sequant 2.9.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +14 -3
  4. package/dist/bin/cli.js +97 -17
  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.js +2 -0
  36. package/dist/src/commands/logs.js +14 -5
  37. package/dist/src/commands/merge.js +154 -2
  38. package/dist/src/commands/ready-tui-adapter.js +6 -1
  39. package/dist/src/commands/ready.d.ts +25 -3
  40. package/dist/src/commands/ready.js +39 -13
  41. package/dist/src/commands/resume.d.ts +113 -0
  42. package/dist/src/commands/resume.js +193 -0
  43. package/dist/src/commands/run-display.js +37 -4
  44. package/dist/src/commands/run-flags.d.ts +42 -1
  45. package/dist/src/commands/run-flags.js +53 -1
  46. package/dist/src/commands/run-progress.js +26 -1
  47. package/dist/src/commands/run.js +11 -10
  48. package/dist/src/commands/state.js +7 -0
  49. package/dist/src/commands/status.d.ts +9 -0
  50. package/dist/src/commands/status.js +24 -1
  51. package/dist/src/commands/sync.js +90 -19
  52. package/dist/src/commands/update.js +28 -5
  53. package/dist/src/lib/ac-parser.d.ts +14 -0
  54. package/dist/src/lib/ac-parser.js +99 -5
  55. package/dist/src/lib/assess/index.d.ts +10 -0
  56. package/dist/src/lib/assess/index.js +9 -0
  57. package/dist/src/lib/assess/renderer.d.ts +64 -0
  58. package/dist/src/lib/assess/renderer.js +481 -0
  59. package/dist/src/lib/assess/types.d.ts +224 -0
  60. package/dist/src/lib/assess/types.js +241 -0
  61. package/dist/src/lib/ci/outputs.d.ts +15 -2
  62. package/dist/src/lib/ci/outputs.js +17 -5
  63. package/dist/src/lib/cli-flags.d.ts +33 -0
  64. package/dist/src/lib/cli-flags.js +46 -0
  65. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +21 -7
  66. package/dist/src/lib/cli-ui/run-renderer.d.ts +10 -0
  67. package/dist/src/lib/cli-ui/run-renderer.js +62 -0
  68. package/dist/src/lib/errors.d.ts +78 -6
  69. package/dist/src/lib/errors.js +133 -15
  70. package/dist/src/lib/locks/index.d.ts +3 -3
  71. package/dist/src/lib/locks/index.js +2 -2
  72. package/dist/src/lib/locks/lock-manager.d.ts +20 -2
  73. package/dist/src/lib/locks/lock-manager.js +47 -3
  74. package/dist/src/lib/locks/types.d.ts +34 -3
  75. package/dist/src/lib/locks/types.js +16 -0
  76. package/dist/src/lib/mcp-config.d.ts +45 -0
  77. package/dist/src/lib/mcp-config.js +77 -1
  78. package/dist/src/lib/merge-check/combined-branch-test.d.ts +21 -2
  79. package/dist/src/lib/merge-check/combined-branch-test.js +233 -110
  80. package/dist/src/lib/merge-check/command-result.d.ts +36 -0
  81. package/dist/src/lib/merge-check/command-result.js +56 -0
  82. package/dist/src/lib/merge-check/index.d.ts +4 -0
  83. package/dist/src/lib/merge-check/index.js +1 -1
  84. package/dist/src/lib/merge-check/report.js +5 -1
  85. package/dist/src/lib/merge-check/types.d.ts +9 -0
  86. package/dist/src/lib/merge-check/watch.d.ts +121 -0
  87. package/dist/src/lib/merge-check/watch.js +215 -0
  88. package/dist/src/lib/qa/infra-blocked-ci.d.ts +90 -0
  89. package/dist/src/lib/qa/infra-blocked-ci.js +80 -0
  90. package/dist/src/lib/settings.d.ts +14 -0
  91. package/dist/src/lib/settings.js +61 -8
  92. package/dist/src/lib/shutdown.d.ts +42 -2
  93. package/dist/src/lib/shutdown.js +75 -5
  94. package/dist/src/lib/skills-check.d.ts +26 -0
  95. package/dist/src/lib/skills-check.js +31 -0
  96. package/dist/src/lib/stacks.d.ts +110 -1
  97. package/dist/src/lib/stacks.js +244 -5
  98. package/dist/src/lib/templates.d.ts +74 -0
  99. package/dist/src/lib/templates.js +171 -16
  100. package/dist/src/lib/test-tautology-detector.d.ts +9 -2
  101. package/dist/src/lib/test-tautology-detector.js +153 -12
  102. package/dist/src/lib/version-check.d.ts +31 -0
  103. package/dist/src/lib/version-check.js +45 -2
  104. package/dist/src/lib/workflow/batch-executor.d.ts +125 -6
  105. package/dist/src/lib/workflow/batch-executor.js +477 -44
  106. package/dist/src/lib/workflow/chain-resume.d.ts +13 -11
  107. package/dist/src/lib/workflow/chain-resume.js +24 -20
  108. package/dist/src/lib/workflow/completed-status.d.ts +52 -0
  109. package/dist/src/lib/workflow/completed-status.js +57 -0
  110. package/dist/src/lib/workflow/config-resolver.d.ts +26 -0
  111. package/dist/src/lib/workflow/config-resolver.js +54 -2
  112. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +7 -0
  113. package/dist/src/lib/workflow/drivers/aider.d.ts +2 -0
  114. package/dist/src/lib/workflow/drivers/aider.js +2 -0
  115. package/dist/src/lib/workflow/drivers/claude-code.d.ts +14 -0
  116. package/dist/src/lib/workflow/drivers/claude-code.js +20 -2
  117. package/dist/src/lib/workflow/git-diff-utils.d.ts +35 -3
  118. package/dist/src/lib/workflow/git-diff-utils.js +77 -4
  119. package/dist/src/lib/workflow/heartbeat.d.ts +38 -0
  120. package/dist/src/lib/workflow/heartbeat.js +90 -0
  121. package/dist/src/lib/workflow/log-writer.d.ts +44 -1
  122. package/dist/src/lib/workflow/log-writer.js +75 -6
  123. package/dist/src/lib/workflow/metrics-schema.d.ts +3 -3
  124. package/dist/src/lib/workflow/phase-executor.d.ts +203 -14
  125. package/dist/src/lib/workflow/phase-executor.js +457 -52
  126. package/dist/src/lib/workflow/phase-registry.js +2 -1
  127. package/dist/src/lib/workflow/platforms/github.d.ts +64 -0
  128. package/dist/src/lib/workflow/platforms/github.js +152 -0
  129. package/dist/src/lib/workflow/qa-cache.d.ts +6 -1
  130. package/dist/src/lib/workflow/qa-cache.js +22 -9
  131. package/dist/src/lib/workflow/ready-gate.d.ts +17 -4
  132. package/dist/src/lib/workflow/ready-gate.js +25 -9
  133. package/dist/src/lib/workflow/run-log-schema.d.ts +14 -0
  134. package/dist/src/lib/workflow/run-log-schema.js +47 -1
  135. package/dist/src/lib/workflow/run-orchestrator.d.ts +19 -0
  136. package/dist/src/lib/workflow/run-orchestrator.js +141 -20
  137. package/dist/src/lib/workflow/skills-preflight.d.ts +89 -0
  138. package/dist/src/lib/workflow/skills-preflight.js +112 -0
  139. package/dist/src/lib/workflow/state-cleanup.js +9 -2
  140. package/dist/src/lib/workflow/state-manager.d.ts +33 -0
  141. package/dist/src/lib/workflow/state-manager.js +83 -0
  142. package/dist/src/lib/workflow/state-schema.d.ts +26 -0
  143. package/dist/src/lib/workflow/state-schema.js +47 -0
  144. package/dist/src/lib/workflow/types.d.ts +126 -4
  145. package/dist/src/lib/workflow/types.js +1 -0
  146. package/dist/src/lib/workflow/worktree-manager.d.ts +28 -2
  147. package/dist/src/lib/workflow/worktree-manager.js +102 -23
  148. package/dist/src/mcp/tools/run.d.ts +9 -1
  149. package/dist/src/mcp/tools/run.js +20 -1
  150. package/dist/src/mcp/tools/status.js +6 -0
  151. package/dist/src/ui/tui/ElapsedTimer.d.ts +12 -3
  152. package/dist/src/ui/tui/ElapsedTimer.js +12 -10
  153. package/dist/src/ui/tui/IssueBox.js +10 -2
  154. package/dist/src/ui/tui/row-cap.js +2 -1
  155. package/dist/src/ui/tui/theme.d.ts +7 -0
  156. package/dist/src/ui/tui/theme.js +9 -0
  157. package/package.json +8 -7
  158. package/templates/hooks/parallel-marker.sh +50 -0
  159. package/templates/hooks/post-tool.sh +20 -7
  160. package/templates/hooks/pre-tool.sh +75 -10
  161. package/templates/scripts/cleanup-worktree.sh +182 -19
  162. package/templates/scripts/new-feature.sh +264 -16
  163. package/templates/skills/_shared/references/trust-model.md +18 -0
  164. package/templates/skills/assess/SKILL.md +215 -390
  165. package/templates/skills/assess/references/predicted-collision-detection.md +1 -1
  166. package/templates/skills/docs/SKILL.md +4 -4
  167. package/templates/skills/exec/SKILL.md +26 -6
  168. package/templates/skills/fullsolve/SKILL.md +3 -1
  169. package/templates/skills/loop/SKILL.md +38 -2
  170. package/templates/skills/merger/SKILL.md +2 -2
  171. package/templates/skills/qa/SKILL.md +337 -57
  172. package/templates/skills/qa/references/anti-pattern-detection.md +6 -6
  173. package/templates/skills/qa/references/call-site-review.md +2 -2
  174. package/templates/skills/qa/references/code-review-checklist.md +2 -2
  175. package/templates/skills/qa/references/fixtures/injection-issue-body.md +36 -0
  176. package/templates/skills/qa/references/quality-gates.md +21 -7
  177. package/templates/skills/qa/references/testing-requirements.md +1 -1
  178. package/templates/skills/qa/scripts/quality-checks.sh +11 -11
  179. package/templates/skills/release/SKILL.md +1 -1
  180. package/templates/skills/setup/SKILL.md +5 -5
  181. package/templates/skills/spec/SKILL.md +2 -0
  182. package/templates/skills/test/SKILL.md +1 -1
  183. package/dist/src/lib/phase-spinner.d.ts +0 -146
  184. package/dist/src/lib/phase-spinner.js +0 -255
  185. package/dist/src/lib/workflow/pr-operations.d.ts +0 -86
  186. package/dist/src/lib/workflow/pr-operations.js +0 -326
  187. package/dist/src/lib/workflow/run-summary.d.ts +0 -36
  188. package/dist/src/lib/workflow/run-summary.js +0 -142
