sequant 2.2.0 → 2.4.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 (156) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +81 -5
  4. package/dist/bin/cli.js +140 -13
  5. package/dist/src/commands/abort.d.ts +36 -0
  6. package/dist/src/commands/abort.js +138 -0
  7. package/dist/src/commands/doctor.d.ts +25 -0
  8. package/dist/src/commands/doctor.js +36 -1
  9. package/dist/src/commands/locks.d.ts +67 -0
  10. package/dist/src/commands/locks.js +290 -0
  11. package/dist/src/commands/merge.js +11 -0
  12. package/dist/src/commands/prompt.d.ts +46 -0
  13. package/dist/src/commands/prompt.js +273 -0
  14. package/dist/src/commands/run-display.d.ts +11 -2
  15. package/dist/src/commands/run-display.js +62 -28
  16. package/dist/src/commands/run-progress.d.ts +42 -0
  17. package/dist/src/commands/run-progress.js +93 -0
  18. package/dist/src/commands/run.js +90 -18
  19. package/dist/src/commands/stats.d.ts +2 -0
  20. package/dist/src/commands/stats.js +94 -8
  21. package/dist/src/commands/status.js +12 -0
  22. package/dist/src/commands/watch.d.ts +18 -0
  23. package/dist/src/commands/watch.js +211 -0
  24. package/dist/src/lib/ac-linter.d.ts +1 -1
  25. package/dist/src/lib/ac-linter.js +81 -0
  26. package/dist/src/lib/assess-collision-detect.d.ts +91 -0
  27. package/dist/src/lib/assess-collision-detect.js +217 -0
  28. package/dist/src/lib/assess-comment-parser.d.ts +59 -1
  29. package/dist/src/lib/assess-comment-parser.js +124 -2
  30. package/dist/src/lib/cli-ui/format.d.ts +19 -0
  31. package/dist/src/lib/cli-ui/format.js +34 -0
  32. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +220 -0
  33. package/dist/src/lib/cli-ui/run-renderer-types.js +7 -0
  34. package/dist/src/lib/cli-ui/run-renderer.d.ts +265 -0
  35. package/dist/src/lib/cli-ui/run-renderer.js +1390 -0
  36. package/dist/src/lib/cli-ui/scrollback-harness.d.ts +112 -0
  37. package/dist/src/lib/cli-ui/scrollback-harness.js +294 -0
  38. package/dist/src/lib/heuristics/behavior-rule-detector.d.ts +94 -0
  39. package/dist/src/lib/heuristics/behavior-rule-detector.js +467 -0
  40. package/dist/src/lib/locks/index.d.ts +7 -0
  41. package/dist/src/lib/locks/index.js +5 -0
  42. package/dist/src/lib/locks/lock-manager.d.ts +168 -0
  43. package/dist/src/lib/locks/lock-manager.js +433 -0
  44. package/dist/src/lib/locks/types.d.ts +59 -0
  45. package/dist/src/lib/locks/types.js +31 -0
  46. package/dist/src/lib/merge-check/types.js +1 -1
  47. package/dist/src/lib/qa/markdown-only-ci.d.ts +46 -0
  48. package/dist/src/lib/qa/markdown-only-ci.js +74 -0
  49. package/dist/src/lib/relay/activation.d.ts +60 -0
  50. package/dist/src/lib/relay/activation.js +122 -0
  51. package/dist/src/lib/relay/archive.d.ts +34 -0
  52. package/dist/src/lib/relay/archive.js +112 -0
  53. package/dist/src/lib/relay/frame.d.ts +20 -0
  54. package/dist/src/lib/relay/frame.js +76 -0
  55. package/dist/src/lib/relay/index.d.ts +13 -0
  56. package/dist/src/lib/relay/index.js +13 -0
  57. package/dist/src/lib/relay/paths.d.ts +43 -0
  58. package/dist/src/lib/relay/paths.js +59 -0
  59. package/dist/src/lib/relay/pid.d.ts +34 -0
  60. package/dist/src/lib/relay/pid.js +72 -0
  61. package/dist/src/lib/relay/reader.d.ts +35 -0
  62. package/dist/src/lib/relay/reader.js +115 -0
  63. package/dist/src/lib/relay/types.d.ts +70 -0
  64. package/dist/src/lib/relay/types.js +85 -0
  65. package/dist/src/lib/relay/writer.d.ts +48 -0
  66. package/dist/src/lib/relay/writer.js +113 -0
  67. package/dist/src/lib/settings.d.ts +31 -1
  68. package/dist/src/lib/settings.js +18 -3
  69. package/dist/src/lib/version-check.d.ts +60 -5
  70. package/dist/src/lib/version-check.js +97 -9
  71. package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
  72. package/dist/src/lib/workflow/batch-executor.js +274 -185
  73. package/dist/src/lib/workflow/config-resolver.js +4 -0
  74. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +48 -1
  75. package/dist/src/lib/workflow/drivers/aider.d.ts +7 -1
  76. package/dist/src/lib/workflow/drivers/aider.js +9 -0
  77. package/dist/src/lib/workflow/drivers/claude-code.d.ts +17 -1
  78. package/dist/src/lib/workflow/drivers/claude-code.js +51 -2
  79. package/dist/src/lib/workflow/drivers/index.d.ts +1 -1
  80. package/dist/src/lib/workflow/event-emitter.d.ts +157 -0
  81. package/dist/src/lib/workflow/event-emitter.js +102 -0
  82. package/dist/src/lib/workflow/heartbeat.d.ts +71 -0
  83. package/dist/src/lib/workflow/heartbeat.js +194 -0
  84. package/dist/src/lib/workflow/notice.d.ts +32 -0
  85. package/dist/src/lib/workflow/notice.js +38 -0
  86. package/dist/src/lib/workflow/phase-executor.d.ts +58 -16
  87. package/dist/src/lib/workflow/phase-executor.js +244 -130
  88. package/dist/src/lib/workflow/phase-mapper.d.ts +27 -13
  89. package/dist/src/lib/workflow/phase-mapper.js +70 -51
  90. package/dist/src/lib/workflow/phase-registry.d.ts +127 -0
  91. package/dist/src/lib/workflow/phase-registry.js +233 -0
  92. package/dist/src/lib/workflow/platforms/github.d.ts +1 -1
  93. package/dist/src/lib/workflow/platforms/github.js +20 -3
  94. package/dist/src/lib/workflow/pr-status.d.ts +18 -2
  95. package/dist/src/lib/workflow/pr-status.js +41 -9
  96. package/dist/src/lib/workflow/qa-stagnation.d.ts +117 -0
  97. package/dist/src/lib/workflow/qa-stagnation.js +179 -0
  98. package/dist/src/lib/workflow/run-log-schema.d.ts +5 -55
  99. package/dist/src/lib/workflow/run-orchestrator.d.ts +70 -1
  100. package/dist/src/lib/workflow/run-orchestrator.js +464 -25
  101. package/dist/src/lib/workflow/run-reflect.js +1 -1
  102. package/dist/src/lib/workflow/run-state.d.ts +71 -0
  103. package/dist/src/lib/workflow/run-state.js +14 -0
  104. package/dist/src/lib/workflow/state-cleanup.d.ts +13 -5
  105. package/dist/src/lib/workflow/state-cleanup.js +17 -5
  106. package/dist/src/lib/workflow/state-manager.d.ts +31 -2
  107. package/dist/src/lib/workflow/state-manager.js +64 -1
  108. package/dist/src/lib/workflow/state-schema.d.ts +82 -35
  109. package/dist/src/lib/workflow/state-schema.js +63 -4
  110. package/dist/src/lib/workflow/types.d.ts +139 -16
  111. package/dist/src/lib/workflow/types.js +18 -13
  112. package/dist/src/lib/workflow/worktree-manager.d.ts +8 -1
  113. package/dist/src/lib/workflow/worktree-manager.js +15 -6
  114. package/dist/src/mcp/tools/run.d.ts +44 -0
  115. package/dist/src/mcp/tools/run.js +104 -13
  116. package/dist/src/ui/tui/App.d.ts +14 -0
  117. package/dist/src/ui/tui/App.js +41 -0
  118. package/dist/src/ui/tui/ElapsedTimer.d.ts +10 -0
  119. package/dist/src/ui/tui/ElapsedTimer.js +31 -0
  120. package/dist/src/ui/tui/Header.d.ts +6 -0
  121. package/dist/src/ui/tui/Header.js +15 -0
  122. package/dist/src/ui/tui/IssueBox.d.ts +16 -0
  123. package/dist/src/ui/tui/IssueBox.js +68 -0
  124. package/dist/src/ui/tui/Spinner.d.ts +9 -0
  125. package/dist/src/ui/tui/Spinner.js +18 -0
  126. package/dist/src/ui/tui/index.d.ts +15 -0
  127. package/dist/src/ui/tui/index.js +29 -0
  128. package/dist/src/ui/tui/theme.d.ts +29 -0
  129. package/dist/src/ui/tui/theme.js +52 -0
  130. package/dist/src/ui/tui/truncate.d.ts +11 -0
  131. package/dist/src/ui/tui/truncate.js +31 -0
  132. package/package.json +14 -6
  133. package/templates/agents/sequant-explorer.md +1 -0
  134. package/templates/agents/sequant-qa-checker.md +2 -1
  135. package/templates/agents/sequant-testgen.md +1 -0
  136. package/templates/hooks/post-tool.sh +92 -0
  137. package/templates/hooks/pre-tool.sh +18 -9
  138. package/templates/hooks/relay-check.sh +107 -0
  139. package/templates/relay/frame.txt +11 -0
  140. package/templates/scripts/cleanup-worktree.sh +25 -3
  141. package/templates/scripts/new-feature.sh +6 -0
  142. package/templates/skills/_shared/references/behavior-rule-detection.md +205 -0
  143. package/templates/skills/_shared/references/subagent-types.md +21 -8
  144. package/templates/skills/assess/SKILL.md +122 -68
  145. package/templates/skills/assess/references/predicted-collision-detection.md +109 -0
  146. package/templates/skills/docs/SKILL.md +141 -22
  147. package/templates/skills/exec/SKILL.md +10 -8
  148. package/templates/skills/fullsolve/SKILL.md +79 -5
  149. package/templates/skills/loop/SKILL.md +28 -0
  150. package/templates/skills/merger/SKILL.md +621 -0
  151. package/templates/skills/qa/SKILL.md +727 -8
  152. package/templates/skills/setup/SKILL.md +12 -6
  153. package/templates/skills/spec/SKILL.md +52 -0
  154. package/templates/skills/spec/references/parallel-groups.md +7 -0
  155. package/templates/skills/spec/references/recommended-workflow.md +4 -2
  156. package/templates/skills/testgen/SKILL.md +24 -17
