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,7 +10,7 @@ import chalk from "chalk";
10
10
  import { spawnSync } from "child_process";
11
11
  import pLimit from "p-limit";
12
12
  import { formatCoarseNowLine } from "./run-state.js";
13
- import { detectDefaultBranch, ensureWorktrees, ensureWorktreesChain, getWorktreeDiffStats, } from "./worktree-manager.js";
13
+ import { detectDefaultBranch, ensureWorktrees, ensureWorktreesChain, getWorktreeDiffStats, rebaseOntoLocalBranch, } from "./worktree-manager.js";
14
14
  import { LogWriter } from "./log-writer.js";
15
15
  import { StateManager } from "./state-manager.js";
16
16
  import { ShutdownManager } from "../shutdown.js";
@@ -27,20 +27,63 @@ function formatSignalLine(issue, pid, result) {
27
27
  return ` Refused to signal PID ${pid} for #${issue} (matches this process or its parent)`;
28
28
  case "pid-dead":
29
29
  return ` Could not signal PID ${pid} for #${issue} (already exited)`;
30
+ case "stale-pid-untrusted":
31
+ return ` Refused to signal PID ${pid} for #${issue} (lock is past the age ceiling; that PID has likely been recycled)`;
30
32
  case "kill-failed":
31
33
  return ` Could not signal PID ${pid} for #${issue} (kill syscall failed)`;
32
34
  case "orchestrator":
33
35
  return ` Skipped signal for #${issue} (orchestrator mode)`;
34
36
  }
35
37
  }
36
- import { getIssueInfo, sortByDependencies, parseBatches, runIssueWithLogging, emitRunIdLine, } from "./batch-executor.js";
38
+ /**
39
+ * Resolve a git ref (branch name or base branch) to its tip commit SHA in the
40
+ * current repo, or undefined if the ref does not exist. Used by the chain
41
+ * resume planner (#760) to reconstruct — and validate the existence of — a
42
+ * completed link's committed tip.
43
+ */
44
+ function revParseRef(ref) {
45
+ const result = spawnSync("git", ["rev-parse", "--verify", "--quiet", ref], {
46
+ stdio: "pipe",
47
+ encoding: "utf-8",
48
+ });
49
+ if (result.status !== 0) {
50
+ return undefined;
51
+ }
52
+ const sha = result.stdout.trim();
53
+ return sha.length > 0 ? sha : undefined;
54
+ }
55
+ /**
56
+ * True iff `worktreePath` has uncommitted changes (`git status --porcelain`,
57
+ * which already honours .gitignore). Used by the chain resume planner (#760) to
58
+ * reject a resume base whose checkpoint commit never landed.
59
+ *
60
+ * Returns false when the path is missing or not a worktree: an unreadable
61
+ * worktree can't be judged dirty, and the branch-tip check already covers the
62
+ * destroyed-link case.
63
+ */
64
+ function isWorktreeDirty(worktreePath) {
65
+ const result = spawnSync("git", ["-C", worktreePath, "status", "--porcelain"], {
66
+ stdio: "pipe",
67
+ encoding: "utf-8",
68
+ });
69
+ if (result.status !== 0) {
70
+ return false;
71
+ }
72
+ return result.stdout.trim().length > 0;
73
+ }
74
+ import { getIssueInfo, sortByDependencies, parseBatches, runIssueWithLogging, recordIssueCompletion, emitRunIdLine, } from "./batch-executor.js";
37
75
  import { reconcileStateAtStartup } from "./state-utils.js";
76
+ import { runChainPreflight } from "./chain-preflight.js";
77
+ import { runSkillsPreflight } from "./skills-preflight.js";
38
78
  import { getCommitHash } from "./git-diff-utils.js";
79
+ import { planChainResumeFromState, } from "./chain-resume.js";
80
+ import { isCompletedIssueStatus } from "./completed-status.js";
39
81
  import { MetricsWriter } from "./metrics-writer.js";
40
82
  import { WorkflowEventEmitter } from "./event-emitter.js";
41
83
  import { determineOutcome } from "./metrics-schema.js";
42
84
  import { getTokenUsageForRun } from "./token-utils.js";
43
85
  import { resolveRunOptions, buildExecutionConfig } from "./config-resolver.js";