@@ -11,12 +11,16 @@ import chalk from "chalk";
11
11
  import { spawnSync } from "child_process";
12
12
  import { createPhaseLogFromTiming } from "./log-writer.js";
13
13
  import { classifyError, errorTypeToCategory, } from "./error-classifier.js";
14
- import { getGitDiffStats, getCommitHash } from "./git-diff-utils.js";
14
+ import { getGitDiffStats, getCommitHash, resolveDiffBase, } from "./git-diff-utils.js";
15
15
  import { createCheckpointCommit, rebaseBeforePR, createPR, readCacheMetrics, filterResumedPhases, } from "./worktree-manager.js";
16
- import { executePhaseWithRetry } from "./phase-executor.js";
16
+ import { AUTO_WAIT_BUFFER_MS, createAutoWaitLedger, executePhaseWithRetry, isWindowExhaustedRateLimit, } from "./phase-executor.js";
17
+ import { BillingError, RateLimitError, resetsAtToMs } from "../errors.js";
17
18
  import { parseBodyDependencyMarkers } from "./dependency-markers.js";
18
19
  import { detectPhasesFromLabels, parseRecommendedWorkflow, determinePhasesForIssue, DOCS_LABELS, } from "./phase-mapper.js";
19
20
  import { activateRelay, deactivateRelay, } from "../relay/activation.js";
21
+ import { getSettings } from "../settings.js";
22
+ import { GitHubProvider } from "./platforms/github.js";
23
+ import { runReadyGate, parseNonGoals, } from "./ready-gate.js";
20
24
  /**
21
25
  * Emit a structured progress line to stderr for MCP progress notifications.
22
26
  * Only emits when running under an orchestrator (e.g., MCP server).
@@ -28,29 +32,95 @@ import { activateRelay, deactivateRelay, } from "../relay/activation.js";
28
32
  * @param extra - Optional fields: durationSeconds (on complete), error (on failed)
29
33
  */