@@ -9,17 +9,50 @@
9
9
  import chalk from "chalk";
10
10
  import { spawnSync } from "child_process";
11
11
  import pLimit from "p-limit";
12
+ import { formatCoarseNowLine } from "./run-state.js";
12
13
  import { detectDefaultBranch, ensureWorktrees, ensureWorktreesChain, getWorktreeDiffStats, } from "./worktree-manager.js";
13
14
  import { LogWriter } from "./log-writer.js";
14
15
  import { StateManager } from "./state-manager.js";
15
16
  import { ShutdownManager } from "../shutdown.js";
16
- import { getIssueInfo, sortByDependencies, parseBatches, runIssueWithLogging, } from "./batch-executor.js";
17
+ import { LockManager, formatLockedMessage } from "../locks/index.js";
18
+ import { bracketedConsoleLog } from "./notice.js";
19
+ /** Human-readable line for the run-orchestrator's `--signal-other` log (#637). */
20
+ function formatSignalLine(issue, pid, result) {
21
+ switch (result.reason) {
22
+ case "sent":
23
+ return ` Signaled PID ${pid} (SIGTERM) for #${issue}`;
24
+ case "cross-host":
25
+ return ` Could not signal PID ${pid} for #${issue} (cross-host holder)`;
26
+ case "self-or-parent":
27
+ return ` Refused to signal PID ${pid} for #${issue} (matches this process or its parent)`;
28
+ case "pid-dead":
29
+ return ` Could not signal PID ${pid} for #${issue} (already exited)`;
30
+ case "kill-failed":
31
+ return ` Could not signal PID ${pid} for #${issue} (kill syscall failed)`;
32
+ case "orchestrator":
33
+ return ` Skipped signal for #${issue} (orchestrator mode)`;
34
+ }
35
+ }
36
+ import { getIssueInfo, sortByDependencies, parseBatches, runIssueWithLogging, emitRunIdLine, } from "./batch-executor.js";
17
37
  import { reconcileStateAtStartup } from "./state-utils.js";