86
+ import { pipelineHasFailed } from "./status-derivation.js";
44
87
  /**
45
88
  * Build the stack-manifest line emitted into PR bodies under --stacked.
46
89
  *
@@ -92,6 +135,20 @@ export class RunOrchestrator {
92
135
  getEmitter() {
93
136
  return this.emitter;
94
137
  }
138
+ /**
139
+ * The wrapped progress sink handed to the batch executor — every phase
140
+ * lifecycle event enters the orchestrator through here.
141
+ *
142
+ * @internal Exported for testing only. `applyProgressEvent` is private and
143
+ * depends on `issueStates`/`phaseStartTimes`/`emitter`, so there is no way
144
+ * to exercise its branches short of a full `run()`. Without this seam the
145
+ * #804 `waiting` branch would be untestable — and an untested `waiting`
146
+ * branch is not cosmetic: falling through to the complete/failed handler
147
+ * marks a merely-paused phase `failed` and emits `phase_failed`.
148
+ */
149
+ getProgressCallback() {
150
+ return this.cfg.onProgress;
151
+ }
95
152
  /**
96
153
  * Point-in-time view of the entire run.
97
154
  *
@@ -158,6 +215,16 @@ export class RunOrchestrator {
158
215
  const wasStatus = state.status;
159
216
  if (!state.startedAt)
160
217
  state.startedAt = new Date();
218
+ // #866: a phase starting means the issue is live again, so any
219
+ // `completedAt` from an earlier terminal verdict is stale and must not
220
+ // outlive it. The #766 quality-loop recovery path reaches here holding
221
+ // one: a non-loop failure pins `failed` and stamps `completedAt` below,
222
+ // then iteration 2 re-runs the phase through this branch. Leaving it set
223
+ // published a completion time for a running issue — which froze the TUI's
224
+ // header clock for the whole recovery window. `startedAt` is deliberately
225
+ // NOT reset: the header measures the issue's total wall clock, spanning
226
+ // every iteration.
227
+ state.completedAt = undefined;
161
228
  state.status = "running";
162
229
  const now = new Date();
163
230
  this.phaseStartTimes.set(`${issue}:${phase}`, now.getTime());
@@ -185,6 +252,25 @@ export class RunOrchestrator {
185
252
  }
186
253
  return;
187
254
  }
255
+ // #804 AC-7: an auto-wait is a *paused running* phase, not a terminal one.
256
+ // This branch must precede the complete/failed fall-through below, which
257
+ // would otherwise close out the phase the moment a wait began. Surfaces the
258
+ // wait through the same `nowLine` the TUI already renders.
259
+ if (event === "waiting") {
260
+ if (!state.currentPhase || state.currentPhase.name !== phase)
261
+ return;
262
+ const line = extra?.text;
263
+ if (!line)
264
+ return;
265
+ state.currentPhase.nowLine = line;
266
+ state.currentPhase.lastActivityAt = new Date();
267
+ void this.emitter.emit("progress", {
268
+ issueNumber: issue,
269
+ phase,
270
+ text: line,
271
+ });
272
+ return;
273
+ }
188
274
  if (event === "activity") {
189
275
  // Ignore activity for stale phases (race between completion and a
190
276
  // final flushed output chunk).
@@ -218,8 +304,15 @@ export class RunOrchestrator {
218
304
  const durationSec = elapsedMs !== undefined ? Math.round(elapsedMs / 1000) : undefined;
219
305
  if (event === "failed") {
220
306
  const prev = state.status;
221
- state.status = "failed";
222
- state.completedAt = new Date();
307
+ // #766: a loop-phase failure alone must not pin the issue `failed` — the
308
+ // outer quality loop may still recover on a later iteration. Derive from
309
+ // the phase slots (loop excluded) so only a genuine non-loop failure marks
310
+ // the issue failed; otherwise hold `running` until a later event reaches a
311
+ // terminal verdict (a subsequent iteration always follows a loop failure).
312
+ const nowFailed = pipelineHasFailed(state.phases);
313
+ state.status = nowFailed ? "failed" : "running";
314
+ if (nowFailed)
315
+ state.completedAt = new Date();
223
316
  void this.emitter.emit("phase_failed", {
224
317
  issueNumber: issue,
225
318
  phase,
@@ -227,11 +320,11 @@ export class RunOrchestrator {
227
320
  error: extra?.error ?? "unknown",
228
321
  iteration: extra?.iteration,
229
322
  });
230
- if (prev !== "failed") {
323
+ if (prev !== state.status) {
231
324
  void this.emitter.emit("issue_status_changed", {
232
325
  issueNumber: issue,
233
326
  from: prev,
234
- to: "failed",
327
+ to: state.status,
235
328
  });
236
329
  }
237
330
  return;
@@ -246,9 +339,10 @@ export class RunOrchestrator {
246
339
  const allDone = state.phases.every((ph) => ph.status === "done" || ph.status === "failed");
247
340
  if (allDone) {
248
341
  const prev = state.status;
249
- state.status = state.phases.some((ph) => ph.status === "failed")
250
- ? "failed"
251
- : "passed";
342
+ // #766: exclude a stale failed `loop` slot so a run that failed the loop
343
+ // on an early iteration and then recovered finalizes as `passed`, not
344
+ // `failed`. An unrecovered failure still leaves a non-loop phase failed.
345
+ state.status = pipelineHasFailed(state.phases) ? "failed" : "passed";
252
346
  state.completedAt = new Date();
253
347
  if (prev !== state.status) {
254
348
  void this.emitter.emit("issue_status_changed", {
@@ -291,6 +385,8 @@ export class RunOrchestrator {
291
385
  .map((i) => parseInt(i, 10))
292
386
  .filter((n) => !isNaN(n));
293
387
  }
388
+ // Capture the raw CLI order before dep-sorting (#762 pre-flight needs it).
389
+ const rawIssueOrder = [...issueNumbers];
294
390
  if (issueNumbers.length > 1 && !resolvedBatches) {
295
391
  issueNumbers = sortByDependencies(issueNumbers);
296
392
  }
@@ -302,6 +398,7 @@ export class RunOrchestrator {
302
398
  mergedOptions,
303
399
  config,
304
400
  issueNumbers,
401
+ rawIssueOrder,
305
402
  batches: resolvedBatches,
306
403
  baseBranch,
307
404
  stack: manifest.stack,
@@ -319,6 +416,15 @@ export class RunOrchestrator {
319
416
  */
