sequant 2.1.2 → 2.3.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 (146) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +73 -0
  4. package/dist/bin/cli.js +95 -9
  5. package/dist/src/commands/doctor.d.ts +25 -0
  6. package/dist/src/commands/doctor.js +36 -1
  7. package/dist/src/commands/init.d.ts +1 -0
  8. package/dist/src/commands/init.js +118 -0
  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 +39 -0
  13. package/dist/src/commands/prompt.js +179 -0
  14. package/dist/src/commands/run-display.d.ts +26 -0
  15. package/dist/src/commands/run-display.js +150 -0
  16. package/dist/src/commands/run-progress.d.ts +32 -0
  17. package/dist/src/commands/run-progress.js +76 -0
  18. package/dist/src/commands/run.js +83 -73
  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 +27 -1
  22. package/dist/src/commands/watch.d.ts +16 -0
  23. package/dist/src/commands/watch.js +147 -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 +181 -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 +239 -0
  35. package/dist/src/lib/cli-ui/run-renderer.js +1173 -0
  36. package/dist/src/lib/heuristics/behavior-rule-detector.d.ts +94 -0
  37. package/dist/src/lib/heuristics/behavior-rule-detector.js +467 -0
  38. package/dist/src/lib/locks/index.d.ts +7 -0
  39. package/dist/src/lib/locks/index.js +5 -0
  40. package/dist/src/lib/locks/lock-manager.d.ts +168 -0
  41. package/dist/src/lib/locks/lock-manager.js +433 -0
  42. package/dist/src/lib/locks/types.d.ts +59 -0
  43. package/dist/src/lib/locks/types.js +31 -0
  44. package/dist/src/lib/qa/markdown-only-ci.d.ts +46 -0
  45. package/dist/src/lib/qa/markdown-only-ci.js +74 -0
  46. package/dist/src/lib/relay/activation.d.ts +60 -0
  47. package/dist/src/lib/relay/activation.js +122 -0
  48. package/dist/src/lib/relay/archive.d.ts +34 -0
  49. package/dist/src/lib/relay/archive.js +106 -0
  50. package/dist/src/lib/relay/frame.d.ts +20 -0
  51. package/dist/src/lib/relay/frame.js +76 -0
  52. package/dist/src/lib/relay/index.d.ts +13 -0
  53. package/dist/src/lib/relay/index.js +13 -0
  54. package/dist/src/lib/relay/paths.d.ts +43 -0
  55. package/dist/src/lib/relay/paths.js +59 -0
  56. package/dist/src/lib/relay/pid.d.ts +34 -0
  57. package/dist/src/lib/relay/pid.js +72 -0
  58. package/dist/src/lib/relay/reader.d.ts +35 -0
  59. package/dist/src/lib/relay/reader.js +115 -0
  60. package/dist/src/lib/relay/types.d.ts +68 -0
  61. package/dist/src/lib/relay/types.js +76 -0
  62. package/dist/src/lib/relay/writer.d.ts +48 -0
  63. package/dist/src/lib/relay/writer.js +113 -0
  64. package/dist/src/lib/settings.d.ts +31 -1
  65. package/dist/src/lib/settings.js +18 -3
  66. package/dist/src/lib/skill-version.d.ts +19 -0
  67. package/dist/src/lib/skill-version.js +68 -0
  68. package/dist/src/lib/templates.d.ts +1 -0
  69. package/dist/src/lib/templates.js +1 -1
  70. package/dist/src/lib/version-check.d.ts +60 -5
  71. package/dist/src/lib/version-check.js +97 -9
  72. package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
  73. package/dist/src/lib/workflow/batch-executor.js +249 -176
  74. package/dist/src/lib/workflow/config-resolver.js +4 -0
  75. package/dist/src/lib/workflow/heartbeat.d.ts +71 -0
  76. package/dist/src/lib/workflow/heartbeat.js +194 -0
  77. package/dist/src/lib/workflow/phase-executor.d.ts +88 -3
  78. package/dist/src/lib/workflow/phase-executor.js +276 -52
  79. package/dist/src/lib/workflow/phase-mapper.d.ts +3 -2
  80. package/dist/src/lib/workflow/phase-mapper.js +17 -20
  81. package/dist/src/lib/workflow/platforms/github.d.ts +1 -1
  82. package/dist/src/lib/workflow/platforms/github.js +20 -3
  83. package/dist/src/lib/workflow/pr-status.d.ts +18 -2
  84. package/dist/src/lib/workflow/pr-status.js +41 -9
  85. package/dist/src/lib/workflow/qa-stagnation.d.ts +117 -0
  86. package/dist/src/lib/workflow/qa-stagnation.js +179 -0
  87. package/dist/src/lib/workflow/run-orchestrator.d.ts +76 -0
  88. package/dist/src/lib/workflow/run-orchestrator.js +382 -29
  89. package/dist/src/lib/workflow/run-reflect.js +1 -1
  90. package/dist/src/lib/workflow/run-state.d.ts +71 -0
  91. package/dist/src/lib/workflow/run-state.js +14 -0
  92. package/dist/src/lib/workflow/state-cleanup.d.ts +13 -5
  93. package/dist/src/lib/workflow/state-cleanup.js +17 -5
  94. package/dist/src/lib/workflow/state-manager.d.ts +12 -1
  95. package/dist/src/lib/workflow/state-manager.js +37 -0
  96. package/dist/src/lib/workflow/state-schema.d.ts +62 -0
  97. package/dist/src/lib/workflow/state-schema.js +35 -1
  98. package/dist/src/lib/workflow/types.d.ts +74 -1
  99. package/dist/src/lib/workflow/worktree-manager.d.ts +12 -4
  100. package/dist/src/lib/workflow/worktree-manager.js +76 -17
  101. package/dist/src/mcp/tools/run.d.ts +44 -0
  102. package/dist/src/mcp/tools/run.js +104 -13
  103. package/dist/src/ui/tui/App.d.ts +14 -0
  104. package/dist/src/ui/tui/App.js +41 -0
  105. package/dist/src/ui/tui/ElapsedTimer.d.ts +10 -0
  106. package/dist/src/ui/tui/ElapsedTimer.js +31 -0
  107. package/dist/src/ui/tui/Header.d.ts +6 -0
  108. package/dist/src/ui/tui/Header.js +15 -0
  109. package/dist/src/ui/tui/IssueBox.d.ts +16 -0
  110. package/dist/src/ui/tui/IssueBox.js +68 -0
  111. package/dist/src/ui/tui/Spinner.d.ts +9 -0
  112. package/dist/src/ui/tui/Spinner.js +18 -0
  113. package/dist/src/ui/tui/index.d.ts +15 -0
  114. package/dist/src/ui/tui/index.js +29 -0
  115. package/dist/src/ui/tui/theme.d.ts +29 -0
  116. package/dist/src/ui/tui/theme.js +52 -0
  117. package/dist/src/ui/tui/truncate.d.ts +11 -0
  118. package/dist/src/ui/tui/truncate.js +31 -0
  119. package/package.json +10 -3
  120. package/templates/agents/sequant-explorer.md +1 -0
  121. package/templates/agents/sequant-qa-checker.md +2 -1
  122. package/templates/agents/sequant-testgen.md +1 -0
  123. package/templates/hooks/post-tool.sh +11 -0
  124. package/templates/hooks/pre-tool.sh +18 -9
  125. package/templates/hooks/relay-check.sh +107 -0
  126. package/templates/relay/frame.txt +11 -0
  127. package/templates/scripts/cleanup-worktree.sh +25 -3
  128. package/templates/scripts/new-feature.sh +6 -0
  129. package/templates/skills/_shared/references/behavior-rule-detection.md +205 -0
  130. package/templates/skills/_shared/references/subagent-types.md +21 -8
  131. package/templates/skills/assess/SKILL.md +261 -94
  132. package/templates/skills/assess/references/predicted-collision-detection.md +109 -0
  133. package/templates/skills/docs/SKILL.md +141 -22
  134. package/templates/skills/exec/SKILL.md +10 -49
  135. package/templates/skills/fullsolve/SKILL.md +80 -32
  136. package/templates/skills/loop/SKILL.md +28 -0
  137. package/templates/skills/merger/SKILL.md +621 -0
  138. package/templates/skills/qa/SKILL.md +746 -8
  139. package/templates/skills/qa/scripts/quality-checks.sh +47 -1
  140. package/templates/skills/setup/SKILL.md +6 -0
  141. package/templates/skills/spec/SKILL.md +217 -964
  142. package/templates/skills/spec/references/parallel-groups.md +7 -0
  143. package/templates/skills/spec/references/quality-checklist.md +75 -0
  144. package/templates/skills/spec/references/recommended-workflow.md +4 -2
  145. package/templates/skills/test/SKILL.md +0 -27
  146. package/templates/skills/testgen/SKILL.md +24 -44