30
34
  /**
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).
35
+ * Wrap an `ExecutionConfig` with the runtime liveness hooks:
36
+ * - `onActivity` — re-emits each agent-output ping as an `"activity"` progress
37
+ * event for the dashboard (#543).
38
+ * - `onAutoWait` — re-emits each auto-wait tick as a `"waiting"` progress
39
+ * event so the renderer and heartbeat can show the pause and its wake time
40
+ * (#804 AC-7). Under `SEQUANT_ORCHESTRATOR` it additionally emits throttled
41
+ * `SEQUANT_PROGRESS` waiting lines (#860): an MCP-driven wait was previously
42
+ * invisible on the JSON channel — indistinguishable from a hang — AND was
43
+ * killed by the MCP inactivity timeout, which resets on progress lines.
44
+ * Optionally notifies `onWaitTransition` on wait start/end so the caller
45
+ * can persist the wait to issue state (`sequant status` truthfulness).
33
46
  *
34
- * Returns the input config unchanged when no `onProgress` callback is set,
35
- * so non-TUI runs pay no overhead.
47
+ * Returns the input config unchanged when there is no consumer at all (no
48
+ * `onProgress`, no orchestrator channel, no transition callback), so plain
49
+ * non-TUI runs pay no overhead.
36
50
  *
37
51
  * @internal Exported for testing only
38
52
  */