320
417
  static async run(init, issueArgs, batches) {
321
418
  const { manifest, onProgress, phasePauseHandle, settings } = init;
419
+ // #867: capture the run's start before any service/log setup, so wall clock
420
+ // is independent of whether the log writer exists (AC-3: correct under
421
+ // --no-log and when log init fails). This single origin is the source of
422
+ // truth for both the printed summary and the run log (AC-2).
423
+ const runStartedAt = Date.now();
424
+ // Every `return` below — the success path and all six early exits — closes
425
+ // the bracket the same way, so the subtraction lives in one place rather
426
+ // than being restated per exit and left to drift.
427
+ const wallClock = () => (Date.now() - runStartedAt) / 1000;
322
428
  // ── Config resolution ──────────────────────────────────────────────
323
429
  const resolved = RunOrchestrator.resolveConfig(init, issueArgs, batches);
324
430
  const { mergedOptions, config, baseBranch } = resolved;
@@ -334,6 +440,7 @@ export class RunOrchestrator {
334
440
  config,
335
441
  mergedOptions,
336
442
  logWriter: null,
443
+ wallClockDurationSeconds: wallClock(),
337
444
  };
338
445
  }
339
446
  // ── Services setup ─────────────────────────────────────────────────
@@ -355,6 +462,10 @@ export class RunOrchestrator {
355
462
  logPath: mergedOptions.logPath ?? settings.run.logPath,
356
463
  verbose: config.verbose,
357
464
  startCommit: getCommitHash(process.cwd()),
465
+ // #867: share the orchestrator's run origin so the log's stored
466
+ // wall clock (finalizeRunLog: (end-start)/1000) is derived from the
467
+ // same start as the summary — AC-2/AC-7 (one producer, two consumers).
468
+ startTime: new Date(runStartedAt),
358
469
  });
359
470
  await logWriter.initialize(runConfig);
360
471
  const runId = logWriter.getRunId();