@@ -9,17 +9,48 @@
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
+ /** Human-readable line for the run-orchestrator's `--signal-other` log (#637). */
19
+ function formatSignalLine(issue, pid, result) {
20
+ switch (result.reason) {
21
+ case "sent":
22
+ return ` Signaled PID ${pid} (SIGTERM) for #${issue}`;
23
+ case "cross-host":
24
+ return ` Could not signal PID ${pid} for #${issue} (cross-host holder)`;
25
+ case "self-or-parent":
26
+ return ` Refused to signal PID ${pid} for #${issue} (matches this process or its parent)`;
27
+ case "pid-dead":
28
+ return ` Could not signal PID ${pid} for #${issue} (already exited)`;
29
+ case "kill-failed":
30
+ return ` Could not signal PID ${pid} for #${issue} (kill syscall failed)`;
31
+ case "orchestrator":
32
+ return ` Skipped signal for #${issue} (orchestrator mode)`;
33
+ }
34
+ }
35
+ import { getIssueInfo, sortByDependencies, parseBatches, runIssueWithLogging, emitRunIdLine, } from "./batch-executor.js";
17
36
  import { reconcileStateAtStartup } from "./state-utils.js";
18
37
  import { getCommitHash } from "./git-diff-utils.js";
19
38
  import { MetricsWriter } from "./metrics-writer.js";
