sequant 2.8.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +23 -4
  4. package/dist/bin/cli.js +99 -18
  5. package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
  6. package/dist/marketplace/external_plugins/sequant/.mcp.json +1 -1
  7. package/dist/marketplace/external_plugins/sequant/README.md +58 -2
  8. package/dist/marketplace/external_plugins/sequant/hooks/parallel-marker.sh +50 -0
  9. package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +38 -10
  10. package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +405 -67
  11. package/dist/marketplace/external_plugins/sequant/skills/_shared/references/trust-model.md +18 -0
  12. package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +250 -344
  13. package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +10 -7
  14. package/dist/marketplace/external_plugins/sequant/skills/docs/SKILL.md +4 -4
  15. package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +26 -6
  16. package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +4 -2
  17. package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +38 -2
  18. package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +2 -2
  19. package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +337 -57
  20. package/dist/marketplace/external_plugins/sequant/skills/qa/references/anti-pattern-detection.md +6 -6
  21. package/dist/marketplace/external_plugins/sequant/skills/qa/references/call-site-review.md +2 -2
  22. package/dist/marketplace/external_plugins/sequant/skills/qa/references/code-review-checklist.md +2 -2
  23. package/dist/marketplace/external_plugins/sequant/skills/qa/references/fixtures/injection-issue-body.md +36 -0
  24. package/dist/marketplace/external_plugins/sequant/skills/qa/references/quality-gates.md +21 -7
  25. package/dist/marketplace/external_plugins/sequant/skills/qa/references/testing-requirements.md +1 -1
  26. package/dist/marketplace/external_plugins/sequant/skills/qa/scripts/quality-checks.sh +11 -11
  27. package/dist/marketplace/external_plugins/sequant/skills/reflect/SKILL.md +27 -13
  28. package/dist/marketplace/external_plugins/sequant/skills/reflect/references/documentation-tiers.md +80 -68
  29. package/dist/marketplace/external_plugins/sequant/skills/reflect/references/phase-reflection.md +31 -15
  30. package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +11 -3
  31. package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +5 -5
  32. package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +2 -0
  33. package/dist/marketplace/external_plugins/sequant/skills/spec/references/verification-criteria.md +1 -1
  34. package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +1 -1
  35. package/dist/src/commands/assess-render.d.ts +23 -0
  36. package/dist/src/commands/assess-render.js +60 -0
  37. package/dist/src/commands/doctor.js +8 -13
  38. package/dist/src/commands/init.js +13 -1
  39. package/dist/src/commands/locks.js +2 -0
  40. package/dist/src/commands/logs.js +20 -6
  41. package/dist/src/commands/merge.js +154 -2
  42. package/dist/src/commands/ready-tui-adapter.js +6 -1
  43. package/dist/src/commands/ready.d.ts +25 -3
  44. package/dist/src/commands/ready.js +39 -13
  45. package/dist/src/commands/resume.d.ts +113 -0
  46. package/dist/src/commands/resume.js +193 -0
  47. package/dist/src/commands/run-display.d.ts +20 -0
  48. package/dist/src/commands/run-display.js +117 -5
  49. package/dist/src/commands/run-flags.d.ts +42 -1
  50. package/dist/src/commands/run-flags.js +53 -1
  51. package/dist/src/commands/run-progress.js +26 -1
  52. package/dist/src/commands/run.js +11 -10
  53. package/dist/src/commands/state.js +7 -0
  54. package/dist/src/commands/stats.js +47 -0
  55. package/dist/src/commands/status.d.ts +9 -0
  56. package/dist/src/commands/status.js +24 -1
  57. package/dist/src/commands/sync.js +90 -19
  58. package/dist/src/commands/update.js +28 -5
  59. package/dist/src/lib/ac-parser.d.ts +14 -0
  60. package/dist/src/lib/ac-parser.js +99 -5
  61. package/dist/src/lib/assess/index.d.ts +10 -0
  62. package/dist/src/lib/assess/index.js +9 -0
  63. package/dist/src/lib/assess/renderer.d.ts +64 -0
  64. package/dist/src/lib/assess/renderer.js +481 -0
  65. package/dist/src/lib/assess/types.d.ts +224 -0
  66. package/dist/src/lib/assess/types.js +241 -0
  67. package/dist/src/lib/assess-collision-detect.d.ts +19 -2
  68. package/dist/src/lib/assess-collision-detect.js +68 -4
  69. package/dist/src/lib/ci/outputs.d.ts +15 -2
  70. package/dist/src/lib/ci/outputs.js +17 -5
  71. package/dist/src/lib/cli-flags.d.ts +33 -0
  72. package/dist/src/lib/cli-flags.js +46 -0
  73. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +21 -7
  74. package/dist/src/lib/cli-ui/run-renderer.d.ts +10 -0
  75. package/dist/src/lib/cli-ui/run-renderer.js +79 -9
  76. package/dist/src/lib/errors.d.ts +84 -6
  77. package/dist/src/lib/errors.js +142 -17
  78. package/dist/src/lib/locks/index.d.ts +3 -3
  79. package/dist/src/lib/locks/index.js +2 -2
  80. package/dist/src/lib/locks/lock-manager.d.ts +20 -2
  81. package/dist/src/lib/locks/lock-manager.js +47 -3
  82. package/dist/src/lib/locks/types.d.ts +34 -3
  83. package/dist/src/lib/locks/types.js +16 -0
  84. package/dist/src/lib/manifest.js +1 -17
  85. package/dist/src/lib/mcp-config.d.ts +45 -0
  86. package/dist/src/lib/mcp-config.js +77 -1
  87. package/dist/src/lib/merge-check/combined-branch-test.d.ts +21 -2
  88. package/dist/src/lib/merge-check/combined-branch-test.js +233 -110
  89. package/dist/src/lib/merge-check/command-result.d.ts +36 -0
  90. package/dist/src/lib/merge-check/command-result.js +56 -0
  91. package/dist/src/lib/merge-check/index.d.ts +4 -0
  92. package/dist/src/lib/merge-check/index.js +1 -1
  93. package/dist/src/lib/merge-check/report.js +5 -1
  94. package/dist/src/lib/merge-check/types.d.ts +9 -0
  95. package/dist/src/lib/merge-check/watch.d.ts +121 -0
  96. package/dist/src/lib/merge-check/watch.js +215 -0
  97. package/dist/src/lib/qa/infra-blocked-ci.d.ts +90 -0
  98. package/dist/src/lib/qa/infra-blocked-ci.js +80 -0
  99. package/dist/src/lib/settings.d.ts +14 -0
  100. package/dist/src/lib/settings.js +61 -8
  101. package/dist/src/lib/shutdown.d.ts +42 -2
  102. package/dist/src/lib/shutdown.js +75 -5
  103. package/dist/src/lib/skills-check.d.ts +26 -0
  104. package/dist/src/lib/skills-check.js +31 -0
  105. package/dist/src/lib/stacks.d.ts +110 -1
  106. package/dist/src/lib/stacks.js +244 -5
  107. package/dist/src/lib/templates.d.ts +74 -0
  108. package/dist/src/lib/templates.js +171 -16
  109. package/dist/src/lib/test-tautology-detector.d.ts +9 -2
  110. package/dist/src/lib/test-tautology-detector.js +153 -12
  111. package/dist/src/lib/version-check.d.ts +31 -0
  112. package/dist/src/lib/version-check.js +46 -7
  113. package/dist/src/lib/workflow/batch-executor.d.ts +138 -6
  114. package/dist/src/lib/workflow/batch-executor.js +550 -54
  115. package/dist/src/lib/workflow/chain-preflight.d.ts +89 -0
  116. package/dist/src/lib/workflow/chain-preflight.js +199 -0
  117. package/dist/src/lib/workflow/chain-resume.d.ts +118 -0
  118. package/dist/src/lib/workflow/chain-resume.js +170 -0
  119. package/dist/src/lib/workflow/completed-status.d.ts +52 -0
  120. package/dist/src/lib/workflow/completed-status.js +57 -0
  121. package/dist/src/lib/workflow/config-resolver.d.ts +26 -0
  122. package/dist/src/lib/workflow/config-resolver.js +54 -2
  123. package/dist/src/lib/workflow/dependency-markers.d.ts +29 -0
  124. package/dist/src/lib/workflow/dependency-markers.js +79 -0
  125. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +7 -0
  126. package/dist/src/lib/workflow/drivers/aider.d.ts +2 -0
  127. package/dist/src/lib/workflow/drivers/aider.js +2 -0
  128. package/dist/src/lib/workflow/drivers/claude-code.d.ts +21 -0
  129. package/dist/src/lib/workflow/drivers/claude-code.js +50 -8
  130. package/dist/src/lib/workflow/error-classifier.d.ts +9 -2
  131. package/dist/src/lib/workflow/error-classifier.js +14 -1
  132. package/dist/src/lib/workflow/git-diff-utils.d.ts +35 -3
  133. package/dist/src/lib/workflow/git-diff-utils.js +77 -4
  134. package/dist/src/lib/workflow/heartbeat.d.ts +38 -0
  135. package/dist/src/lib/workflow/heartbeat.js +90 -0
  136. package/dist/src/lib/workflow/log-writer.d.ts +44 -1
  137. package/dist/src/lib/workflow/log-writer.js +81 -14
  138. package/dist/src/lib/workflow/metrics-schema.d.ts +42 -3
  139. package/dist/src/lib/workflow/metrics-schema.js +16 -0
  140. package/dist/src/lib/workflow/metrics-writer.d.ts +2 -1
  141. package/dist/src/lib/workflow/phase-executor.d.ts +234 -13
  142. package/dist/src/lib/workflow/phase-executor.js +533 -56
  143. package/dist/src/lib/workflow/phase-registry.js +2 -1
  144. package/dist/src/lib/workflow/platforms/github.d.ts +64 -0
  145. package/dist/src/lib/workflow/platforms/github.js +152 -0
  146. package/dist/src/lib/workflow/qa-cache.d.ts +6 -1
  147. package/dist/src/lib/workflow/qa-cache.js +22 -9
  148. package/dist/src/lib/workflow/ready-gate.d.ts +17 -4
  149. package/dist/src/lib/workflow/ready-gate.js +25 -9
  150. package/dist/src/lib/workflow/run-log-schema.d.ts +37 -0
  151. package/dist/src/lib/workflow/run-log-schema.js +92 -2
  152. package/dist/src/lib/workflow/run-orchestrator.d.ts +33 -0
  153. package/dist/src/lib/workflow/run-orchestrator.js +429 -47
  154. package/dist/src/lib/workflow/skills-preflight.d.ts +89 -0
  155. package/dist/src/lib/workflow/skills-preflight.js +112 -0
  156. package/dist/src/lib/workflow/state-cleanup.js +9 -2
  157. package/dist/src/lib/workflow/state-manager.d.ts +33 -0
  158. package/dist/src/lib/workflow/state-manager.js +83 -0
  159. package/dist/src/lib/workflow/state-schema.d.ts +26 -0
  160. package/dist/src/lib/workflow/state-schema.js +47 -0
  161. package/dist/src/lib/workflow/status-derivation.d.ts +30 -0
  162. package/dist/src/lib/workflow/status-derivation.js +27 -0
  163. package/dist/src/lib/workflow/types.d.ts +149 -4
  164. package/dist/src/lib/workflow/types.js +1 -0
  165. package/dist/src/lib/workflow/worktree-manager.d.ts +70 -2
  166. package/dist/src/lib/workflow/worktree-manager.js +201 -52
  167. package/dist/src/mcp/tools/run.d.ts +11 -1
  168. package/dist/src/mcp/tools/run.js +22 -1
  169. package/dist/src/mcp/tools/status.js +6 -0
  170. package/dist/src/ui/tui/ElapsedTimer.d.ts +12 -3
  171. package/dist/src/ui/tui/ElapsedTimer.js +12 -10
  172. package/dist/src/ui/tui/IssueBox.js +10 -2
  173. package/dist/src/ui/tui/row-cap.js +2 -1
  174. package/dist/src/ui/tui/theme.d.ts +7 -0
  175. package/dist/src/ui/tui/theme.js +9 -0
  176. package/package.json +9 -10
  177. package/templates/hooks/parallel-marker.sh +50 -0
  178. package/templates/hooks/post-tool.sh +38 -10
  179. package/templates/hooks/pre-tool.sh +405 -67
  180. package/templates/scripts/cleanup-worktree.sh +282 -30
  181. package/templates/scripts/new-feature.sh +264 -16
  182. package/templates/skills/_shared/references/trust-model.md +18 -0
  183. package/templates/skills/assess/SKILL.md +250 -344
  184. package/templates/skills/assess/references/predicted-collision-detection.md +10 -7
  185. package/templates/skills/docs/SKILL.md +4 -4
  186. package/templates/skills/exec/SKILL.md +26 -6
  187. package/templates/skills/fullsolve/SKILL.md +4 -2
  188. package/templates/skills/loop/SKILL.md +38 -2
  189. package/templates/skills/merger/SKILL.md +2 -2
  190. package/templates/skills/qa/SKILL.md +337 -57
  191. package/templates/skills/qa/references/anti-pattern-detection.md +6 -6
  192. package/templates/skills/qa/references/call-site-review.md +2 -2
  193. package/templates/skills/qa/references/code-review-checklist.md +2 -2
  194. package/templates/skills/qa/references/fixtures/injection-issue-body.md +36 -0
  195. package/templates/skills/qa/references/quality-gates.md +21 -7
  196. package/templates/skills/qa/references/testing-requirements.md +1 -1
  197. package/templates/skills/qa/scripts/quality-checks.sh +11 -11
  198. package/templates/skills/reflect/SKILL.md +27 -13
  199. package/templates/skills/reflect/references/documentation-tiers.md +80 -68
  200. package/templates/skills/reflect/references/phase-reflection.md +31 -15
  201. package/templates/skills/release/SKILL.md +11 -3
  202. package/templates/skills/setup/SKILL.md +5 -5
  203. package/templates/skills/spec/SKILL.md +2 -0
  204. package/templates/skills/spec/references/verification-criteria.md +1 -1
  205. package/templates/skills/test/SKILL.md +1 -1
  206. package/dist/src/lib/phase-spinner.d.ts +0 -146
  207. package/dist/src/lib/phase-spinner.js +0 -255
  208. package/dist/src/lib/workflow/pr-operations.d.ts +0 -86
  209. package/dist/src/lib/workflow/pr-operations.js +0 -326
  210. package/dist/src/lib/workflow/run-summary.d.ts +0 -36
  211. package/dist/src/lib/workflow/run-summary.js +0 -142