@@ -374,8 +485,12 @@ export class RunOrchestrator {
374
485
  const shutdown = new ShutdownManager();
375
486
  if (logWriter) {
376
487
  const writer = logWriter;
377
- shutdown.registerCleanup("Finalize run logs", async () => {
378
- await writer.finalize();
488
+ // #856: forward the abort cause into the log. This cleanup runs on the
489
+ // SIGINT/SIGTERM path, where no phase result will ever arrive for the
490
+ // in-flight issue — without the context, `finalize()` writes that issue
491
+ // out as if the run had simply ended.
492
+ shutdown.registerCleanup("Finalize run logs", async (abort) => {
493
+ await writer.finalize(abort ? { aborted: abort } : undefined);
379
494
  });
380
495
  }
381
496
  // ── Pre-flight state guard ─────────────────────────────────────────
@@ -392,29 +507,47 @@ export class RunOrchestrator {
392
507
  logNonFatalWarning(" ! State reconciliation failed, continuing...", error, config.verbose);
393
508
  }
394
509
  }
510
+ let chainResume;
395
511
  if (stateManager && !config.dryRun && !mergedOptions.force) {
396
- const activeIssues = [];
397
- for (const issueNumber of issueNumbers) {
398
- try {
399
- const issueState = await stateManager.getIssueState(issueNumber);
400
- if (issueState &&
401
- (issueState.status === "ready_for_merge" ||
402
- issueState.status === "merged")) {
403
- console.log(chalk.yellow(` ! #${issueNumber}: already ${issueState.status} — skipping (use --force to re-run)`));
404
- }
405
- else {
406
- activeIssues.push(issueNumber);
407
- }
408
- }
409
- catch (error) {
410
- logNonFatalWarning(` ! State lookup failed for #${issueNumber}, including anyway...`, error, config.verbose);
411
- activeIssues.push(issueNumber);
512
+ if (mergedOptions.chain) {
513
+ // ── Chain-aware resume (#760) ──────────────────────────────────
514
+ // Don't just drop completed links (chain-unaware skip leaves the
515
+ // first incomplete link at index 0, where the #748 successor-rebase
516
+ // never fires → it silently builds on main). Instead compute a
517
+ // chain-correct plan that resumes at the first incomplete link,
518
+ // rebased onto the completed prefix's committed tip.
519
+ const resolver = {
520
+ resolveBranchTip: (branch) => revParseRef(branch),
521
+ resolveBaseTip: () => revParseRef(baseBranch),
522
+ isWorktreeDirty,
523
+ };
524
+ const plan = await planChainResumeFromState(issueNumbers, baseBranch, (issueNumber) => stateManager.getIssueState(issueNumber), resolver, (issueNumber, error) => logNonFatalWarning(` ! State lookup failed for #${issueNumber}, treating as incomplete...`, error, config.verbose));
525
+ if (plan.failFast) {
526
+ console.log(chalk.red(`\n Chain resume aborted: ${plan.failFast}`));
527
+ shutdown.dispose();
528
+ return {
529
+ results: [
530
+ {
531
+ issueNumber: plan.resumeIssue ?? issueNumbers[0],
532
+ success: false,
533
+ phaseResults: [],
534
+ durationSeconds: 0,
535
+ loopTriggered: false,
536
+ abortReason: `chain resume aborted: ${plan.failFast}`,
537
+ },
538
+ ],
539
+ logPath: null,
540
+ exitCode: 1,
541
+ worktreeMap: new Map(),
542
+ issueInfoMap: new Map(),
543
+ config,
544
+ mergedOptions,
545
+ logWriter: null,
546
+ wallClockDurationSeconds: wallClock(),
547
+ };
412
548
  }
413
- }
414
- if (activeIssues.length < issueNumbers.length) {
415
- issueNumbers = activeIssues;
416
- if (issueNumbers.length === 0) {
417
- console.log(chalk.yellow(`\n All issues already completed. Use --force to re-run.`));
549
+ if (plan.allComplete) {
550
+ console.log(chalk.yellow(`\n All chain links already completed. Use --force to re-run.`));
418
551
  shutdown.dispose();
419
552
  return {
420
553
  results: [],
@@ -425,8 +558,58 @@ export class RunOrchestrator {
425
558
  config,
426
559
  mergedOptions,
427
560
  logWriter: null,
561
+ wallClockDurationSeconds: wallClock(),
428
562
  };
429
563
  }
564
+ if (plan.skipped.length > 0) {
565
+ chainResume = plan;
566
+ issueNumbers = plan.active;
567
+ const resumeAt = plan.resumeBaseCommit
568
+ ? plan.resumeBaseCommit.slice(0, 8)
569
+ : (plan.resumeBase ?? "base");
570
+ for (const s of plan.skipped) {
571
+ console.log(chalk.yellow(` ! #${s.issueNumber}: already ${s.status} — skipping (use --force to re-run)`));
572
+ }
573
+ console.log(chalk.cyan(` ↻ Resuming chain at #${plan.resumeIssue} from ${resumeAt}` +
574
+ ` (${plan.resumeBase})`));
575
+ }
576
+ }
577
+ else {
578
+ // ── Non-chain skip guard (unchanged) ───────────────────────────
579
+ const activeIssues = [];
580
+ for (const issueNumber of issueNumbers) {
581
+ try {
582
+ const issueState = await stateManager.getIssueState(issueNumber);
583
+ if (issueState && isCompletedIssueStatus(issueState.status)) {
584
+ console.log(chalk.yellow(` ! #${issueNumber}: already ${issueState.status} — skipping (use --force to re-run)`));
585
+ }
586
+ else {
587
+ activeIssues.push(issueNumber);
588
+ }
589
+ }
590
+ catch (error) {
591
+ logNonFatalWarning(` ! State lookup failed for #${issueNumber}, including anyway...`, error, config.verbose);
592
+ activeIssues.push(issueNumber);
593
+ }
594
+ }
595
+ if (activeIssues.length < issueNumbers.length) {
596
+ issueNumbers = activeIssues;
597
+ if (issueNumbers.length === 0) {
598
+ console.log(chalk.yellow(`\n All issues already completed. Use --force to re-run.`));
599
+ shutdown.dispose();
600
+ return {
601
+ results: [],
602
+ logPath: null,
603
+ exitCode: 0,
604
+ worktreeMap: new Map(),
605
+ issueInfoMap: new Map(),
606
+ config,
607
+ mergedOptions,
608
+ logWriter: null,
609
+ wallClockDurationSeconds: wallClock(),
610
+ };
611
+ }
612
+ }
430
613
  }
431
614
  }
432
615
  // ── Concurrency lock (#625) ────────────────────────────────────────
@@ -483,14 +666,90 @@ export class RunOrchestrator {
483
666
  config,
484
667
  mergedOptions,
485
668
  logWriter: null,
669
+ wallClockDurationSeconds: wallClock(),
486
670
  };
487
671
  }
488
672
  }
673
+ // ── Chain content pre-flight (#762) ────────────────────────────────
674
+ // Warn-by-default; `--strict-preflight` turns warnings fatal. Runs BEFORE
675
+ // any worktree is provisioned. Uses the RAW CLI order (not the dep-sorted
676
+ // `issueNumbers`) so the dependency-order / file-overlap checks aren't
677
+ // masked by `sortByDependencies`, filtered to issues still active after the
678
+ // state guard / lock acquisition.
679
+ if (mergedOptions.chain && issueNumbers.length > 1) {
680
+ const activeSet = new Set(issueNumbers);
681
+ const preflightOrder = resolved.rawIssueOrder.filter((n) => activeSet.has(n));
682
+ const preflightWarnings = await runChainPreflight(preflightOrder);
683
+ if (preflightWarnings.length > 0) {
684
+ for (const w of preflightWarnings) {
685
+ bracketedConsoleLog(phasePauseHandle, chalk.yellow(` ⚠ ${w.message}`));
686
+ }
687
+ if (mergedOptions.strictPreflight) {
688
+ bracketedConsoleLog(phasePauseHandle, chalk.red(`\n ✖ --strict-preflight: ${preflightWarnings.length} pre-flight ` +
689
+ `warning(s) — aborting before any worktree is provisioned.`));
690
+ shutdown.dispose();
691
+ return {
692
+ results: [],
693
+ logPath: null,
694
+ exitCode: 1,
695
+ worktreeMap: new Map(),
696
+ issueInfoMap: new Map(),
697
+ config,
698
+ mergedOptions,
699
+ logWriter: null,
700
+ wallClockDurationSeconds: wallClock(),
701
+ };
702
+ }
703
+ }
704
+ }
489
705
  // ── Issue info + worktree setup ────────────────────────────────────
490
706
  const issueInfoMap = new Map();
491
707
  for (const issueNumber of issueNumbers) {
492
708
  issueInfoMap.set(issueNumber, await getIssueInfo(issueNumber));
493
709
  }
710
+ // ── Skills pre-flight (#813) ───────────────────────────────────────
711
+ // Fail fast when the driver resolves phases via `.claude/skills/` and a
712
+ // required skill is missing — BEFORE any worktree is provisioned or
713
+ // per-issue state entry written. Without this, the phase agent hunts for
714
+ // a slash command that can never resolve and the run surfaces as a bogus
715
+ // "spec retry" failure. Skipped for non-skill drivers (aider) and for
716
+ // --dry-run (no agent executes).
717
+ if (!config.dryRun) {
718
+ const preflight = await runSkillsPreflight({
719
+ agent: config.agent,
720
+ aiderSettings: config.aiderSettings,
721
+ phases: config.phases,
722
+ autoDetectPhases: resolved.autoDetectPhases,
723
+ qualityLoop: config.qualityLoop,
724
+ testgen: mergedOptions.testgen,
725
+ securityReview: mergedOptions.securityReview,
726
+ issueNumbers,
727
+ issueInfoMap,
728
+ });
729
+ if (!preflight.ok) {
730
+ bracketedConsoleLog(phasePauseHandle, chalk.red(`\n ✖ Skills pre-flight failed: ${preflight.cause}`));
731
+ bracketedConsoleLog(phasePauseHandle, chalk.red(` ${preflight.remedy}`));
732
+ shutdown.dispose();
733
+ return {
734
+ results: issueNumbers.map((issueNumber) => ({
735
+ issueNumber,
736
+ success: false,
737
+ phaseResults: [],
738
+ durationSeconds: 0,
739
+ loopTriggered: false,
740
+ abortReason: `skills pre-flight failed: ${preflight.cause}`,
741
+ })),
742
+ logPath: null,
743
+ exitCode: 1,
744
+ worktreeMap: new Map(),
745
+ issueInfoMap,
746
+ config,
747
+ mergedOptions,
748
+ logWriter: null,
749
+ wallClockDurationSeconds: wallClock(),
750
+ };
751
+ }
752
+ }
494
753
  const useWorktreeIsolation = mergedOptions.worktreeIsolation !== false && issueNumbers.length > 0;
495
754
  let worktreeMap = new Map();
496
755
  if (useWorktreeIsolation && !config.dryRun) {
@@ -499,7 +758,11 @@ export class RunOrchestrator {
499
758
  title: issueInfoMap.get(num)?.title || `Issue #${num}`,
500
759
  }));
501
760
  if (mergedOptions.chain) {
502
- worktreeMap = await ensureWorktreesChain(issueData, config.verbose, manifest.packageManager, baseBranch);
761
+ // On resume (#760), provision the first incomplete link from the
762
+ // completed prefix's committed tip (resumeBase) instead of the base
763
+ // branch, so the chain rebuilds onto the work already done.
764
+ const chainBase = chainResume?.resumeBase ?? baseBranch;
765
+ worktreeMap = await ensureWorktreesChain(issueData, config.verbose, manifest.packageManager, chainBase);
503
766
  }
504
767
  else {
505
768
  worktreeMap = await ensureWorktrees(issueData, config.verbose, manifest.packageManager, baseBranch);
@@ -524,6 +787,7 @@ export class RunOrchestrator {
524
787
  services: { logWriter, stateManager, shutdownManager: shutdown },
525
788
  packageManager: manifest.packageManager,
526
789
  baseBranch,
790
+ chainResume,
527
791
  onProgress,
528
792
  onPhasePlan: init.onPhasePlan,
529
793
  phasePauseHandle,
@@ -557,10 +821,15 @@ export class RunOrchestrator {
557
821
  endCommit: getCommitHash(process.cwd()),
558
822
  });
559
823
  }
824
+ // #867: the run's wall clock, computed once from the single origin. Both
825
+ // the metrics record and the RunResult below derive from this value — no
826
+ // consumer re-sums per-issue durations (which double-counts under
827
+ // --parallel). Same notion the log stores via finalizeRunLog above.
828
+ const wallClockDurationSeconds = wallClock();
560
829
  // ── Record metrics ─────────────────────────────────────────────
561
830
  if (!config.dryRun && results.length > 0) {
562
831
  try {
563
- await RunOrchestrator.recordMetrics(config, mergedOptions, results, worktreeMap, issueNumbers);
832
+ await RunOrchestrator.recordMetrics(config, mergedOptions, results, worktreeMap, issueNumbers, wallClockDurationSeconds);
564
833
  }
565
834
  catch (metricsError) {
566
835
  logNonFatalWarning(" ! Metrics recording failed, continuing...", metricsError, config.verbose);
@@ -576,6 +845,7 @@ export class RunOrchestrator {
576
845
  config,
577
846
  mergedOptions,
578
847
  logWriter,
848
+ wallClockDurationSeconds,
579
849
  };
580
850
  }
581
851
  finally {
@@ -639,6 +909,108 @@ export class RunOrchestrator {
639
909
  if (shutdown?.shuttingDown) {
640
910
  break;
641
911
  }
912
+ // #760: On chain resume, the completed prefix was skipped, so the first
913
+ // active link (i === 0) is now the resume point. The #748 successor-rebase
914
+ // below only fires for i > 0, so explicitly rebase this first link onto
915
+ // the last completed link's committed tip (resumeBase). This is the
916
+ // authoritative correctness gate: provisioning may have created the
917
+ // worktree fresh (already on resumeBase → no-op) or rebased an existing
918
+ // one, but a provisioning-time rebase failure only warns and continues —
919
+ // here we fail fast so the link never silently executes on the wrong base.
920
+ const chainResume = this.cfg.chainResume;
921
+ if (options.chain && i === 0 && chainResume?.resumeBase) {
922
+ const activeWorktree = this.cfg.worktreeMap.get(issueNumber);
923
+ if (activeWorktree) {
924
+ const rebase = rebaseOntoLocalBranch(activeWorktree.path, chainResume.resumeBase, this.cfg.config.verbose);
925
+ if (!rebase.success) {
926
+ console.log(chalk.yellow(` ⚠️ Chain resume broken: could not rebase #${issueNumber} onto resume base ${chainResume.resumeBase}` +
927
+ (rebase.conflict ? " — merge conflict" : "") +
928
+ `. Stopping the chain; #${issueNumber} and any later issues were not run.`));
929
+ results.push({
930
+ issueNumber,
931
+ success: false,
932
+ phaseResults: [],
933
+ durationSeconds: 0,
934
+ loopTriggered: false,
935
+ abortReason: rebase.conflict
936
+ ? `chain resume rebase conflict onto ${chainResume.resumeBase}`
937
+ : `chain resume rebase failed onto ${chainResume.resumeBase}: ${rebase.error ?? "unknown error"}`,
938
+ });
939
+ break;
940
+ }
941
+ }
942
+ else {
943
+ console.log(chalk.yellow(` ⚠️ Chain resume broken: no worktree for #${issueNumber}. ` +
944
+ `Stopping the chain; #${issueNumber} and any later issues were not run.`));
945
+ results.push({
946
+ issueNumber,
947
+ success: false,
948
+ phaseResults: [],
949
+ durationSeconds: 0,
950
+ loopTriggered: false,
951
+ abortReason: `chain resume could not be established: missing worktree for #${issueNumber}`,
952
+ });
953
+ break;
954
+ }
955
+ }
956
+ // #748: Successors were provisioned up-front (ensureWorktreesChain) while
957
+ // the predecessor branch still pointed at the base, so on a fresh run each
958
+ // successor effectively branched from main. Now that the predecessor has
959
+ // executed and committed, re-rebase this successor's worktree onto the
960
+ // predecessor's *local* committed tip — independent of --stacked, and
961
+ // targeting the local feature branch (not origin/main). This is what makes
962
+ // the chain contract ("each branches from previous") actually hold.
963
+ if (options.chain && i > 0) {
964
+ const successorWorktree = this.cfg.worktreeMap.get(issueNumber);
965
+ const predecessorLocalBranch = this.cfg.worktreeMap.get(issueNumbers[i - 1])?.branch;
966
+ if (successorWorktree && predecessorLocalBranch) {
967
+ const rebase = rebaseOntoLocalBranch(successorWorktree.path, predecessorLocalBranch, this.cfg.config.verbose);
968
+ if (!rebase.success) {
969
+ // A broken chain link must NOT silently produce a successor built on
970
+ // the wrong base — that successor would miss the predecessor's work
971
+ // (the original #748 bug) and the break would propagate to every
972
+ // downstream successor. Treat it like a predecessor failure: warn
973
+ // loudly, record the break, and stop the chain so a human can
974
+ // resolve the conflict and re-run.
975
+ console.log(chalk.yellow(` ⚠️ Chain link broken: could not rebase #${issueNumber} onto #${issueNumbers[i - 1]} (${predecessorLocalBranch})` +
976
+ (rebase.conflict ? " — merge conflict" : "") +
977
+ `. Stopping the chain; #${issueNumber} and any later issues were not run.`));
978
+ results.push({
979
+ issueNumber,
980
+ success: false,
981
+ phaseResults: [],
982
+ durationSeconds: 0,
983
+ loopTriggered: false,
984
+ abortReason: rebase.conflict
985
+ ? `chain rebase conflict onto #${issueNumbers[i - 1]} (${predecessorLocalBranch})`
986
+ : `chain rebase failed onto #${issueNumbers[i - 1]} (${predecessorLocalBranch}): ${rebase.error ?? "unknown error"}`,
987
+ });
988
+ break;
989
+ }
990
+ }
991
+ else {
992
+ // The worktree map is expected to be fully populated for a chain (the
993
+ // --stacked block below treats a missing predecessor branch as
994
+ // unreachable). If it isn't, the successor cannot be chained onto its
995
+ // predecessor's work — the same end state as a rebase conflict — so we
996
+ // break the chain identically rather than letting the successor
997
+ // silently branch from its un-rebased base (the original #748 bug).
998
+ const missing = !successorWorktree
999
+ ? "successor worktree"
1000
+ : `predecessor branch for #${issueNumbers[i - 1]}`;
1001
+ console.log(chalk.yellow(` ⚠️ Chain link could not be established for #${issueNumber}: missing ${missing} in worktree map. ` +
1002
+ `Stopping the chain; #${issueNumber} and any later issues were not run.`));
1003
+ results.push({
1004
+ issueNumber,
1005
+ success: false,
1006
+ phaseResults: [],
1007
+ durationSeconds: 0,
1008
+ loopTriggered: false,
1009
+ abortReason: `chain link could not be established onto #${issueNumbers[i - 1]}: missing ${missing} in worktree map`,
1010
+ });
1011
+ break;
1012
+ }
1013
+ }
642
1014
  // #605: under --stacked, non-first PRs target the predecessor branch.
643
1015
  // The final PR still targets `main` (AC-3 open-question default) so the
644
1016
  // stack can land partially. Manifest renders for every PR in the stack.
@@ -670,14 +1042,13 @@ export class RunOrchestrator {
670
1042
  : undefined,
671
1043
  });
672
1044
  results.push(result);
673
- if (!result.success) {
674
- if (options.qaGate && options.chain) {
675
- const qaFailed = result.phaseResults.some((p) => p.phase === "qa" && !p.success);
676
- if (qaFailed)
677
- break;
678
- }
1045
+ // A chain halts on ANY failed link, QA or otherwise: every successor
1046
+ // rebases onto its predecessor's committed work, so continuing past a
1047
+ // failure would build on a broken or absent base. #795 removed a
1048
+ // `--qa-gate` branch here that re-tested for a QA failure before this
1049
+ // same unconditional break — it could never change the outcome.
1050
+ if (!result.success)
679
1051
  break;
680
- }
681
1052
  }
682
1053
  return results;
683
1054
  }
@@ -762,11 +1133,12 @@ export class RunOrchestrator {
762
1133
  });
763
1134
  }
764
1135
  }