39
- export function withActivityHook(base, issueNumber, phase, onProgress) {
40
- if (!onProgress)
53
+ export function withActivityHook(base, issueNumber, phase, onProgress, onWaitTransition) {
54
+ const orchestrated = Boolean(process.env.SEQUANT_ORCHESTRATOR);
55
+ if (!onProgress && !orchestrated && !onWaitTransition)
41
56
  return base;
57
+ // Throttle the orchestrator waiting lines: the wait ticks every ~15s, and
58
+ // one JSON line per minute is enough to keep the MCP inactivity timeout
59
+ // alive (it resets on every SEQUANT_PROGRESS line) without bloating the
60
+ // captured stderr over a multi-hour pause.
61
+ let lastWaitLineAt = 0;
62
+ let waitAnnounced = false;
42
63
  return {
43
64
  ...base,
44
65
  onActivity: (text) => {
45
66
  try {
46
- onProgress(issueNumber, phase, "activity", { text });
67
+ onProgress?.(issueNumber, phase, "activity", { text });
47
68
  }
48
69
  catch {
49
70
  // Activity events must never disrupt the run.
50
71
  }
51
72
  },
73
+ onAutoWait: (notice) => {
74
+ try {
75
+ onProgress?.(issueNumber, phase, "waiting", {
76
+ text: notice.message,
77
+ // Omitted on the terminal notice — its absence is what tells the
78
+ // consumers to clear the waiting state.
79
+ wakeAtMs: notice.done ? undefined : notice.wakeAtMs,
80
+ });
81
+ }
82
+ catch {
83
+ // Liveness notices must never disrupt the run.
84
+ }
85
+ try {
86
+ if (notice.done) {
87
+ if (waitAnnounced) {
88
+ waitAnnounced = false;
89
+ lastWaitLineAt = 0;
90
+ emitProgressLine(issueNumber, phase, "waiting", {
91
+ remainingMs: 0,
92
+ });
93
+ onWaitTransition?.(null);
94
+ }
95
+ }
96
+ else {
97
+ if (!waitAnnounced) {
98
+ waitAnnounced = true;
99
+ onWaitTransition?.(notice.wakeAtMs);
100
+ }
101
+ const now = Date.now();
102
+ if (now - lastWaitLineAt >= AUTO_WAIT_PROGRESS_LINE_INTERVAL_MS) {
103
+ lastWaitLineAt = now;
104
+ emitProgressLine(issueNumber, phase, "waiting", {
105
+ wakeAtMs: notice.wakeAtMs,
106
+ remainingMs: notice.remainingMs,
107
+ });
108
+ }
109
+ }
110
+ }
111
+ catch {
112
+ // Liveness notices must never disrupt the run.
113
+ }
114
+ },
52
115
  };
53
116
  }
117
+ /**
118
+ * Cadence of orchestrator-channel waiting lines during an auto-wait (#860).
119
+ * See {@link withActivityHook}.
120
+ *
121
+ * @internal Exported for testing only
122
+ */
123
+ export const AUTO_WAIT_PROGRESS_LINE_INTERVAL_MS = 60_000;
54
124
  /**
55
125
  * Build enriched prompt context for the /loop phase from a failed phase result (#488).
56
126
  * Passes QA verdict, failed ACs, and error directly so the /loop skill doesn't need
@@ -94,6 +164,17 @@ export function emitProgressLine(issue, phase, event = "start", extra) {
94
164
  if (extra?.iteration !== undefined) {
95
165
  payload.iteration = extra.iteration;
96
166
  }
167
+ // #860: auto-wait liveness. `wakeAtMs` is present while waiting and absent
168
+ // on the terminal notice (`remainingMs: 0`), mirroring the in-process
169
+ // ProgressCallback convention. Every line — waiting included — resets the
170
+ // MCP inactivity timeout (prefix-matched in spawnAsync), which is what
171
+ // keeps a legitimate multi-hour pause from being killed as "no progress".
172
+ if (extra?.wakeAtMs !== undefined) {
173
+ payload.wakeAtMs = extra.wakeAtMs;
174
+ }
175
+ if (extra?.remainingMs !== undefined) {
176
+ payload.remainingMs = extra.remainingMs;
177
+ }
97
178
  const line = `SEQUANT_PROGRESS:${JSON.stringify(payload)}\n`;
98
179
  process.stderr.write(line);
99
180
  }
@@ -254,8 +335,53 @@ export function getEnvConfig() {
254
335
  if (process.env.SEQUANT_SECURITY_REVIEW === "true") {
255
336
  config.securityReview = true;
256
337
  }
338
+ // #804: the env layer for --auto-wait. `resolveRunOptions` does NOT route
339
+ // through the `ConfigResolver` class (it uses `??` chains + this function),
340
+ // so numeric coercion is explicit here rather than free via `coerceEnvValue`.
341
+ // Non-numeric and negative values are ignored so a typo cannot silently
342
+ // enable an unbounded wait.
343
+ if (process.env.SEQUANT_AUTO_WAIT_MINUTES) {
344
+ const autoWait = parseInt(process.env.SEQUANT_AUTO_WAIT_MINUTES, 10);
345
+ if (!isNaN(autoWait) && autoWait >= 0) {
346
+ config.autoWaitMinutes = autoWait;
347
+ }
348
+ }
257
349
  return config;
258
350
  }
351
+ /**
352
+ * Record an issue's completion in the run log in ONE place (#879): PR info, the
353
+ * PR-failure status flip, then finalize. Extracted so every batch loop shares a
354
+ * single completion sequence and cannot drift.
355
+ *
356
+ * The #879 defect was exactly such a drift: `markIssueFailed` was wired into
357
+ * `executeBatch`'s loop, but the live `sequant run` path is
358
+ * `RunOrchestrator.executeOneIssue`, which called `setPRInfo` + `completeIssue`
359
+ * without it — so a real run left the run-log status at `success` on a
360
+ * PR-creation failure. Both call sites now go through this helper.
361
+ *
362
+ * A PR-creation failure occurs after every phase has been logged, so
363
+ * `deriveIssueLogStatus` (last run at phase-log time) leaves the issue at
364
+ * `success`; the flip here is what counts it under `failed`. Safe post-hoc:
365
+ * no further phase is logged before `completeIssue`.
366
+ */
367
+ export function recordIssueCompletion(logWriter, result, issueNumber) {
368
+ if (result.prNumber && result.prUrl) {
369
+ logWriter.setPRInfo(result.prNumber, result.prUrl, issueNumber);
370
+ }
371
+ if (result.prCreationError) {
372
+ logWriter.markIssueFailed(issueNumber);
373
+ }
374
+ logWriter.completeIssue(issueNumber);
375
+ }
376
+ /**
377
+ * @deprecated No live caller — `sequant run` executes issues via
378
+ * `RunOrchestrator.executeOneIssue`; this survives only as a
379
+ * `commands/run-compat` re-export. Do not build a new execution loop on it:
380
+ * any path that completes an issue MUST go through
381
+ * {@link recordIssueCompletion}, or the #879 status-drift returns (a
382
+ * completion path that skips the PR-failure flip logs a failed issue as
383
+ * `success`). Slated for removal with the run-compat surface.
384
+ */
259
385
  export async function executeBatch(issueNumbers, batchCtx) {
260
386
  const { config, options, issueInfoMap, worktreeMap, logWriter, stateManager, shutdownManager, packageManager, baseBranch, onProgress, onPhasePlan, phasePauseHandle, } = batchCtx;
261
387
  const results = [];
@@ -291,13 +417,10 @@ export async function executeBatch(issueNumbers, batchCtx) {
291
417
  };
292
418
  const result = await runIssueWithLogging(ctx);
293
419
  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
420
+ // Record PR info, flip status on PR failure (#879), and finalize — all via
421
+ // the shared helper so this loop and the orchestrator path cannot drift.
299
422
  if (logWriter) {
300
- logWriter.completeIssue();
423
+ recordIssueCompletion(logWriter, result, issueNumber);
301
424
  }
302
425
  }
303
426
  return results;
@@ -323,6 +446,192 @@ export function deriveFailureCategory(phaseResults) {
323
446
  classifyError(failedPhase.stderrTail ?? [], failedPhase.exitCode);
324
447
  return errorTypeToCategory(typedError);
325
448
  }
449
+ /**
450
+ * "Halt, don't loop" predicate for the outer `-Q` quality loop (#799).
451
+ *
452
+ * A billing / out-of-credits failure (`BillingError`) or a window-exhausted
453
+ * rate limit (reset hours away, per `isWindowExhaustedRateLimit`) cannot be
454
+ * recovered by re-running the phase — every retry re-spawns into the same
455
+ * closed window and, worse, mislabels the halt as a downstream
456
+ * `QA completed without a parseable verdict`. Mirrors the `haltedByCap` (#739)
457
+ * treatment: surface the real cause and halt so the user resumes once credits
458
+ * or the rate-limit window are restored.
459
+ *
460
+ * A transient / metadata-absent rate limit is NOT a halt: it returns false and
461
+ * keeps today's outer-loop behavior (the inner retry ladder in `phase-executor`
462
+ * handles its backoff, per #761 AC-4/AC-9).
463
+ *
464
+ * @internal Exported for testing
465
+ */
466
+ export function isBillingOrWindowHalt(result) {
467
+ return isBillingHalt(result) || isWindowHalt(result);
468
+ }
469
+ /**
470
+ * The billing half of {@link isBillingOrWindowHalt} (#804 AC-8).
471
+ *
472
+ * Split out because the two causes stopped being interchangeable once
473
+ * `--auto-wait` existed: a closed window can now reopen on its own, while
474
+ * out-of-credits cannot — credits are purchased, not waited out. Callers that
475
+ * need to reason about recoverability must be able to tell them apart.
476
+ *
477
+ * @internal Exported for testing
478
+ */
479
+ export function isBillingHalt(result) {
480
+ return result.structuredError instanceof BillingError;
481
+ }
482
+ /**
483
+ * The rate-limit-window half of {@link isBillingOrWindowHalt} (#804 AC-8).
484
+ *
485
+ * NOTE — this predicate needed no behavioral change for auto-wait, and that is
486
+ * a deliberate finding rather than an oversight. AC-8 anticipated that a phase
487
+ * which waits and then succeeds would still halt the `-Q` loop. It cannot:
488
+ * every call site (`:~700` spec, `:~1030` progress label, `:~1150` halt flag)
489
+ * sits inside the `else` of an `if (result.success)`, so a successful
490
+ * post-wait result never reaches this predicate at all. When auto-wait does
491
+ * NOT fire — the default, an exhausted budget, or a spent wait bound — the
492
+ * result is still a failure carrying a window-exhausted `RateLimitError`, and
493
+ * halting is then the correct outcome (#799 behavior, preserved exactly).
494
+ *
495
+ * @internal Exported for testing
496
+ */
497
+ export function isWindowHalt(result) {
498
+ return isWindowExhaustedRateLimit(result.structuredError);
499
+ }
500
+ /**
501
+ * Human-readable halt reason for a billing / rate-limit-window failure (#799
502
+ * AC-3). Surfaces the driver's real cause verbatim — `result.error` is already
503
+ * the well-formatted message the driver built via `formatRateLimitMessage`
504
+ * (`Out of credits` for billing, `Rate limited — resets at <local time>` for a
505
+ * throttle with a known reset), so the phase-failed line and run summary name
506
+ * the actual cause instead of a downstream `QA completed without a parseable
507
+ * verdict`.
508
+ *
509
+ * Do NOT re-append `resetsAt` here: the rate-limit message already carries the
510
+ * reset time, and doing so produced a doubled, timezone-inconsistent string
511
+ * (`… resets at 07-24 14:32 — resets at 2026-…Z`). Credits failures carry no
512
+ * reset time by design (they need purchasing, not a window wait).
513
+ *
514
+ * @internal Exported for testing
515
+ */
516
+ export function billingHaltReason(result) {
517
+ return result.error ?? "Out of credits";
518
+ }
519
+ /**
520
+ * Epoch ms after which a waitable-window halt can be re-entered (#892 AC-1):
521
+ * the window's `resetsAt` normalized to ms plus the same buffer auto-wait
522
+ * applies (`AUTO_WAIT_BUFFER_MS`), so in-process waits and durable halts wake
523
+ * on the same clock. Returns `null` when the result carries no future-reset
524
+ * rate-limit window — callers must then skip the `windowHalt` write rather
525
+ * than invent a resume time.
526
+ *
527
+ * @internal Exported for testing
528
+ */
529
+ export function windowHaltResumeAtMs(result) {
530
+ if (!isWindowHalt(result))
531
+ return null;
532
+ const err = result.structuredError;
533
+ if (!(err instanceof RateLimitError))
534
+ return null;
535
+ const resetsAt = err.metadata.resetsAt;
536
+ if (typeof resetsAt !== "number")
537
+ return null;
538
+ return resetsAtToMs(resetsAt) + AUTO_WAIT_BUFFER_MS;
539
+ }
540
+ /**
541
+ * Persist or clear the durable `windowHalt` record for a phase result (#892).
542
+ *
543
+ * A waitable-window failure writes `resumeAt` (preserving any re-entry count);
544
+ * every other outcome — success, or a failure whose cause is not a waitable
545
+ * window — clears the record so `sequant resume` never re-enters on a stale
546
+ * or non-waitable halt. Never throws: state bookkeeping must not mask the
547
+ * phase result it describes.
548
+ */
549
+ async function recordWindowHaltState(stateManager, issueNumber, phase, result) {
550
+ if (!stateManager)
551
+ return;
552
+ try {
553
+ const resumeAtMs = result.success ? null : windowHaltResumeAtMs(result);
554
+ if (resumeAtMs !== null) {
555
+ await stateManager.updateWindowHalt(issueNumber, phase, resumeAtMs);
556
+ }
557
+ else {
558
+ await stateManager.clearWindowHalt(issueNumber);
559
+ }
560
+ }
561
+ catch {
562
+ // State tracking errors shouldn't stop execution
563
+ }
564
+ }
565
+ /**
566
+ * Run the post-QA ready gate (#817) for a single issue at the run path's
567
+ * post-success / pre-PR seam.
568
+ *
569
+ * Mirrors `src/commands/ready.ts`'s driver: resolve the policy from
570
+ * `settings.ready.policy` (no per-run override — AC-4 forbids new surface),
571
+ * parse the issue's Non-Goals for report-only classification, wrap
572
+ * `executePhaseWithRetry` as the gate's phase runner, and delegate the whole
573
+ * qa→loop→qa loop to `runReadyGate`. The token budget stays disabled (parity
574
+ * with `sequant ready` invoked without `--budget`); the `maxIterations` cap
575
+ * already bounds cost.
576
+ *
577
+ * A gate failure is non-fatal: the standard-phase work is already committed to
578
+ * the worktree, so we log a warning and fall through to normal PR creation
579
+ * rather than aborting the run (the issue then keeps its `ready_for_merge`
580
+ * status — the run has degraded to a standard run, and nothing about the work
581
+ * is actually blocked).
582
+ *
583
+ * The failure is returned rather than swallowed. A dropped gate must not be
584
+ * invisible: the caller opted in with `--ready-gate`, so a run whose gate never
585
+ * executed has to look different in the summary from one that gated cleanly —
586
+ * otherwise a crashed gate is indistinguishable from an approved one, and the
587
+ * whole point of the flag (a second look actually happened) is silently lost.
588
+ */
589
+ async function runReadyGateForIssue(args) {
590
+ const { issueNumber, worktreePath, config, shutdownManager, phasePauseHandle, onProgress, log, } = args;
591
+ const runGate = args.runGate ?? runReadyGate;
592
+ const getSettingsFn = args.getSettingsFn ?? getSettings;
593
+ const fetchBody = args.fetchBody ??
594
+ ((n) => new GitHubProvider().fetchIssueBodySync(String(n)));
595
+ try {
596
+ const settings = await getSettingsFn();
597
+ const policy = settings.ready.policy;
598
+ // Non-Goals feed the gate's report-only classification (ac mode never
599
+ // auto-fixes Non-Goal-touching findings). Best-effort — an unavailable
600
+ // body just yields no Non-Goals.
601
+ const body = fetchBody(issueNumber);
602
+ const nonGoals = body ? parseNonGoals(body) : [];
603
+ // The gate's phase runner: same executePhaseWithRetry wrapper ready.ts
604
+ // uses, bound to this issue's worktree, shutdown manager, and pause handle.
605
+ const runPhase = (phase, phaseConfig, wt) => executePhaseWithRetry(issueNumber, phase, phaseConfig, undefined, wt, shutdownManager, phasePauseHandle);
606
+ log(chalk.blue(`\n Ready gate (#817) — policy: ${policy}, max iterations: ${config.maxIterations}`));
607
+ const result = await runGate({
608
+ issueNumber,
609
+ worktreePath,
610
+ policy,
611
+ maxIterations: config.maxIterations,
612
+ // AC-4: budget stays disabled on the run path (parity with `ready` sans
613
+ // `--budget`); maxIterations bounds cost.
614
+ tokenBudget: undefined,
615
+ nonGoals,
616
+ phaseTimeout: config.phaseTimeout,
617
+ mcp: config.mcp,
618
+ verbose: config.verbose,
619
+ runPhase,
620
+ onProgress,
621
+ });
622
+ log(result.ready
623
+ ? chalk.green(` ✓ Ready gate: ${result.reason} — awaiting human merge (never merged)`)
624
+ : chalk.yellow(` ⚠️ Ready gate halted: ${result.reason} — needs human review`));
625
+ return { result };
626
+ }
627
+ catch (err) {
628
+ // Non-fatal: keep the run going to PR with the standard status, but hand
629
+ // the reason back so the summary can say the gate did NOT run.
630
+ const error = err instanceof Error ? err.message : String(err);
631
+ log(chalk.yellow(` ⚠️ Ready gate failed for #${issueNumber}: ${error} — continuing to PR without the gate.`));
632
+ return { error };
633
+ }
634
+ }
326
635
  export async function runIssueWithLogging(ctx) {
327
636
  // Destructure context for use throughout the function
328
637
  const { issueNumber, config, options, title: issueTitle, labels, services: { logWriter, stateManager, shutdownManager }, worktree, chain, packageManager, baseBranch, onProgress, onPhasePlan, phasePauseHandle, } = ctx;
@@ -335,6 +644,10 @@ export async function runIssueWithLogging(ctx) {
335
644
  let loopTriggered = false;
336
645
  // Cross-phase resume token, driver-tagged and cwd-bound (#674).
337
646
  let resumeHandle;
647
+ // #804 AC-6: ONE ledger for the whole issue. Created here rather than inside
648
+ // `executePhaseWithRetry` because the bound and the budget are per-issue —
649
+ // a per-phase ledger would silently grant every phase its own full budget.
650
+ const autoWaitLedger = createAutoWaitLedger(config.autoWaitMinutes);
338
651
  // In parallel mode, suppress per-issue terminal output to prevent interleaving.
339
652
  // The caller (run.ts) handles progress display via updateProgress().
340
653
  const log = config.parallel ? () => { } : console.log.bind(console);
@@ -368,6 +681,19 @@ export async function runIssueWithLogging(ctx) {
368
681
  }
369
682
  }
370
683
  }
684
+ // #860: persist auto-wait transitions to issue state so `sequant status`
685
+ // reports "waiting until <wake>" instead of an hours-stale in-progress
686
+ // phase. Fire-and-forget — state bookkeeping must never disturb the wait
687
+ // it describes.
688
+ const makeWaitTransition = (phase) => stateManager
689
+ ? (wakeAtMs) => {
690
+ void stateManager
691
+ .updateAutoWait(issueNumber, phase, wakeAtMs)
692
+ .catch(() => {
693
+ // Never let state bookkeeping disturb a live wait.
694
+ });
695
+ }
696
+ : undefined;
371
697
  // Activate relay (#383) if enabled. Tolerates errors — relay must never
372
698
  // block the underlying run.
373
699
  let relayActivation = null;
@@ -420,8 +746,10 @@ export async function runIssueWithLogging(ctx) {
420
746
  }
421
747
  const specStartTime = new Date();
422
748
  // 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);
749
+ const specResult = await executePhaseWithRetry(issueNumber, "spec", withActivityHook(config, issueNumber, "spec", onProgress, makeWaitTransition("spec")), resumeHandle, worktreePath, // Will be ignored for spec (non-isolated phase)
750
+ shutdownManager, phasePauseHandle, undefined, // executePhaseFn — use the default
751
+ undefined, // delayFn — use the default
752
+ autoWaitLedger);
425
753
  const specEndTime = new Date();
426
754
  if (specResult.resumeHandle) {
427
755
  resumeHandle = specResult.resumeHandle;
@@ -450,16 +778,19 @@ export async function runIssueWithLogging(ctx) {
450
778
  }
451
779
  }
452
780
  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.
781
+ // Mirror the main phase loop (#739/#799): a turn-capped spec phase surfaces
782
+ // the distinct "partial output preserved" signal, and a billing /
783
+ // rate-limit-window failure names the real cause so both are recognizable
784
+ // on the spec path too (it has its own failure handling, separate from the
785
+ // main loop). The spec phase already halts on any failure via the early
786
+ // return below; routing billing through billingHaltReason only keeps the
787
+ // message/fallback symmetric with the main loop.
459
788
  const extra = {
460
789
  error: specResult.capped
461
790
  ? "turn cap reached — partial output preserved (resume to continue)"
462
- : (specResult.error ?? "unknown"),
791
+ : isBillingOrWindowHalt(specResult)
792
+ ? billingHaltReason(specResult)
793
+ : (specResult.error ?? "unknown"),
463
794
  };
464
795
  emitProgressLine(issueNumber, "spec", "failed", extra);
465
796
  try {
@@ -517,6 +848,9 @@ export async function runIssueWithLogging(ctx) {
517
848
  // State tracking errors shouldn't stop execution
518
849
  }
519
850
  }
851
+ // Durable halt-and-resume (#892 AC-1): a waitable-window spec halt writes
852
+ // `resumeAt` so `sequant resume` can re-enter after the window reopens.
853
+ await recordWindowHaltState(stateManager, issueNumber, "spec", specResult);
520
854
  if (!specResult.success) {
521
855
  const durationSeconds = (Date.now() - startTime) / 1000;
522
856
  // Archive relay state on early exit (spec failure).
@@ -640,6 +974,11 @@ export async function runIssueWithLogging(ctx) {
640
974
  // retry too, not just the inner /loop spawn — re-running a capped phase
641
975
  // would only cap again, and "surface + halt" means the user resumes.
642
976
  let haltedByCap = false;
977
+ // Set when a phase fails with a billing / out-of-credits error or a
978
+ // window-exhausted rate limit (#799): like the turn cap, re-running the phase
979
+ // (or spawning /loop) cannot succeed while the window is closed, so halt the
980
+ // outer quality loop and let the user resume once credits/window are restored.
981
+ let haltedByBilling = false;
643
982
  while (iteration < maxIterations) {
644
983
  iteration++;
645
984
  if (useQualityLoop && iteration > 1) {
@@ -672,7 +1011,9 @@ export async function runIssueWithLogging(ctx) {
672
1011
  }
673
1012
  }
674
1013
  const phaseStartTime = new Date();
675
- const result = await executePhaseWithRetry(issueNumber, phase, withActivityHook(issueConfig, issueNumber, phase, onProgress), resumeHandle, worktreePath, shutdownManager, phasePauseHandle);
1014
+ const result = await executePhaseWithRetry(issueNumber, phase, withActivityHook(issueConfig, issueNumber, phase, onProgress, makeWaitTransition(phase)), resumeHandle, worktreePath, shutdownManager, phasePauseHandle, undefined, // executePhaseFn — use the default
1015
+ undefined, // delayFn — use the default
1016
+ autoWaitLedger);
676
1017
  const phaseEndTime = new Date();
677
1018
  // Capture resume handle for subsequent phases (#674).
678
1019
  if (result.resumeHandle) {
@@ -709,7 +1050,12 @@ export async function runIssueWithLogging(ctx) {
709
1050
  const extra = {
710
1051
  error: result.capped
711
1052
  ? "turn cap reached — partial output preserved (resume to continue)"
712
- : (result.error ?? "unknown"),
1053
+ : isBillingOrWindowHalt(result)
1054
+ ? // Billing / rate-limit-window halt (#799): name the real cause so
1055
+ // the run summary doesn't cascade into a downstream
1056
+ // `QA completed without a parseable verdict`.
1057
+ billingHaltReason(result)
1058
+ : (result.error ?? "unknown"),
713
1059
  iteration,
714
1060
  };
715
1061
  emitProgressLine(issueNumber, phase, "failed", extra);
@@ -722,13 +1068,21 @@ export async function runIssueWithLogging(ctx) {
722
1068
  }
723
1069
  // Log phase result with observability data (AC-1, AC-2, AC-3, AC-7)
724
1070
  if (logWriter) {
1071
+ // Resolve the diff base once (#878): worktrees branch from
1072
+ // origin/<base>, so both the diff stats and the phase-commit check
1073
+ // must compare against the resolved ref, not the local branch name.
1074
+ const resolvedDiffBase = worktreePath
1075
+ ? resolveDiffBase(worktreePath, baseBranch ?? "main")
1076
+ : undefined;
725
1077
  // Capture git diff stats for worktree phases (AC-1, AC-3)
726
- const diffStats = worktreePath
727
- ? getGitDiffStats(worktreePath, baseBranch)
1078
+ const diffStats = worktreePath && resolvedDiffBase
1079
+ ? getGitDiffStats(worktreePath, resolvedDiffBase)
728
1080
  : undefined;
729
- // Capture commit hash after phase (AC-2)
1081
+ // Capture commit hash after phase (AC-2) — undefined when the branch
1082
+ // never moved off its base, so a base tip is not logged as the
1083
+ // phase's commit (#878).
730
1084
  const commitHash = worktreePath
731
- ? getCommitHash(worktreePath)
1085
+ ? getCommitHash(worktreePath, resolvedDiffBase)
732
1086
  : undefined;
733
1087
  // Read cache metrics for QA phase (AC-7)
734
1088
  const cacheMetrics = phase === "qa" ? readCacheMetrics(worktreePath) : undefined;
@@ -789,6 +1143,9 @@ export async function runIssueWithLogging(ctx) {
789
1143
  // State tracking errors shouldn't stop execution
790
1144
  }
791
1145
  }
1146
+ // Durable halt-and-resume (#892 AC-1): a waitable-window halt writes
1147
+ // `resumeAt`; success or a non-window failure clears any stale record.
1148
+ await recordWindowHaltState(stateManager, issueNumber, phase, result);
792
1149
  if (result.success) {
793
1150
  // Phase succeeded — RunRenderer (#618) updates state via onProgress.
794
1151
  }
@@ -797,12 +1154,24 @@ export async function runIssueWithLogging(ctx) {
797
1154
  if (result.capped) {
798
1155
  haltedByCap = true;
799
1156
  }
1157
+ // Billing / rate-limit-window failure (#799): halt the outer quality
1158
+ // loop for the same reason as the turn cap — re-running the phase or
1159
+ // spawning /loop cannot succeed while credits/window are exhausted, and
1160
+ // doing so mislabels the halt as a downstream unparseable-verdict error.
1161
+ if (isBillingOrWindowHalt(result)) {
1162
+ haltedByBilling = true;
1163
+ }
800
1164
  // If quality loop enabled, run loop phase to fix issues.
801
1165
  // A turn-capped phase (#739) is incomplete, not a genuine quality
802
1166
  // failure: skip the loop and halt cleanly ("surface + halt"). Spawning
803
1167
  // /loop on partial output would act on incomplete work — exactly the
804
1168
  // risk the capped path is meant to avoid. The user resumes instead.
805
- if (useQualityLoop && iteration < maxIterations && !result.capped) {
1169
+ // A billing / rate-limit-window halt (#799) is skipped for the same
1170
+ // reason: /loop would re-spawn into the same closed window.
1171
+ if (useQualityLoop &&
1172
+ iteration < maxIterations &&
1173
+ !result.capped &&
1174
+ !haltedByBilling) {
806
1175
  // #624 Item 3 (AC-3.3): the loop phase carries the current outer
807
1176
  // iteration so the live-zone status cell can show `loop N/M`.
808
1177
  const loopStartExtra = { iteration };
@@ -823,7 +1192,9 @@ export async function runIssueWithLogging(ctx) {
823
1192
  promptContext: buildLoopContext(result),
824
1193
  };
825
1194
  const loopStartTime = new Date();
826
- const loopResult = await executePhaseWithRetry(issueNumber, "loop", withActivityHook(loopConfig, issueNumber, "loop", onProgress), resumeHandle, worktreePath, shutdownManager, phasePauseHandle);
1195
+ const loopResult = await executePhaseWithRetry(issueNumber, "loop", withActivityHook(loopConfig, issueNumber, "loop", onProgress, makeWaitTransition("loop")), resumeHandle, worktreePath, shutdownManager, phasePauseHandle, undefined, // executePhaseFn — use the default
1196
+ undefined, // delayFn — use the default
1197
+ autoWaitLedger);
827
1198
  const loopEndTime = new Date();
828
1199
  phaseResults.push(loopResult);
829
1200
  // #766: record the loop phase in the run log — spec (:655) and the
@@ -884,7 +1255,9 @@ export async function runIssueWithLogging(ctx) {
884
1255
  }
885
1256
  // A turn-capped phase (#739) halts the outer quality-loop retry as well —
886
1257
  // re-running would only cap again; the partial work is already preserved.
887
- if (haltedByCap) {
1258
+ // A billing / rate-limit-window failure (#799) halts for the same reason:
1259
+ // the retry re-spawns into the same closed window and cannot progress.
1260
+ if (haltedByCap || haltedByBilling) {
888
1261
  break;
889
1262
  }
890
1263
  // If we're not in quality loop mode, don't retry
@@ -896,10 +1269,42 @@ export async function runIssueWithLogging(ctx) {
896
1269
  // Success is determined by whether all phases completed in any iteration,
897
1270
  // not whether all accumulated phase results passed (which would fail after loop recovery)
898
1271
  const success = completedSuccessfully;
899
- // Update final issue status in state
1272
+ // #817: opt-in post-QA ready gate. When the standard phases succeed AND
1273
+ // `--ready-gate` was passed, drive the existing `sequant ready` engine
1274
+ // (qa→loop→qa to the configured policy) against this worktree BEFORE
1275
+ // checkpoint/rebase/PR — so the gate's auto-fix commits land in the PR. The
1276
+ // engine NEVER merges; it terminates with the issue `waiting_for_human_merge`
1277
+ // (ready) or `blocked` (guard halt). Without the flag this block is skipped
1278
+ // entirely, keeping the run path byte-identical (AC-5).
1279
+ const readyGateOutcome = config.readyGate && success && worktreePath
1280
+ ? await runReadyGateForIssue({
1281
+ issueNumber,
1282
+ worktreePath,
1283
+ config,
1284
+ shutdownManager,
1285
+ phasePauseHandle,
1286
+ onProgress,
1287
+ log,
1288
+ })
1289
+ : undefined;
1290
+ const readyGateResult = readyGateOutcome?.result;
1291
+ // Surfaced separately from `readyGateResult` so a gate that *crashed* renders
1292
+ // differently in the summary from one that ran — a silently-skipped gate on a
1293
+ // run the user explicitly opted into is the failure mode worth naming.
1294
+ const readyGateError = readyGateOutcome?.error;
1295
+ // Update final issue status in state. When the gate ran it owns the terminal
1296
+ // status (never `ready_for_merge` — that would read as auto-merge-ready and
1297
+ // defeat the human merge gate the gate deliberately stops at).
1298
+ // Hoisted out of the `if (stateManager)` block below because the checkpoint
1299
+ // warning also has to name this status, and naming the wrong one is exactly
1300
+ // the #837 inaccuracy being fixed here.
1301
+ const finalStatus = readyGateResult
1302
+ ? readyGateResult.issueStatus
1303
+ : success
1304
+ ? "ready_for_merge"
1305
+ : "in_progress";
900
1306
  if (stateManager) {
901
1307
  try {
902
- const finalStatus = success ? "ready_for_merge" : "in_progress";
903
1308
  await stateManager.updateIssueStatus(issueNumber, finalStatus);
904
1309
  }
905
1310
  catch {
@@ -910,11 +1315,17 @@ export async function runIssueWithLogging(ctx) {
910
1315
  // #760: chain resume rebases the next link onto this checkpoint, so a failure
911
1316
  // here is not silent — warn prominently and record it on the result (AC-4).
912
1317
  //
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.
1318
+ // Note a completed status was already written above `ready_for_merge`, or
1319
+ // `waiting_for_human_merge` when #817's `--ready-gate` owned the terminal
1320
+ // status (#837) so a re-run reads this link as a completed prefix and does
1321
+ // NOT redo it. Its uncommitted work is therefore absent from the branch tip,
1322
+ // which `computeChainResumePlan` detects (dirty worktree fail fast) rather
1323
+ // than wrong-basing the next link. The message states that outcome exactly:
1324
+ // the work must be committed, or --force.
1325
+ //
1326
+ // A gate that halted (`blocked`) is NOT a completed prefix, so that link is
1327
+ // re-executed on resume rather than skipped — see COMPLETED_STATUSES in
1328
+ // chain-resume.ts.
918
1329
  let checkpointFailed = false;
919
1330
  if (success && chainMode && worktreePath) {
920
1331
  const checkpointOk = createCheckpointCommit(worktreePath, issueNumber, config.verbose, baseBranch);
@@ -922,7 +1333,7 @@ export async function runIssueWithLogging(ctx) {
922
1333
  checkpointFailed = true;
923
1334
  log(chalk.yellow(` ⚠️ Checkpoint commit for #${issueNumber} could not be created — its uncommitted ` +
924
1335
  `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 ` +
1336
+ `${finalStatus}, so a re-run will skip it and refuse to resume the chain here until the ` +
926
1337
  `work is committed in ${worktreePath} (or re-run with --force to redo the whole chain).`));
927
1338
  }
928
1339
  }
@@ -941,6 +1352,10 @@ export async function runIssueWithLogging(ctx) {
941
1352
  // Create PR after successful QA + rebase (unless --no-pr)
942
1353
  let prNumber;
943
1354
  let prUrl;
1355
+ // #879: a PR-creation failure after passing QA must fail the run, not print a
1356
+ // warning and leave the issue at `success`. Recorded here and folded into the
1357
+ // returned `success` below.
1358
+ let prCreationError;
944
1359
  const shouldCreatePR = success && worktreePath && branch && !options.noPr;
945
1360
  if (shouldCreatePR) {
946
1361
  // #605: under --stacked, target predecessor branch (only for non-first,
@@ -954,7 +1369,10 @@ export async function runIssueWithLogging(ctx) {
954
1369
  // #749: surface a non-A+ qa verdict (e.g. AC_MET_BUT_NOT_A_PLUS) in the PR
955
1370
  // body so a reviewer sees why the run broke to PR rather than reaching A+.
956
1371
  const qaVerdict = phaseResults.find((p) => p.phase === "qa")?.verdict;
957
- const prResult = createPR(worktreePath, issueNumber, issueTitle, branch, config.verbose, labels, stackOptions, qaVerdict);
1372
+ const prResult = createPR(worktreePath, issueNumber, issueTitle, branch, config.verbose, labels, stackOptions, qaVerdict,
1373
+ // #817 AC-6: surface the ready-gate outcome in the PR body the same way
1374
+ // `sequant ready` reports it (threshold reached vs guard halt).
1375
+ readyGateResult?.report);
958
1376
  if (prResult.success && prResult.prNumber && prResult.prUrl) {
959
1377
  prNumber = prResult.prNumber;
960
1378
  prUrl = prResult.prUrl;
@@ -971,6 +1389,11 @@ export async function runIssueWithLogging(ctx) {
971
1389
  }
972
1390
  }
973
1391
  }
1392
+ else if (prResult.attempted && !prResult.success) {
1393
+ // #879: PR creation was attempted (branch/QA passed) but failed. This is
1394
+ // a run failure — the deliverable never reached GitHub.
1395
+ prCreationError = prResult.error ?? "PR creation failed";
1396
+ }
974
1397
  }
975
1398
  // Deactivate relay (#383) — archive inbox/outbox transcripts to
976
1399
  // .sequant/logs/relay/ before worktree teardown (AC-D2). Never throws.
@@ -989,15 +1412,25 @@ export async function runIssueWithLogging(ctx) {
989
1412
  }
990
1413
  }
991
1414
  }
1415
+ // #879: fold a PR-creation failure into the issue's overall verdict. Phases
1416
+ // all passed, but the run did not deliver — report it as failed.
1417
+ const overallSuccess = success && !prCreationError;
992
1418
  return {
993
1419
  issueNumber,
994
- success,
1420
+ success: overallSuccess,
995
1421
  phaseResults,
996
1422
  durationSeconds,
997
1423
  loopTriggered,
998
1424
  prNumber,
999
1425
  prUrl,
1426
+ prCreationError,
1000
1427
  checkpointFailed,
1001
- failureCategory: success ? undefined : deriveFailureCategory(phaseResults),
1428
+ failureCategory: overallSuccess
1429
+ ? undefined
1430
+ : deriveFailureCategory(phaseResults),
1431
+ // #817: present only when `--ready-gate` ran the gate; the summary renders
1432
+ // its terminal reason (AC-6).
1433
+ readyGate: readyGateResult,
1434
+ readyGateError,
1002
1435
  };
1003
1436
  }