18
38
  import { getCommitHash } from "./git-diff-utils.js";
19
39
  import { MetricsWriter } from "./metrics-writer.js";
40
+ import { WorkflowEventEmitter } from "./event-emitter.js";
20
41
  import { determineOutcome } from "./metrics-schema.js";
21
42
  import { getTokenUsageForRun } from "./token-utils.js";
22
43
  import { resolveRunOptions, buildExecutionConfig } from "./config-resolver.js";
44
+ /**
45
+ * Build the stack-manifest line emitted into PR bodies under --stacked.
46
+ *
47
+ * Example for issues `[100, 101, 102]` at `currentIndex=1`:
48
+ * `Part of stack: #100 → #101 (this) → #102`
49
+ *
50
+ * @internal Exported for testing.
51
+ */
52
+ export function buildStackManifest(issueNumbers, currentIndex) {
53
+ const parts = issueNumbers.map((n, i) => i === currentIndex ? `#${n} (this)` : `#${n}`);
54
+ return `Part of stack: ${parts.join(" → ")}`;
55
+ }
23
56
  // ── Orchestrator ────────────────────────────────────────────────────────────
24
57
  /**
25
58
  * CLI-free workflow execution engine.
@@ -32,9 +65,199 @@ import { resolveRunOptions, buildExecutionConfig } from "./config-resolver.js";
32
65
  */