765
- if (logWriter && result.prNumber && result.prUrl) {
766
- logWriter.setPRInfo(result.prNumber, result.prUrl, parallelIssueNumber);
767
- }
1136
+ // Record PR info, flip status on a PR-creation failure (#879), and
1137
+ // finalize — via the shared helper so this live path stays in lockstep
1138
+ // with executeBatch. Before #879 this block omitted the failure flip, so
1139
+ // a real `sequant run` left the run-log status at `success` on PR failure.
768
1140
  if (logWriter) {
769
- logWriter.completeIssue(parallelIssueNumber);
1141
+ recordIssueCompletion(logWriter, result, parallelIssueNumber);
770
1142
  }
771
1143
  return result;
772
1144
  }
@@ -779,9 +1151,13 @@ export class RunOrchestrator {
779
1151
  });
780
1152
  }
781
1153
  }
782
- static async recordMetrics(config, mergedOptions, results, worktreeMap, issueNumbers) {
1154
+ static async recordMetrics(config, mergedOptions, results, worktreeMap, issueNumbers, wallClockDurationSeconds) {
783
1155
  const metricsWriter = new MetricsWriter({ verbose: config.verbose });
784
- const totalDuration = results.reduce((sum, r) => sum + (r.durationSeconds ?? 0), 0);
1156
+ // #867: run wall clock, not the sum of per-issue durations under
1157
+ // --parallel that sum double-counts overlapping issues (same defect the
1158
+ // SUMMARY header had). The orchestrator owns the run's start/end and passes
1159
+ // the single authoritative value in.
1160
+ const totalDuration = wallClockDurationSeconds;
785
1161
  const allPhases = new Set();
786
1162
  for (const result of results) {
787
1163
  for (const pr of result.phaseResults) {
@@ -826,6 +1202,11 @@ export class RunOrchestrator {
826
1202
  cliFlags.push("--testgen");
827
1203
  const tokenUsage = getTokenUsageForRun(undefined, true);
828
1204
  const passed = results.filter((r) => r.success).length;
1205
+ // #761 AC-7: record why the run failed, as a bounded enum. Sequential and
1206
+ // chain runs halt at the first failure, so the first categorized failed
1207
+ // issue is the halting one; in parallel runs it is the earliest-listed
1208
+ // failure. Undefined on success — the field is omitted from the record.
1209
+ const failureCategory = results.find((r) => !r.success && r.failureCategory)?.failureCategory;
829
1210
  await metricsWriter.recordRun({
830
1211
  issues: issueNumbers,
831
1212
  phases: Array.from(allPhases),
@@ -833,6 +1214,7 @@ export class RunOrchestrator {
833
1214
  duration: totalDuration,
834
1215
  model: process.env.ANTHROPIC_MODEL ?? "opus",
835
1216
  flags: cliFlags,
1217
+ failureCategory,
836
1218
  metrics: {
837
1219
  tokensUsed: tokenUsage.tokensUsed,
838
1220
  filesChanged: totalFilesChanged,