20
39
  import { determineOutcome } from "./metrics-schema.js";
21
40
  import { getTokenUsageForRun } from "./token-utils.js";
22
41
  import { resolveRunOptions, buildExecutionConfig } from "./config-resolver.js";
42
+ /**
43
+ * Build the stack-manifest line emitted into PR bodies under --stacked.
44
+ *
45
+ * Example for issues `[100, 101, 102]` at `currentIndex=1`:
46
+ * `Part of stack: #100 → #101 (this) → #102`
47
+ *
48
+ * @internal Exported for testing.
49
+ */
50
+ export function buildStackManifest(issueNumbers, currentIndex) {
51
+ const parts = issueNumbers.map((n, i) => i === currentIndex ? `#${n} (this)` : `#${n}`);
52
+ return `Part of stack: ${parts.join(" → ")}`;
53
+ }
23
54
  // ── Orchestrator ────────────────────────────────────────────────────────────
24
55
  /**
25
56
  * CLI-free workflow execution engine.
@@ -32,25 +63,143 @@ import { resolveRunOptions, buildExecutionConfig } from "./config-resolver.js";
32
63
  */
33
64
  export class RunOrchestrator {
34
65
  cfg;
66
+ issueStates = new Map();
67
+ phaseStartTimes = new Map();
68
+ done = false;
35
69
  constructor(config) {
36
70
  this.validate(config);
37
- this.cfg = config;
71
+ this.cfg = { ...config, onProgress: this.wrapProgress(config.onProgress) };
72
+ this.initIssueStates();
38
73
  }
39
74
  /**
40
- * Full lifecycle execution the primary entry point for programmatic use.
75
+ * Point-in-time view of the entire run.
41
76
  *
42
- * Handles: config resolution services setup state guard
43
- * issue discovery worktree creation execution metrics cleanup.
77
+ * Safe under concurrent reads: the returned object contains only freshly
78
+ * allocated arrays and plain records; no internal Map or mutable state
79
+ * reference is leaked. Callers may hold snapshots across awaits without
80
+ * observing torn writes.
44
81
  */
45
- static async run(init, issueArgs, batches) {
46
- const { options, settings, manifest, onProgress } = init;
47
- // ── Config resolution ──────────────────────────────────────────────
82
+ getSnapshot() {
83
+ const { config } = this.cfg;
84
+ const snapshotConfig = {
85
+ concurrency: config.concurrency,
86
+ baseBranch: this.cfg.baseBranch ?? "main",
87
+ qualityLoop: config.qualityLoop,
88
+ };
89
+ const issues = [];
90
+ for (const state of this.issueStates.values()) {
91
+ issues.push(cloneIssueState(state));
92
+ }
93
+ return {
94
+ config: snapshotConfig,
95
+ issues,
96
+ done: this.done,
97
+ capturedAt: new Date(),
98
+ };
99
+ }
100
+ /** Mark the run as completed so the dashboard can unmount. */
101
+ markDone() {
102
+ this.done = true;
103
+ }
104
+ initIssueStates() {
105
+ const { issueInfoMap, worktreeMap, config } = this.cfg;
106
+ for (const [num, info] of issueInfoMap.entries()) {
107
+ const branch = worktreeMap.get(num)?.branch ?? `#${num}`;
108
+ const phases = config.phases.map((name) => ({
109
+ name,
110
+ status: "pending",
111
+ }));
112
+ this.issueStates.set(num, {
113
+ number: num,
114
+ title: info.title,
115
+ branch,
116
+ status: "queued",
117
+ phases,
118
+ });
119
+ }
120
+ }
121
+ wrapProgress(external) {
122
+ return (issue, phase, event, extra) => {
123
+ this.applyProgressEvent(issue, phase, event, extra);
124
+ external?.(issue, phase, event, extra);
125
+ };
126
+ }
127
+ applyProgressEvent(issue, phase, event, extra) {
128
+ const state = this.issueStates.get(issue);
129
+ if (!state)
130
+ return;
131
+ if (event === "start") {
132
+ if (!state.startedAt)
133
+ state.startedAt = new Date();
134
+ state.status = "running";
135
+ const now = new Date();
136
+ this.phaseStartTimes.set(`${issue}:${phase}`, now.getTime());
137
+ state.currentPhase = {
138
+ name: phase,
139
+ startedAt: now,
140
+ lastActivityAt: now,
141
+ nowLine: formatCoarseNowLine(phase),
142
+ };
143
+ const p = findOrAppendPhase(state, phase);
144
+ p.status = "running";
145
+ p.startedAt = now;
146
+ return;
147
+ }
148
+ if (event === "activity") {
149
+ // Ignore activity for stale phases (race between completion and a
150
+ // final flushed output chunk).
151
+ if (!state.currentPhase || state.currentPhase.name !== phase)
152
+ return;
153
+ const line = extractActivityLine(extra?.text);
154
+ if (!line)
155
+ return;
156
+ state.currentPhase.nowLine = line;
157
+ state.currentPhase.lastActivityAt = new Date();
158
+ return;
159
+ }
160
+ // complete / failed
161
+ const key = `${issue}:${phase}`;
162
+ const startMs = this.phaseStartTimes.get(key);
163
+ this.phaseStartTimes.delete(key);
164
+ const elapsedMs = extra?.durationSeconds != null
165
+ ? extra.durationSeconds * 1000
166
+ : startMs != null
167
+ ? Date.now() - startMs
168
+ : undefined;
169
+ const p = findOrAppendPhase(state, phase);
170
+ p.status = event === "complete" ? "done" : "failed";
171
+ p.elapsedMs = elapsedMs;
172
+ state.currentPhase = undefined;
173
+ if (event === "failed") {
174
+ state.status = "failed";
175
+ state.completedAt = new Date();
176
+ return;
177
+ }
178
+ // Completed phase: if it's the last phase in the plan, mark issue passed.
179
+ const allDone = state.phases.every((ph) => ph.status === "done" || ph.status === "failed");
180
+ if (allDone) {
181
+ state.status = state.phases.some((ph) => ph.status === "failed")
182
+ ? "failed"
183
+ : "passed";
184
+ state.completedAt = new Date();
185
+ }
186
+ }
187
+ /**
188
+ * Pure config resolution — no side effects.
189
+ *
190
+ * Produces a `ResolvedRun` containing merged options, execution config,
191
+ * parsed/sorted issue numbers, base branch, and display-only flags. Safe
192
+ * to call for preview purposes (e.g. CLI config display before run).
193
+ *
194
+ * `run()` uses this internally to avoid duplicating resolution logic.
195
+ */
196
+ static resolveConfig(init, issueArgs, batches) {
197
+ const { options, settings, manifest } = init;
48
198
  const mergedOptions = resolveRunOptions(options, settings);
49
199
  const baseBranch = init.baseBranch ??
50
200
  options.base ??
51
201
  settings.run.defaultBase ??
52
202
  detectDefaultBranch(mergedOptions.verbose ?? false);
53
- // ── Parse issues ───────────────────────────────────────────────────
54
203
  let issueNumbers;
55
204
  let resolvedBatches = batches ?? null;
56
205
  if (mergedOptions.batch &&
@@ -67,6 +216,39 @@ export class RunOrchestrator {
67
216
  .map((i) => parseInt(i, 10))
68
217
  .filter((n) => !isNaN(n));
69
218
  }
219
+ if (issueNumbers.length > 1 && !resolvedBatches) {
220
+ issueNumbers = sortByDependencies(issueNumbers);
221
+ }
222
+ const config = buildExecutionConfig(mergedOptions, settings, issueNumbers.length);
223
+ const logEnabled = !mergedOptions.noLog &&
224
+ !config.dryRun &&
225
+ (mergedOptions.logJson ?? settings.run.logJson ?? false);
226
+ return {
227
+ mergedOptions,
228
+ config,
229
+ issueNumbers,
230
+ batches: resolvedBatches,
231
+ baseBranch,
232
+ stack: manifest.stack,
233
+ autoDetectPhases: mergedOptions.autoDetectPhases ?? false,
234
+ worktreeIsolationEnabled: mergedOptions.worktreeIsolation !== false && issueNumbers.length > 0,
235
+ logEnabled,
236
+ stateEnabled: !config.dryRun,
237
+ };
238
+ }
239
+ /**
240
+ * Full lifecycle execution — the primary entry point for programmatic use.
241
+ *
242
+ * Handles: config resolution → services setup → state guard →
243
+ * issue discovery → worktree creation → execution → metrics → cleanup.
244
+ */
245
+ static async run(init, issueArgs, batches) {
246
+ const { manifest, onProgress, settings } = init;
247
+ // ── Config resolution ──────────────────────────────────────────────
248
+ const resolved = RunOrchestrator.resolveConfig(init, issueArgs, batches);
249
+ const { mergedOptions, config, baseBranch } = resolved;
250
+ let { issueNumbers } = resolved;
251
+ const resolvedBatches = resolved.batches;
70
252
  if (issueNumbers.length === 0) {
71
253
  return {
72
254
  results: [],
@@ -74,17 +256,11 @@ export class RunOrchestrator {
74
256
  exitCode: 0,
75
257
  worktreeMap: new Map(),
76
258
  issueInfoMap: new Map(),
77
- config: buildExecutionConfig(mergedOptions, settings, 0),
259
+ config,
78
260
  mergedOptions,
79
261
  logWriter: null,
80
262
  };
81
263
  }
82
- // Sort by dependencies
83
- if (issueNumbers.length > 1 && !resolvedBatches) {
84
- issueNumbers = sortByDependencies(issueNumbers);
85
- }
86
- // ── Build execution config ─────────────────────────────────────────
87
- const config = buildExecutionConfig(mergedOptions, settings, issueNumbers.length);
88
264
  // ── Services setup ─────────────────────────────────────────────────
89
265
  let logWriter = null;
90
266
  const shouldLog = !mergedOptions.noLog &&
@@ -106,6 +282,9 @@ export class RunOrchestrator {
106
282
  startCommit: getCommitHash(process.cwd()),
107
283
  });
108
284
  await logWriter.initialize(runConfig);
285
+ const runId = logWriter.getRunId();
286
+ if (runId)
287
+ emitRunIdLine(runId);
109
288
  }
110
289
  catch (err) {
111
290
  const msg = err instanceof Error ? err.message : String(err);
@@ -175,6 +354,63 @@ export class RunOrchestrator {
175
354
  }
176
355
  }
177
356
  }
357
+ // ── Concurrency lock (#625) ────────────────────────────────────────
358
+ // Acquired here — after the state guard, before worktree creation — so
359
+ // that issues already filtered as ready_for_merge don't claim locks they
360
+ // wouldn't release. Locked issues are skipped from the run with a
361
+ // synthetic IssueResult so the batch continues.
362
+ const lockManager = new LockManager();
363
+ const lockedResults = [];
364
+ if (!lockManager.isNoop && !config.dryRun) {
365
+ const commandLabel = `npx sequant run ${issueNumbers.join(" ")}`;
366
+ const claimed = [];
367
+ for (const issueNumber of issueNumbers) {
368
+ const claim = mergedOptions.force
369
+ ? (() => {
370
+ const { previous } = lockManager.forceAcquire(issueNumber, commandLabel);
371
+ if (previous && mergedOptions.signalOther) {
372
+ const result = lockManager.signalOther(previous);
373
+ console.log(chalk.gray(formatSignalLine(issueNumber, previous.pid, result)));
374
+ }
375
+ return { acquired: true };
376
+ })()
377
+ : lockManager.acquire(issueNumber, commandLabel);
378
+ if (claim.acquired) {
379
+ claimed.push(issueNumber);
380
+ }
381
+ else {
382
+ lockedResults.push(buildLockedResult(issueNumber, claim.holder));
383
+ console.log(chalk.yellow(` ! ${formatLockedMessage(issueNumber, claim.holder)}`));
384
+ }
385
+ }
386
+ issueNumbers = claimed;
387
+ if (claimed.length > 0) {
388
+ shutdown.registerCleanup("Release issue locks", async () => {
389
+ lockManager.releaseAll();
390
+ });
391
+ // Sync cleanup for SIGKILL / uncaughtException paths. process.on('exit')
392
+ // only fires sync handlers; this is the best-effort safety net for
393
+ // events ShutdownManager doesn't catch.
394
+ const exitHandler = () => lockManager.releaseAll();
395
+ process.on("exit", exitHandler);
396
+ shutdown.registerCleanup("Detach exit-handler", async () => {
397
+ process.off("exit", exitHandler);
398
+ });
399
+ }
400
+ if (issueNumbers.length === 0) {
401
+ shutdown.dispose();
402
+ return {
403
+ results: lockedResults,
404
+ logPath: null,
405
+ exitCode: lockedResults.length > 0 ? 1 : 0,
406
+ worktreeMap: new Map(),
407
+ issueInfoMap: new Map(),
408
+ config,
409
+ mergedOptions,
410
+ logWriter: null,
411
+ };
412
+ }
413
+ }
178
414
  // ── Issue info + worktree setup ────────────────────────────────────
179
415
  const issueInfoMap = new Map();
180
416
  for (const issueNumber of issueNumbers) {
@@ -205,17 +441,18 @@ export class RunOrchestrator {
205
441
  }
206
442
  // ── Execute ────────────────────────────────────────────────────────
207
443
  let results = [];
444
+ const orchestrator = new RunOrchestrator({
445
+ config,
446
+ options: mergedOptions,
447
+ issueInfoMap,
448
+ worktreeMap,
449
+ services: { logWriter, stateManager, shutdownManager: shutdown },
450
+ packageManager: manifest.packageManager,
451
+ baseBranch,
452
+ onProgress,
453
+ });
454
+ init.onOrchestratorReady?.(orchestrator);
208
455
  try {
209
- const orchestrator = new RunOrchestrator({
210
- config,
211
- options: mergedOptions,
212
- issueInfoMap,
213
- worktreeMap,
214
- services: { logWriter, stateManager, shutdownManager: shutdown },
215
- packageManager: manifest.packageManager,
216
- baseBranch,
217
- onProgress,
218
- });
219
456
  if (resolvedBatches) {
220
457
  for (let batchIdx = 0; batchIdx < resolvedBatches.length; batchIdx++) {
221
458
  const batch = resolvedBatches[batchIdx];
@@ -248,10 +485,11 @@ export class RunOrchestrator {
248
485
  logNonFatalWarning(" ! Metrics recording failed, continuing...", metricsError, config.verbose);
249
486
  }
250
487
  }
488
+ const allResults = [...lockedResults, ...results];
251
489
  return {
252
- results,
490
+ results: allResults,
253
491
  logPath,
254
- exitCode: results.some((r) => !r.success) && !config.dryRun ? 1 : 0,
492
+ exitCode: allResults.some((r) => !r.success) && !config.dryRun ? 1 : 0,
255
493
  worktreeMap,
256
494
  issueInfoMap,
257
495
  config,
@@ -260,6 +498,7 @@ export class RunOrchestrator {
260
498
  };
261
499
  }
262
500
  finally {
501
+ orchestrator.markDone();
263
502
  shutdown.dispose();
264
503
  }
265
504
  }
@@ -317,11 +556,34 @@ export class RunOrchestrator {
317
556
  if (shutdown?.shuttingDown) {
318
557
  break;
319
558
  }
559
+ // #605: under --stacked, non-first PRs target the predecessor branch.
560
+ // The final PR still targets `main` (AC-3 open-question default) so the
561
+ // stack can land partially. Manifest renders for every PR in the stack.
562
+ let predecessorBranch;
563
+ let stackManifest;
564
+ if (options.chain && options.stacked) {
565
+ if (i > 0 && i < issueNumbers.length - 1) {
566
+ // Invariant: chain breaks on prior failure (see `break` below), so the
567
+ // predecessor's worktree is always in worktreeMap when we reach this
568
+ // branch. The optional-chained fallback to undefined is unreachable.
569
+ predecessorBranch = this.cfg.worktreeMap.get(issueNumbers[i - 1])?.branch;
570
+ }
571
+ else if (i > 0) {
572
+ // Last PR: still emit manifest, but base stays main (no predecessor).
573
+ // intentionally undefined predecessorBranch
574
+ }
575
+ stackManifest = buildStackManifest(issueNumbers, i);
576
+ }
320
577
  const result = await this.executeOneIssue({
321
578
  issueNumber,
322
579
  batchCtx,
323
580
  chain: options.chain
324
- ? { enabled: true, isLast: i === issueNumbers.length - 1 }
581
+ ? {
582
+ enabled: true,
583
+ isLast: i === issueNumbers.length - 1,
584
+ predecessorBranch,
585
+ stackManifest,
586
+ }
325
587
  : undefined,
326
588
  });
327
589
  results.push(result);
@@ -473,6 +735,97 @@ export class RunOrchestrator {
473
735
  }
474
736
  }
475
737
  }
738
+ function findOrAppendPhase(state, name) {
739
+ let p = state.phases.find((ph) => ph.name === name);
740
+ if (!p) {
741
+ p = { name, status: "pending" };
742
+ state.phases.push(p);
743
+ }
744
+ return p;
745
+ }
746
+ /**
747
+ * Activity is considered stale (and `nowLine` falls back to the coarse
748
+ * `running <phase>` form) once it goes this long without an update (#543).
749
+ */
750
+ const ACTIVITY_STALE_MS = 5_000;
751
+ function cloneIssueState(s) {
752
+ return {
753
+ number: s.number,
754
+ title: s.title,
755
+ branch: s.branch,
756
+ status: s.status,
757
+ startedAt: s.startedAt,
758
+ completedAt: s.completedAt,
759
+ phases: s.phases.map((p) => ({
760
+ name: p.name,
761
+ status: p.status,
762
+ startedAt: p.startedAt,
763
+ elapsedMs: p.elapsedMs,
764
+ })),
765
+ currentPhase: s.currentPhase
766
+ ? {
767
+ name: s.currentPhase.name,
768
+ startedAt: s.currentPhase.startedAt,
769
+ lastActivityAt: s.currentPhase.lastActivityAt,
770
+ nowLine: nowLineWithStaleFallback(s.currentPhase),
771
+ logPath: s.currentPhase.logPath,
772
+ }
773
+ : undefined,
774
+ };
775
+ }
776
+ function nowLineWithStaleFallback(current) {
777
+ const ageMs = Date.now() - current.lastActivityAt.getTime();
778
+ if (ageMs >= ACTIVITY_STALE_MS) {
779
+ return formatCoarseNowLine(current.name);
780
+ }
781
+ return current.nowLine;
782
+ }
783
+ /**
784
+ * Reduce a chunk of streamed agent output to a single line suitable for the
785
+ * activity row. Strips ANSI sequences and trailing whitespace; returns the
786
+ * last non-empty line, truncated to keep the cell render cheap. Returns
787
+ * `undefined` when the chunk contains no usable content.
788
+ */
789
+ function extractActivityLine(raw) {
790
+ if (!raw)
791
+ return undefined;
792
+ // Strip ANSI CSI escapes — covers SGR (colour/bold, `…m`), cursor-movement
793
+ // and line-clear codes (`\x1b[2K`, `\x1b[G`), and DEC private-mode toggles
794
+ // (`\x1b[?25l`), any of which can leak through chalk/ink in agent output.
795
+ // eslint-disable-next-line no-control-regex
796
+ const cleaned = raw.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "");
797
+ const lines = cleaned.split(/\r?\n/);
798
+ let last = "";
799
+ for (let i = lines.length - 1; i >= 0; i--) {
800
+ const trimmed = lines[i].trim();
801
+ if (trimmed.length > 0) {
802
+ last = trimmed;
803
+ break;
804
+ }
805
+ }
806
+ if (!last)
807
+ return undefined;
808
+ // Bound at 200 chars; the TUI truncates further per row width.
809
+ return last.length > 200 ? last.slice(0, 200) : last;
810
+ }
811
+ /**
812
+ * Build the synthetic `IssueResult` returned for an issue that was skipped
813
+ * because another sequant session holds its lock (#625).
814
+ */
815
+ export function buildLockedResult(issueNumber, holder) {
816
+ return {
817
+ issueNumber,
818
+ success: false,
819
+ phaseResults: [],
820
+ abortReason: `locked by PID ${holder.pid}`,
821
+ locked: {
822
+ pid: holder.pid,
823
+ hostname: holder.hostname,
824
+ startedAt: holder.startedAt,
825
+ command: holder.command,
826
+ },
827
+ };
828
+ }
476
829
  /** Log a non-fatal warning: one-line summary always, detail in verbose. */
477
830
  export function logNonFatalWarning(message, error, verbose) {
478
831
  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;
@@ -0,0 +1,14 @@
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
+ /**
9
+ * Format a coarse "now" line for a phase transition.
10
+ * Used as the M1 default when no finer activity signal exists.
11
+ */
12
+ export function formatCoarseNowLine(phase) {
13
+ return `running ${phase}`;
14
+ }
@@ -58,9 +58,9 @@ export interface ReconcileOptions {
58
58
  export interface ReconcileResult {
59
59
  /** Whether reconciliation was successful */
60
60
  success: boolean;
61
- /** Issues that were advanced from ready_for_merge to merged */
61
+ /** Issues advanced to `merged` (from `ready_for_merge`, `in_progress`, or `waiting_for_qa_gate`) */
62
62
  advanced: number[];
63
- /** Issues checked but still ready_for_merge */
63
+ /** Issues checked but not yet merged (status unchanged) */
64
64
  stillPending: number[];
65
65
  /** Error message if failed */
66
66
  error?: string;
@@ -68,10 +68,18 @@ export interface ReconcileResult {
68
68
  /**
69
69
  * Lightweight state reconciliation at run start
70
70
  *
71
- * Checks issues in `ready_for_merge` state and advances them to `merged`
72
- * if their PRs are merged or their branches are in main.
71
+ * Checks issues in `ready_for_merge`, `in_progress`, or `waiting_for_qa_gate`
72
+ * state and advances them to `merged` if their PRs are merged or their branches
73
+ * are in main.
73
74
  *
74
- * This prevents re-running already completed issues.
75
+ * Including `in_progress` covers the case where a PR was merged outside
76
+ * this sequant session (separate process, `gh pr merge`, web UI) — without
77
+ * it, the run command would re-execute already-merged issues. See #592.
78
+ *
79
+ * Including `waiting_for_qa_gate` covers the symmetric case where a PR
80
+ * awaiting human QA-gate approval is merged externally before the next
81
+ * sequant session — without it, `sequant run <N>` re-executes the QA phase
82
+ * against already-merged work. See #606.
75
83
  *
76
84
  * @param options - Reconciliation options
77
85
  * @returns Result with lists of advanced and still-pending issues