33
66
  export class RunOrchestrator {
34
67
  cfg;
68
+ issueStates = new Map();
69
+ phaseStartTimes = new Map();
70
+ emitter;
71
+ done = false;
35
72
  constructor(config) {
36
73
  this.validate(config);
37
- this.cfg = config;
74
+ // Build the event emitter before wrapProgress so the wrapper can route
75
+ // status transitions through `issue_status_changed` events (AC-3).
76
+ this.emitter = new WorkflowEventEmitter({
77
+ onListenerError: (event, error) => {
78
+ // Mirror the orchestrator's verbose-gated non-fatal warning style.
79
+ // Listener failures must never propagate to the run.
80
+ logNonFatalWarning(` ! Event listener for "${event}" threw, ignoring`, error, config.config?.verbose ?? false);
81
+ },
82
+ });
83
+ this.cfg = { ...config, onProgress: this.wrapProgress(config.onProgress) };
84
+ this.initIssueStates();
85
+ }
86
+ /**
87
+ * Returns the workflow event emitter. External consumers (TUI, MCP server,
88
+ * future webhooks) call `getEmitter().on(...)` to subscribe to lifecycle
89
+ * events. Subscribing is opt-in — the orchestrator runs unaware of who is
90
+ * listening (#504, AC-3).
91
+ */
92
+ getEmitter() {
93
+ return this.emitter;
94
+ }
95
+ /**
96
+ * Point-in-time view of the entire run.
97
+ *
98
+ * Safe under concurrent reads: the returned object contains only freshly
99
+ * allocated arrays and plain records; no internal Map or mutable state
100
+ * reference is leaked. Callers may hold snapshots across awaits without
101
+ * observing torn writes.
102
+ */
103
+ getSnapshot() {
104
+ const { config } = this.cfg;
105
+ const snapshotConfig = {
106
+ concurrency: config.concurrency,
107
+ baseBranch: this.cfg.baseBranch ?? "main",
108
+ qualityLoop: config.qualityLoop,
109
+ };
110
+ const issues = [];
111
+ for (const state of this.issueStates.values()) {
112
+ issues.push(cloneIssueState(state));
113
+ }
114
+ return {
115
+ config: snapshotConfig,
116
+ issues,
117
+ done: this.done,
118
+ capturedAt: new Date(),
119
+ };
120
+ }
121
+ /**
122
+ * Mark the run as completed so the dashboard can unmount and drop event
123
+ * subscribers. Drains the emitter to prevent leaks across multiple
124
+ * `run()` invocations in the same process (e.g. the MCP server).
125
+ */
126
+ markDone() {
127
+ this.done = true;
128
+ this.emitter.removeAllListeners();
129
+ }
130
+ initIssueStates() {
131
+ const { issueInfoMap, worktreeMap, config } = this.cfg;
132
+ for (const [num, info] of issueInfoMap.entries()) {
133
+ const branch = worktreeMap.get(num)?.branch ?? `#${num}`;
134
+ const phases = config.phases.map((name) => ({
135
+ name,
136
+ status: "pending",
137
+ }));
138
+ this.issueStates.set(num, {
139
+ number: num,
140
+ title: info.title,
141
+ branch,
142
+ status: "queued",
143
+ phases,
144
+ });
145
+ }
146
+ }
147
+ wrapProgress(external) {
148
+ return (issue, phase, event, extra) => {
149
+ this.applyProgressEvent(issue, phase, event, extra);
150
+ external?.(issue, phase, event, extra);
151
+ };
152
+ }
153
+ applyProgressEvent(issue, phase, event, extra) {
154
+ const state = this.issueStates.get(issue);
155
+ if (!state)
156
+ return;
157
+ if (event === "start") {
158
+ const wasStatus = state.status;
159
+ if (!state.startedAt)
160
+ state.startedAt = new Date();
161
+ state.status = "running";
162
+ const now = new Date();
163
+ this.phaseStartTimes.set(`${issue}:${phase}`, now.getTime());
164
+ state.currentPhase = {
165
+ name: phase,
166
+ startedAt: now,
167
+ lastActivityAt: now,
168
+ nowLine: formatCoarseNowLine(phase),
169
+ };
170
+ const p = findOrAppendPhase(state, phase);
171
+ p.status = "running";
172
+ p.startedAt = now;
173
+ // Fire-and-forget — listener safety guaranteed by the emitter (AC-5).
174
+ void this.emitter.emit("phase_started", {
175
+ issueNumber: issue,
176
+ phase,
177
+ iteration: extra?.iteration,
178
+ });
179
+ if (wasStatus !== "running") {
180
+ void this.emitter.emit("issue_status_changed", {
181
+ issueNumber: issue,
182
+ from: wasStatus,
183
+ to: "running",
184
+ });
185
+ }
186
+ return;
187
+ }
188
+ if (event === "activity") {
189
+ // Ignore activity for stale phases (race between completion and a
190
+ // final flushed output chunk).
191
+ if (!state.currentPhase || state.currentPhase.name !== phase)
192
+ return;
193
+ const line = extractActivityLine(extra?.text);
194
+ if (!line)
195
+ return;
196
+ state.currentPhase.nowLine = line;
197
+ state.currentPhase.lastActivityAt = new Date();
198
+ void this.emitter.emit("progress", {
199
+ issueNumber: issue,
200
+ phase,
201
+ text: line,
202
+ });
203
+ return;
204
+ }
205
+ // complete / failed
206
+ const key = `${issue}:${phase}`;
207
+ const startMs = this.phaseStartTimes.get(key);
208
+ this.phaseStartTimes.delete(key);
209
+ const elapsedMs = extra?.durationSeconds != null
210
+ ? extra.durationSeconds * 1000
211
+ : startMs != null
212
+ ? Date.now() - startMs
213
+ : undefined;
214
+ const p = findOrAppendPhase(state, phase);
215
+ p.status = event === "complete" ? "done" : "failed";
216
+ p.elapsedMs = elapsedMs;
217
+ state.currentPhase = undefined;
218
+ const durationSec = elapsedMs !== undefined ? Math.round(elapsedMs / 1000) : undefined;
219
+ if (event === "failed") {
220
+ const prev = state.status;
221
+ state.status = "failed";
222
+ state.completedAt = new Date();
223
+ void this.emitter.emit("phase_failed", {
224
+ issueNumber: issue,
225
+ phase,
226
+ duration: durationSec,
227
+ error: extra?.error ?? "unknown",
228
+ iteration: extra?.iteration,
229
+ });
230
+ if (prev !== "failed") {
231
+ void this.emitter.emit("issue_status_changed", {
232
+ issueNumber: issue,
233
+ from: prev,
234
+ to: "failed",
235
+ });
236
+ }
237
+ return;
238
+ }
239
+ void this.emitter.emit("phase_completed", {
240
+ issueNumber: issue,
241
+ phase,
242
+ duration: durationSec ?? 0,
243
+ iteration: extra?.iteration,
244
+ });
245
+ // Completed phase: if it's the last phase in the plan, mark issue passed.
246
+ const allDone = state.phases.every((ph) => ph.status === "done" || ph.status === "failed");
247
+ if (allDone) {
248
+ const prev = state.status;
249
+ state.status = state.phases.some((ph) => ph.status === "failed")
250
+ ? "failed"
251
+ : "passed";
252
+ state.completedAt = new Date();
253
+ if (prev !== state.status) {
254
+ void this.emitter.emit("issue_status_changed", {
255
+ issueNumber: issue,
256
+ from: prev,
257
+ to: state.status,
258
+ });
259
+ }
260
+ }
38
261
  }
39
262
  /**
40
263
  * Pure config resolution — no side effects.
@@ -95,7 +318,7 @@ export class RunOrchestrator {
95
318
  * issue discovery → worktree creation → execution → metrics → cleanup.
96
319
  */
97
320
  static async run(init, issueArgs, batches) {
98
- const { manifest, onProgress, settings } = init;
321
+ const { manifest, onProgress, phasePauseHandle, settings } = init;
99
322
  // ── Config resolution ──────────────────────────────────────────────
100
323
  const resolved = RunOrchestrator.resolveConfig(init, issueArgs, batches);
101
324
  const { mergedOptions, config, baseBranch } = resolved;
@@ -134,6 +357,9 @@ export class RunOrchestrator {
134
357
  startCommit: getCommitHash(process.cwd()),
135
358
  });
136
359
  await logWriter.initialize(runConfig);
360
+ const runId = logWriter.getRunId();
361
+ if (runId)
362
+ emitRunIdLine(runId);
137
363
  }