@@ -10,12 +10,17 @@
10
10
  import chalk from "chalk";
11
11
  import { spawnSync } from "child_process";
12
12
  import { createPhaseLogFromTiming } from "./log-writer.js";
13
- import { classifyError, errorTypeToCategory } from "./error-classifier.js";
14
- import { getGitDiffStats, getCommitHash } from "./git-diff-utils.js";
13
+ import { classifyError, errorTypeToCategory, } from "./error-classifier.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";
18
+ import { parseBodyDependencyMarkers } from "./dependency-markers.js";
17
19
  import { detectPhasesFromLabels, parseRecommendedWorkflow, determinePhasesForIssue, DOCS_LABELS, } from "./phase-mapper.js";
18
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";
19
24
  /**
20
25
  * Emit a structured progress line to stderr for MCP progress notifications.
21
26
  * Only emits when running under an orchestrator (e.g., MCP server).
@@ -27,29 +32,95 @@ import { activateRelay, deactivateRelay, } from "../relay/activation.js";
27
32
  * @param extra - Optional fields: durationSeconds (on complete), error (on failed)
28
33
  */
29
34
  /**
30
- * Wrap an `ExecutionConfig` with an `onActivity` hook that re-emits each
31
- * 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).
32
46
  *
33
- * Returns the input config unchanged when no `onProgress` callback is set,
34
- * 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.
35
50
  *
36
51
  * @internal Exported for testing only
37
52
  */
38
- export function withActivityHook(base, issueNumber, phase, onProgress) {
39
- if (!onProgress)
53
+ export function withActivityHook(base, issueNumber, phase, onProgress, onWaitTransition) {
54
+ const orchestrated = Boolean(process.env.SEQUANT_ORCHESTRATOR);
55
+ if (!onProgress && !orchestrated && !onWaitTransition)
40
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;
41
63
  return {
42
64
  ...base,
43
65
  onActivity: (text) => {
44
66
  try {
45
- onProgress(issueNumber, phase, "activity", { text });
67
+ onProgress?.(issueNumber, phase, "activity", { text });
46
68
  }
47
69
  catch {
48
70
  // Activity events must never disrupt the run.
49
71
  }
50
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
+ },
51
115
  };
52
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;
53
124
  /**
54
125
  * Build enriched prompt context for the /loop phase from a failed phase result (#488).
55
126
  * Passes QA verdict, failed ACs, and error directly so the /loop skill doesn't need
@@ -93,6 +164,17 @@ export function emitProgressLine(issue, phase, event = "start", extra) {
93
164
  if (extra?.iteration !== undefined) {
94
165
  payload.iteration = extra.iteration;
95
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
+ }
96
178
  const line = `SEQUANT_PROGRESS:${JSON.stringify(payload)}\n`;
97
179
  process.stderr.write(line);
98
180
  }
@@ -137,17 +219,13 @@ export function parseDependencies(issueNumber) {
137
219
  return [];
138
220
  const data = JSON.parse(result.stdout.toString());
139
221
  const dependencies = [];
140
- // Parse from body: "Depends on: #123" or "**Depends on**: #123"
222
+ // Parse from body: line-leading "Depends on: #123" / "**Depends on**: #123".
223
+ // Delegates to the shared, hardened parser (#767): mid-sentence prose,
224
+ // in-fence examples, and inline-code mentions are ignored, and the `#` is
225
+ // required. Honors ONLY `depends on` — the sorter must not start reordering
226
+ // on `blocked by`, which would be a new silent-reorder class (#762 Open Q #3).
141
227
  if (data.body) {
142
- const bodyMatch = data.body.match(/\*?\*?depends\s+on\*?\*?:?\s*#?(\d+)/gi);
143
- if (bodyMatch) {
144
- for (const match of bodyMatch) {
145
- const numMatch = match.match(/(\d+)/);
146
- if (numMatch) {
147
- dependencies.push(parseInt(numMatch[1], 10));
148
- }
149
- }
150
- }
228
+ dependencies.push(...parseBodyDependencyMarkers(data.body, ["depends on"]));
151
229
  }
152
230
  // Parse from labels: "depends-on/123" or "depends-on-123"
153
231
  if (data.labels && Array.isArray(data.labels)) {
@@ -257,8 +335,53 @@ export function getEnvConfig() {
257
335
  if (process.env.SEQUANT_SECURITY_REVIEW === "true") {
258
336
  config.securityReview = true;
259
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
+ }
260
349
  return config;
261
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
+ */
262
385
  export async function executeBatch(issueNumbers, batchCtx) {
263
386
  const { config, options, issueInfoMap, worktreeMap, logWriter, stateManager, shutdownManager, packageManager, baseBranch, onProgress, onPhasePlan, phasePauseHandle, } = batchCtx;
264
387
  const results = [];
@@ -294,17 +417,221 @@ export async function executeBatch(issueNumbers, batchCtx) {
294
417
  };
295
418
  const result = await runIssueWithLogging(ctx);
296
419
  results.push(result);
297
- // Record PR info in log before completing issue
298
- if (logWriter && result.prNumber && result.prUrl) {
299
- logWriter.setPRInfo(result.prNumber, result.prUrl);
300
- }
301
- // 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.
302
422
  if (logWriter) {
303
- logWriter.completeIssue();
423
+ recordIssueCompletion(logWriter, result, issueNumber);
304
424
  }
305
425
  }
306
426
  return results;
307
427
  }
428
+ /**
429
+ * Derive the bounded-enum failure category for a failed issue (#761 AC-7).
430
+ *
431
+ * Scans for the LAST non-loop failing phase — the same reverse scan
432
+ * `toIssueSummary` uses (#766), so the recorded category and the displayed
433
+ * failure reason describe the same attempt. Prefers the driver's structured
434
+ * cause over stderr-regex classification (#732). Returns only the enum value;
435
+ * message strings never leave this function (metrics privacy contract).
436
+ *
437
+ * @internal Exported for testing
438
+ */
439
+ export function deriveFailureCategory(phaseResults) {
440
+ const failedPhase = [...phaseResults]
441
+ .reverse()
442
+ .find((p) => !p.success && p.phase !== "loop");
443
+ if (!failedPhase)
444
+ return undefined;
445
+ const typedError = failedPhase.structuredError ??
446
+ classifyError(failedPhase.stderrTail ?? [], failedPhase.exitCode);
447
+ return errorTypeToCategory(typedError);
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
+ }
308
635
  export async function runIssueWithLogging(ctx) {
309
636
  // Destructure context for use throughout the function
310
637
  const { issueNumber, config, options, title: issueTitle, labels, services: { logWriter, stateManager, shutdownManager }, worktree, chain, packageManager, baseBranch, onProgress, onPhasePlan, phasePauseHandle, } = ctx;
@@ -317,6 +644,10 @@ export async function runIssueWithLogging(ctx) {
317
644
  let loopTriggered = false;
318
645
  // Cross-phase resume token, driver-tagged and cwd-bound (#674).
319
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);
320
651
  // In parallel mode, suppress per-issue terminal output to prevent interleaving.
321
652
  // The caller (run.ts) handles progress display via updateProgress().
322
653
  const log = config.parallel ? () => { } : console.log.bind(console);
@@ -350,6 +681,19 @@ export async function runIssueWithLogging(ctx) {
350
681
  }
351
682
  }
352
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;
353
697
  // Activate relay (#383) if enabled. Tolerates errors — relay must never
354
698
  // block the underlying run.
355
699
  let relayActivation = null;
@@ -402,8 +746,10 @@ export async function runIssueWithLogging(ctx) {
402
746
  }
403
747
  const specStartTime = new Date();
404
748
  // Note: spec runs in main repo (not worktree) for planning
405
- const specResult = await executePhaseWithRetry(issueNumber, "spec", withActivityHook(config, issueNumber, "spec", onProgress), resumeHandle, worktreePath, // Will be ignored for spec (non-isolated phase)
406
- 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);
407
753
  const specEndTime = new Date();
408
754
  if (specResult.resumeHandle) {
409
755
  resumeHandle = specResult.resumeHandle;
@@ -432,16 +778,19 @@ export async function runIssueWithLogging(ctx) {
432
778
  }
433
779
  }
434
780
  else {
435
- // Mirror the main phase loop (#739): a turn-capped spec phase surfaces the
436
- // distinct "partial output preserved" signal rather than a generic failure
437
- // reason, so the cap is recognizable on the spec path too (it has its own
438
- // failure handling, separate from the main loop). The partial output is
439
- // preserved in `phaseResults` (pushed above) and the run still halts via
440
- // 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.
441
788
  const extra = {
442
789
  error: specResult.capped
443
790
  ? "turn cap reached — partial output preserved (resume to continue)"
444
- : (specResult.error ?? "unknown"),
791
+ : isBillingOrWindowHalt(specResult)
792
+ ? billingHaltReason(specResult)
793
+ : (specResult.error ?? "unknown"),
445
794
  };
446
795
  emitProgressLine(issueNumber, "spec", "failed", extra);
447
796
  try {
@@ -454,10 +803,13 @@ export async function runIssueWithLogging(ctx) {
454
803
  // Log spec phase result
455
804
  // Note: Spec runs in main repo, not worktree, so no git diff stats
456
805
  if (logWriter) {
457
- // Build errorContext from captured stderr/stdout tails (#447)
806
+ // Build errorContext from captured stderr/stdout tails (#447). Prefer
807
+ // the driver's structured cause (#761 AC-6) — stderr-regex
808
+ // classification never sees the SDK's rate-limit/billing signals.
458
809
  let specErrorContext;
459
810
  if (!specResult.success && specResult.stderrTail) {
460
- const specError = classifyError(specResult.stderrTail ?? [], specResult.exitCode);
811
+ const specError = specResult.structuredError ??
812
+ classifyError(specResult.stderrTail ?? [], specResult.exitCode);
461
813
  specErrorContext = {
462
814
  stderrTail: specResult.stderrTail ?? [],
463
815
  stdoutTail: specResult.stdoutTail ?? [],
@@ -496,6 +848,9 @@ export async function runIssueWithLogging(ctx) {
496
848
  // State tracking errors shouldn't stop execution
497
849
  }
498
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);
499
854
  if (!specResult.success) {
500
855
  const durationSeconds = (Date.now() - startTime) / 1000;
501
856
  // Archive relay state on early exit (spec failure).
@@ -518,6 +873,7 @@ export async function runIssueWithLogging(ctx) {
518
873
  phaseResults,
519
874
  durationSeconds,
520
875
  loopTriggered: false,
876
+ failureCategory: deriveFailureCategory(phaseResults),
521
877
  };
522
878
  }
523
879
  // Parse recommended workflow from spec output
@@ -618,6 +974,11 @@ export async function runIssueWithLogging(ctx) {
618
974
  // retry too, not just the inner /loop spawn — re-running a capped phase
619
975
  // would only cap again, and "surface + halt" means the user resumes.
620
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;
621
982
  while (iteration < maxIterations) {
622
983
  iteration++;
623
984
  if (useQualityLoop && iteration > 1) {
@@ -650,7 +1011,9 @@ export async function runIssueWithLogging(ctx) {
650
1011
  }
651
1012
  }
652
1013
  const phaseStartTime = new Date();
653
- 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);
654
1017
  const phaseEndTime = new Date();
655
1018
  // Capture resume handle for subsequent phases (#674).
656
1019
  if (result.resumeHandle) {
@@ -687,7 +1050,12 @@ export async function runIssueWithLogging(ctx) {
687
1050
  const extra = {
688
1051
  error: result.capped
689
1052
  ? "turn cap reached — partial output preserved (resume to continue)"
690
- : (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"),
691
1059
  iteration,
692
1060
  };
693
1061
  emitProgressLine(issueNumber, phase, "failed", extra);
@@ -700,20 +1068,31 @@ export async function runIssueWithLogging(ctx) {
700
1068
  }
701
1069
  // Log phase result with observability data (AC-1, AC-2, AC-3, AC-7)
702
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;
703
1077
  // Capture git diff stats for worktree phases (AC-1, AC-3)
704
- const diffStats = worktreePath
705
- ? getGitDiffStats(worktreePath, baseBranch)
1078
+ const diffStats = worktreePath && resolvedDiffBase
1079
+ ? getGitDiffStats(worktreePath, resolvedDiffBase)
706
1080
  : undefined;
707
- // 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).
708
1084
  const commitHash = worktreePath
709
- ? getCommitHash(worktreePath)
1085
+ ? getCommitHash(worktreePath, resolvedDiffBase)
710
1086
  : undefined;
711
1087
  // Read cache metrics for QA phase (AC-7)
712
1088
  const cacheMetrics = phase === "qa" ? readCacheMetrics(worktreePath) : undefined;
713
- // Build errorContext from captured stderr/stdout tails (#447, AC-7/AC-8)
1089
+ // Build errorContext from captured stderr/stdout tails (#447, AC-7/AC-8).
1090
+ // Prefer the driver's structured cause (#761 AC-6) — stderr-regex
1091
+ // classification never sees the SDK's rate-limit/billing signals.
714
1092
  let errorContext;
715
1093
  if (!result.success && result.stderrTail) {
716
- const typedError = classifyError(result.stderrTail ?? [], result.exitCode);
1094
+ const typedError = result.structuredError ??
1095
+ classifyError(result.stderrTail ?? [], result.exitCode);
717
1096
  errorContext = {
718
1097
  stderrTail: result.stderrTail ?? [],
719
1098
  stdoutTail: result.stdoutTail ?? [],
@@ -764,6 +1143,9 @@ export async function runIssueWithLogging(ctx) {
764
1143
  // State tracking errors shouldn't stop execution
765
1144
  }
766
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);
767
1149
  if (result.success) {
768
1150
  // Phase succeeded — RunRenderer (#618) updates state via onProgress.
769
1151
  }
@@ -772,12 +1154,24 @@ export async function runIssueWithLogging(ctx) {
772
1154
  if (result.capped) {
773
1155
  haltedByCap = true;
774
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
+ }
775
1164
  // If quality loop enabled, run loop phase to fix issues.
776
1165
  // A turn-capped phase (#739) is incomplete, not a genuine quality
777
1166
  // failure: skip the loop and halt cleanly ("surface + halt"). Spawning
778
1167
  // /loop on partial output would act on incomplete work — exactly the
779
1168
  // risk the capped path is meant to avoid. The user resumes instead.
780
- 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) {
781
1175
  // #624 Item 3 (AC-3.3): the loop phase carries the current outer
782
1176
  // iteration so the live-zone status cell can show `loop N/M`.
783
1177
  const loopStartExtra = { iteration };
@@ -798,9 +1192,28 @@ export async function runIssueWithLogging(ctx) {
798
1192
  promptContext: buildLoopContext(result),
799
1193
  };
800
1194
  const loopStartTime = new Date();
801
- 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);
802
1198
  const loopEndTime = new Date();
803
1199
  phaseResults.push(loopResult);
1200
+ // #766: record the loop phase in the run log — spec (:655) and the
1201
+ // regular phases (:982) log via logWriter, but the loop was never
1202
+ // logged, so a loop that decided the card's verdict was absent from
1203
+ // the log you'd use to debug it (AC-6). Loop status never determines
1204
+ // the issue verdict (see deriveIssueLogStatus), but the entry with
1205
+ // phase/status/duration/error must exist.
1206
+ if (logWriter) {
1207
+ const loopPhaseLog = createPhaseLogFromTiming("loop", issueNumber, loopStartTime, loopEndTime, loopResult.success
1208
+ ? "success"
1209
+ : loopResult.error?.includes("Timeout")
1210
+ ? "timeout"
1211
+ : "failure", {
1212
+ error: loopResult.error,
1213
+ capped: loopResult.capped,
1214
+ });
1215
+ logWriter.logPhase(loopPhaseLog);
1216
+ }
804
1217
  // Emit loop completion/failure progress event (AC-8)
805
1218
  const loopDurationSec = Math.round((loopEndTime.getTime() - loopStartTime.getTime()) / 1000);
806
1219
  if (loopResult.success) {
@@ -842,7 +1255,9 @@ export async function runIssueWithLogging(ctx) {
842
1255
  }
843
1256
  // A turn-capped phase (#739) halts the outer quality-loop retry as well —
844
1257
  // re-running would only cap again; the partial work is already preserved.
845
- 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) {
846
1261
  break;
847
1262
  }
848
1263
  // If we're not in quality loop mode, don't retry
@@ -854,19 +1269,73 @@ export async function runIssueWithLogging(ctx) {
854
1269
  // Success is determined by whether all phases completed in any iteration,
855
1270
  // not whether all accumulated phase results passed (which would fail after loop recovery)
856
1271
  const success = completedSuccessfully;
857
- // 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";
858
1306
  if (stateManager) {
859
1307
  try {
860
- const finalStatus = success ? "ready_for_merge" : "in_progress";
861
1308
  await stateManager.updateIssueStatus(issueNumber, finalStatus);
862
1309
  }
863
1310
  catch {
864
1311
  // State tracking errors shouldn't stop execution
865
1312
  }
866
1313
  }
867
- // Create checkpoint commit in chain mode after QA passes
1314
+ // Create checkpoint commit in chain mode after QA passes.
1315
+ // #760: chain resume rebases the next link onto this checkpoint, so a failure
1316
+ // here is not silent — warn prominently and record it on the result (AC-4).
1317
+ //
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.
1329
+ let checkpointFailed = false;
868
1330
  if (success && chainMode && worktreePath) {
869
- createCheckpointCommit(worktreePath, issueNumber, config.verbose, baseBranch);
1331
+ const checkpointOk = createCheckpointCommit(worktreePath, issueNumber, config.verbose, baseBranch);
1332
+ if (!checkpointOk) {
1333
+ checkpointFailed = true;
1334
+ log(chalk.yellow(` ⚠️ Checkpoint commit for #${issueNumber} could not be created — its uncommitted ` +
1335
+ `changes are NOT on branch ${branch ?? "the feature branch"}. #${issueNumber} stays ` +
1336
+ `${finalStatus}, so a re-run will skip it and refuse to resume the chain here until the ` +
1337
+ `work is committed in ${worktreePath} (or re-run with --force to redo the whole chain).`));
1338
+ }
870
1339
  }
871
1340
  // Rebase onto the base branch before PR creation (unless --no-rebase)
872
1341
  // This ensures the branch is up-to-date and prevents lockfile drift
@@ -883,6 +1352,10 @@ export async function runIssueWithLogging(ctx) {
883
1352
  // Create PR after successful QA + rebase (unless --no-pr)
884
1353
  let prNumber;
885
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;
886
1359
  const shouldCreatePR = success && worktreePath && branch && !options.noPr;
887
1360
  if (shouldCreatePR) {
888
1361
  // #605: under --stacked, target predecessor branch (only for non-first,
@@ -893,7 +1366,13 @@ export async function runIssueWithLogging(ctx) {
893
1366
  stackManifest: chain.stackManifest,
894
1367
  }
895
1368
  : undefined;
896
- const prResult = createPR(worktreePath, issueNumber, issueTitle, branch, config.verbose, labels, stackOptions);
1369
+ // #749: surface a non-A+ qa verdict (e.g. AC_MET_BUT_NOT_A_PLUS) in the PR
1370
+ // body so a reviewer sees why the run broke to PR rather than reaching A+.
1371
+ const qaVerdict = phaseResults.find((p) => p.phase === "qa")?.verdict;
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);
897
1376
  if (prResult.success && prResult.prNumber && prResult.prUrl) {
898
1377
  prNumber = prResult.prNumber;
899
1378
  prUrl = prResult.prUrl;
@@ -910,6 +1389,11 @@ export async function runIssueWithLogging(ctx) {
910
1389
  }
911
1390
  }
912
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
+ }
913
1397
  }
914
1398
  // Deactivate relay (#383) — archive inbox/outbox transcripts to
915
1399
  // .sequant/logs/relay/ before worktree teardown (AC-D2). Never throws.
@@ -928,13 +1412,25 @@ export async function runIssueWithLogging(ctx) {
928
1412
  }
929
1413
  }
930
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;
931
1418
  return {
932
1419
  issueNumber,
933
- success,
1420
+ success: overallSuccess,
934
1421
  phaseResults,
935
1422
  durationSeconds,
936
1423
  loopTriggered,
937
1424
  prNumber,
938
1425
  prUrl,
1426
+ prCreationError,
1427
+ checkpointFailed,
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,
939
1435
  };
940
1436
  }