138
364
  catch (err) {
139
365
  const msg = err instanceof Error ? err.message : String(err);
@@ -203,6 +429,63 @@ export class RunOrchestrator {
203
429
  }
204
430
  }
205
431
  }
432
+ // ── Concurrency lock (#625) ────────────────────────────────────────
433
+ // Acquired here — after the state guard, before worktree creation — so
434
+ // that issues already filtered as ready_for_merge don't claim locks they
435
+ // wouldn't release. Locked issues are skipped from the run with a
436
+ // synthetic IssueResult so the batch continues.
437
+ const lockManager = new LockManager();
438
+ const lockedResults = [];
439
+ if (!lockManager.isNoop && !config.dryRun) {
440
+ const commandLabel = `npx sequant run ${issueNumbers.join(" ")}`;
441
+ const claimed = [];
442
+ for (const issueNumber of issueNumbers) {
443
+ const claim = mergedOptions.force
444
+ ? (() => {
445
+ const { previous } = lockManager.forceAcquire(issueNumber, commandLabel);
446
+ if (previous && mergedOptions.signalOther) {
447
+ const result = lockManager.signalOther(previous);
448
+ console.log(chalk.gray(formatSignalLine(issueNumber, previous.pid, result)));
449
+ }
450
+ return { acquired: true };
451
+ })()
452
+ : lockManager.acquire(issueNumber, commandLabel);
453
+ if (claim.acquired) {
454
+ claimed.push(issueNumber);
455
+ }
456
+ else {
457
+ lockedResults.push(buildLockedResult(issueNumber, claim.holder));
458
+ console.log(chalk.yellow(` ! ${formatLockedMessage(issueNumber, claim.holder)}`));
459
+ }
460
+ }
461
+ issueNumbers = claimed;
462
+ if (claimed.length > 0) {
463
+ shutdown.registerCleanup("Release issue locks", async () => {
464
+ lockManager.releaseAll();
465
+ });
466
+ // Sync cleanup for SIGKILL / uncaughtException paths. process.on('exit')
467
+ // only fires sync handlers; this is the best-effort safety net for
468
+ // events ShutdownManager doesn't catch.
469
+ const exitHandler = () => lockManager.releaseAll();
470
+ process.on("exit", exitHandler);
471
+ shutdown.registerCleanup("Detach exit-handler", async () => {
472
+ process.off("exit", exitHandler);
473
+ });
474
+ }
475
+ if (issueNumbers.length === 0) {
476
+ shutdown.dispose();
477
+ return {
478
+ results: lockedResults,
479
+ logPath: null,
480
+ exitCode: lockedResults.length > 0 ? 1 : 0,
481
+ worktreeMap: new Map(),
482
+ issueInfoMap: new Map(),
483
+ config,
484
+ mergedOptions,
485
+ logWriter: null,
486
+ };
487
+ }
488
+ }
206
489
  // ── Issue info + worktree setup ────────────────────────────────────
207
490
  const issueInfoMap = new Map();
208
491
  for (const issueNumber of issueNumbers) {
@@ -233,26 +516,33 @@ export class RunOrchestrator {
233
516
  }
234
517
  // ── Execute ────────────────────────────────────────────────────────
235
518
  let results = [];
519
+ const orchestrator = new RunOrchestrator({
520
+ config,
521
+ options: mergedOptions,
522
+ issueInfoMap,
523
+ worktreeMap,
524
+ services: { logWriter, stateManager, shutdownManager: shutdown },
525
+ packageManager: manifest.packageManager,
526
+ baseBranch,
527
+ onProgress,
528
+ onPhasePlan: init.onPhasePlan,
529
+ phasePauseHandle,
530
+ });
531
+ init.onOrchestratorReady?.(orchestrator);
236
532
  try {
237
- const orchestrator = new RunOrchestrator({
238
- config,
239
- options: mergedOptions,
240
- issueInfoMap,
241
- worktreeMap,
242
- services: { logWriter, stateManager, shutdownManager: shutdown },
243
- packageManager: manifest.packageManager,
244
- baseBranch,
245
- onProgress,
246
- });
247
533
  if (resolvedBatches) {
248
534
  for (let batchIdx = 0; batchIdx < resolvedBatches.length; batchIdx++) {
249
535
  const batch = resolvedBatches[batchIdx];
250
- console.log(chalk.blue(`\n Batch ${batchIdx + 1}/${resolvedBatches.length}: Issues ${batch.map((n) => `#${n}`).join(", ")}`));
536
+ // #647 AC-3: between-batches in a multi-batch run, the renderer is
537
+ // still alive and may have a populated live zone from the previous
538
+ // batch. Route through `bracketedConsoleLog` so log-update's cursor
539
+ // model stays consistent.
540
+ bracketedConsoleLog(phasePauseHandle, chalk.blue(`\n Batch ${batchIdx + 1}/${resolvedBatches.length}: Issues ${batch.map((n) => `#${n}`).join(", ")}`));
251
541
  const batchResults = await orchestrator.execute(batch);
252
542
  results.push(...batchResults);
253
543
  const batchFailed = batchResults.some((r) => !r.success);
254
544
  if (batchFailed && config.sequential) {
255
- console.log(chalk.yellow(`\n ! Batch ${batchIdx + 1} failed, stopping batch execution`));
545
+ bracketedConsoleLog(phasePauseHandle, chalk.yellow(`\n ! Batch ${batchIdx + 1} failed, stopping batch execution`));
256
546
  break;
257
547
  }
258
548
  }
@@ -276,10 +566,11 @@ export class RunOrchestrator {
276
566
  logNonFatalWarning(" ! Metrics recording failed, continuing...", metricsError, config.verbose);
277
567
  }
278
568
  }
569
+ const allResults = [...lockedResults, ...results];
279
570
  return {
280
- results,
571
+ results: allResults,
281
572
  logPath,
282
- exitCode: results.some((r) => !r.success) && !config.dryRun ? 1 : 0,
573
+ exitCode: allResults.some((r) => !r.success) && !config.dryRun ? 1 : 0,
283
574
  worktreeMap,
284
575
  issueInfoMap,
285
576
  config,
@@ -288,6 +579,7 @@ export class RunOrchestrator {
288
579
  };
289
580
  }
290
581
  finally {
582
+ orchestrator.markDone();
291
583
  shutdown.dispose();
292
584
  }
293
585
  }
@@ -335,6 +627,8 @@ export class RunOrchestrator {
335
627
  packageManager: this.cfg.packageManager,
336
628
  baseBranch: this.cfg.baseBranch,
337
629
  onProgress: this.cfg.onProgress,
630
+ onPhasePlan: this.cfg.onPhasePlan,
631
+ phasePauseHandle: this.cfg.phasePauseHandle,
338
632
  };
339
633
  }
340
634
  async executeSequential(issueNumbers, batchCtx, options) {
@@ -345,11 +639,34 @@ export class RunOrchestrator {
345
639
  if (shutdown?.shuttingDown) {
346
640
  break;
347
641
  }
642
+ // #605: under --stacked, non-first PRs target the predecessor branch.
643
+ // The final PR still targets `main` (AC-3 open-question default) so the
644
+ // stack can land partially. Manifest renders for every PR in the stack.
645
+ let predecessorBranch;
646
+ let stackManifest;
647
+ if (options.chain && options.stacked) {
648
+ if (i > 0 && i < issueNumbers.length - 1) {
649
+ // Invariant: chain breaks on prior failure (see `break` below), so the
650
+ // predecessor's worktree is always in worktreeMap when we reach this
651
+ // branch. The optional-chained fallback to undefined is unreachable.
652
+ predecessorBranch = this.cfg.worktreeMap.get(issueNumbers[i - 1])?.branch;
653
+ }
654
+ else if (i > 0) {
655
+ // Last PR: still emit manifest, but base stays main (no predecessor).
656
+ // intentionally undefined predecessorBranch
657
+ }
658
+ stackManifest = buildStackManifest(issueNumbers, i);
659
+ }
348
660
  const result = await this.executeOneIssue({
349
661
  issueNumber,
350
662
  batchCtx,
351
663
  chain: options.chain
352
- ? { enabled: true, isLast: i === issueNumbers.length - 1 }
664
+ ? {
665
+ enabled: true,
666
+ isLast: i === issueNumbers.length - 1,
667
+ predecessorBranch,
668
+ stackManifest,
669
+ }
353
670
  : undefined,
354
671
  });
355
672
  results.push(result);
@@ -398,7 +715,7 @@ export class RunOrchestrator {
398
715
  }
399
716
  async executeOneIssue(args) {
400
717
  const { issueNumber, batchCtx, chain, parallelIssueNumber } = args;
401
- const { config, options, issueInfoMap, worktreeMap, logWriter, stateManager, shutdownManager, packageManager, baseBranch, onProgress, } = batchCtx;
718
+ const { config, options, issueInfoMap, worktreeMap, logWriter, stateManager, shutdownManager, packageManager, baseBranch, onProgress, onPhasePlan, phasePauseHandle, } = batchCtx;
402
719
  const issueInfo = issueInfoMap.get(issueNumber) ?? {
403
720
  title: `Issue #${issueNumber}`,
404
721
  labels: [],
@@ -421,15 +738,46 @@ export class RunOrchestrator {
421
738
  packageManager,
422
739
  baseBranch,
423
740
  onProgress,
741
+ onPhasePlan,
742
+ phasePauseHandle,
424
743
  };
425
- const result = await runIssueWithLogging(ctx);
426
- if (logWriter && result.prNumber && result.prUrl) {
427
- logWriter.setPRInfo(result.prNumber, result.prUrl, parallelIssueNumber);
744
+ // Fire-and-forget orchestrator does not await listener completion on
745
+ // the lifecycle bracket events. Listener safety is the emitter's job (AC-5).
746
+ void this.emitter.emit("run_started", { issueNumber });
747
+ const issueStartedAt = Date.now();
748
+ // `run_completed` is emitted in the finally so the bracket stays
749
+ // symmetric with `run_started` even if `runIssueWithLogging` throws —
750
+ // subscribers (MCP, dashboard) can rely on every started run ending.
751
+ let result;
752
+ try {
753
+ result = await runIssueWithLogging(ctx);
754
+ // Surface QA verdicts as a dedicated event so consumers don't have to
755
+ // re-parse phase output. Emits at most once per QA phase result.
756
+ for (const pr of result.phaseResults) {
757
+ if (pr.phase === "qa" && pr.verdict) {
758
+ void this.emitter.emit("qa_verdict", {
759
+ issueNumber,
760
+ phase: "qa",
761
+ verdict: pr.verdict,
762
+ });
763
+ }
764
+ }
765
+ if (logWriter && result.prNumber && result.prUrl) {
766
+ logWriter.setPRInfo(result.prNumber, result.prUrl, parallelIssueNumber);
767
+ }
768
+ if (logWriter) {
769
+ logWriter.completeIssue(parallelIssueNumber);
770
+ }
771
+ return result;
428
772
  }
429
- if (logWriter) {
430
- logWriter.completeIssue(parallelIssueNumber);
773
+ finally {
774
+ const durationSec = Math.round((Date.now() - issueStartedAt) / 1000);
775
+ void this.emitter.emit("run_completed", {
776
+ issueNumber,
777
+ duration: durationSec,
778
+ success: result?.success ?? false,
779
+ });
431
780
  }
432
- return result;
433
781
  }
434
782
  static async recordMetrics(config, mergedOptions, results, worktreeMap, issueNumbers) {
435
783
  const metricsWriter = new MetricsWriter({ verbose: config.verbose });
@@ -501,6 +849,97 @@ export class RunOrchestrator {
501
849
  }
502
850
  }
503
851
  }
852
+ function findOrAppendPhase(state, name) {
853
+ let p = state.phases.find((ph) => ph.name === name);
854
+ if (!p) {
855
+ p = { name, status: "pending" };
856
+ state.phases.push(p);
857
+ }
858
+ return p;
859
+ }
860
+ /**
861
+ * Activity is considered stale (and `nowLine` falls back to the coarse
862
+ * `running <phase>` form) once it goes this long without an update (#543).
863
+ */
864
+ const ACTIVITY_STALE_MS = 5_000;
865
+ function cloneIssueState(s) {
866
+ return {
867
+ number: s.number,
868
+ title: s.title,
869
+ branch: s.branch,
870
+ status: s.status,
871
+ startedAt: s.startedAt,
872
+ completedAt: s.completedAt,
873
+ phases: s.phases.map((p) => ({
874
+ name: p.name,
875
+ status: p.status,
876
+ startedAt: p.startedAt,
877
+ elapsedMs: p.elapsedMs,
878
+ })),
879
+ currentPhase: s.currentPhase
880
+ ? {
881
+ name: s.currentPhase.name,
882
+ startedAt: s.currentPhase.startedAt,
883
+ lastActivityAt: s.currentPhase.lastActivityAt,
884
+ nowLine: nowLineWithStaleFallback(s.currentPhase),
885
+ logPath: s.currentPhase.logPath,
886
+ }
887
+ : undefined,
888
+ };
889
+ }
890
+ function nowLineWithStaleFallback(current) {
891
+ const ageMs = Date.now() - current.lastActivityAt.getTime();
892
+ if (ageMs >= ACTIVITY_STALE_MS) {
893
+ return formatCoarseNowLine(current.name);
894
+ }
895
+ return current.nowLine;
896
+ }
897
+ /**
898
+ * Reduce a chunk of streamed agent output to a single line suitable for the
899
+ * activity row. Strips ANSI sequences and trailing whitespace; returns the
900
+ * last non-empty line, truncated to keep the cell render cheap. Returns
901
+ * `undefined` when the chunk contains no usable content.
902
+ */
903
+ function extractActivityLine(raw) {
904
+ if (!raw)
905
+ return undefined;
906
+ // Strip ANSI CSI escapes — covers SGR (colour/bold, `…m`), cursor-movement
907
+ // and line-clear codes (`\x1b[2K`, `\x1b[G`), and DEC private-mode toggles
908
+ // (`\x1b[?25l`), any of which can leak through chalk/ink in agent output.
909
+ // eslint-disable-next-line no-control-regex
910
+ const cleaned = raw.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "");
911
+ const lines = cleaned.split(/\r?\n/);
912
+ let last = "";
913
+ for (let i = lines.length - 1; i >= 0; i--) {
914
+ const trimmed = lines[i].trim();
915
+ if (trimmed.length > 0) {
916
+ last = trimmed;
917
+ break;
918
+ }
919
+ }
920
+ if (!last)
921
+ return undefined;
922
+ // Bound at 200 chars; the TUI truncates further per row width.
923
+ return last.length > 200 ? last.slice(0, 200) : last;
924
+ }
925
+ /**
926
+ * Build the synthetic `IssueResult` returned for an issue that was skipped
927
+ * because another sequant session holds its lock (#625).
928
+ */
929
+ export function buildLockedResult(issueNumber, holder) {
930
+ return {
931
+ issueNumber,
932
+ success: false,
933
+ phaseResults: [],
934
+ abortReason: `locked by PID ${holder.pid}`,
935
+ locked: {
936
+ pid: holder.pid,
937
+ hostname: holder.hostname,
938
+ startedAt: holder.startedAt,
939
+ command: holder.command,
940
+ },
941
+ };
942
+ }
504
943
  /** Log a non-fatal warning: one-line summary always, detail in verbose. */
505
944
  export function logNonFatalWarning(message, error, verbose) {
506
945
  console.log(chalk.yellow(message));
@@ -33,7 +33,7 @@ function analyzeTimingPatterns(input, observations, suggestions) {
33
33
  // If spec times are similar (within 30%) despite different issues, flag it
34
34
  if (max > 0 && min / max > 0.7 && max - min < 120) {
35
35
  observations.push(`Spec times similar across issues (${formatSec(min)}–${formatSec(max)}) despite varying complexity`);
36
- suggestions.push("Consider `--phases exec,qa` for simple fixes to skip spec");
36
+ suggestions.push("Consider `--phases exec,qa` for issues where spec is not adding value (the default includes spec, #533)");
37
37
  }
38
38
  }
39
39
  // Flag individual phases that took unusually long
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Runtime state snapshots for multi-issue dashboard rendering.
3
+ *
4
+ * Decouples the TUI from the orchestrator: `getSnapshot()` returns an
5
+ * immutable, plain-object view of current run state that can be safely
6
+ * read from a render loop without holding locks.
7
+ */
8
+ import type { Phase } from "./types.js";
9
+ /** Top-level lifecycle status of a single issue. */
10
+ export type IssueStatus = "queued" | "running" | "passed" | "failed";
11
+ /** Per-phase status within an issue. */
12
+ export type PhaseStatus = "pending" | "running" | "done" | "failed";
13
+ /**
14
+ * One phase's runtime state.
15
+ * `elapsedMs` is populated once a phase reaches `done` or `failed`.
16
+ */
17
+ export interface PhaseRuntimeState {
18
+ name: string;
19
+ status: PhaseStatus;
20
+ startedAt?: Date;
21
+ elapsedMs?: number;
22
+ }
23
+ /**
24
+ * State of the currently running phase for an issue.
25
+ * Populated only while a phase is active. Dashboard consumers render
26
+ * `nowLine` as the activity row and tick `lastActivityAt` for the stamp.
27
+ */
28
+ export interface CurrentPhaseState {
29
+ name: string;
30
+ startedAt: Date;
31
+ lastActivityAt: Date;
32
+ nowLine: string;
33
+ logPath?: string;
34
+ }
35
+ /** Complete runtime state for a single issue. */
36
+ export interface IssueRuntimeState {
37
+ number: number;
38
+ title: string;
39
+ branch: string;
40
+ status: IssueStatus;
41
+ phases: PhaseRuntimeState[];
42
+ currentPhase?: CurrentPhaseState;
43
+ startedAt?: Date;
44
+ completedAt?: Date;
45
+ }
46
+ /** Run-level configuration captured at start for the header. */
47
+ export interface RunSnapshotConfig {
48
+ concurrency: number;
49
+ baseBranch: string;
50
+ baseSha?: string;
51
+ baseFetchedAt?: Date;
52
+ qualityLoop: boolean;
53
+ }
54
+ /**
55
+ * A consistent point-in-time view of the entire run.
56
+ *
57
+ * Returned as a freshly-allocated plain object by `getSnapshot()`; callers
58
+ * may read fields concurrently without further synchronization because no
59
+ * internal mutable references are leaked.
60
+ */
61
+ export interface RunSnapshot {
62
+ config: RunSnapshotConfig;
63
+ issues: IssueRuntimeState[];
64
+ done: boolean;
65
+ capturedAt: Date;
66
+ }
67
+ /**
68
+ * Format a coarse "now" line for a phase transition.
69
+ * Used as the M1 default when no finer activity signal exists.
70
+ */
71
+ export declare function formatCoarseNowLine(phase: Phase | string): string;