sequant 2.2.0 → 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 (137) 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 +94 -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/locks.d.ts +67 -0
  8. package/dist/src/commands/locks.js +290 -0
  9. package/dist/src/commands/merge.js +11 -0
  10. package/dist/src/commands/prompt.d.ts +39 -0
  11. package/dist/src/commands/prompt.js +179 -0
  12. package/dist/src/commands/run-display.d.ts +11 -2
  13. package/dist/src/commands/run-display.js +62 -28
  14. package/dist/src/commands/run-progress.d.ts +32 -0
  15. package/dist/src/commands/run-progress.js +76 -0
  16. package/dist/src/commands/run.js +80 -18
  17. package/dist/src/commands/stats.d.ts +2 -0
  18. package/dist/src/commands/stats.js +94 -8
  19. package/dist/src/commands/status.js +12 -0
  20. package/dist/src/commands/watch.d.ts +16 -0
  21. package/dist/src/commands/watch.js +147 -0
  22. package/dist/src/lib/ac-linter.d.ts +1 -1
  23. package/dist/src/lib/ac-linter.js +81 -0
  24. package/dist/src/lib/assess-collision-detect.d.ts +91 -0
  25. package/dist/src/lib/assess-collision-detect.js +217 -0
  26. package/dist/src/lib/assess-comment-parser.d.ts +59 -1
  27. package/dist/src/lib/assess-comment-parser.js +124 -2
  28. package/dist/src/lib/cli-ui/format.d.ts +19 -0
  29. package/dist/src/lib/cli-ui/format.js +34 -0
  30. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +181 -0
  31. package/dist/src/lib/cli-ui/run-renderer-types.js +7 -0
  32. package/dist/src/lib/cli-ui/run-renderer.d.ts +239 -0
  33. package/dist/src/lib/cli-ui/run-renderer.js +1173 -0
  34. package/dist/src/lib/heuristics/behavior-rule-detector.d.ts +94 -0
  35. package/dist/src/lib/heuristics/behavior-rule-detector.js +467 -0
  36. package/dist/src/lib/locks/index.d.ts +7 -0
  37. package/dist/src/lib/locks/index.js +5 -0
  38. package/dist/src/lib/locks/lock-manager.d.ts +168 -0
  39. package/dist/src/lib/locks/lock-manager.js +433 -0
  40. package/dist/src/lib/locks/types.d.ts +59 -0
  41. package/dist/src/lib/locks/types.js +31 -0
  42. package/dist/src/lib/qa/markdown-only-ci.d.ts +46 -0
  43. package/dist/src/lib/qa/markdown-only-ci.js +74 -0
  44. package/dist/src/lib/relay/activation.d.ts +60 -0
  45. package/dist/src/lib/relay/activation.js +122 -0
  46. package/dist/src/lib/relay/archive.d.ts +34 -0
  47. package/dist/src/lib/relay/archive.js +106 -0
  48. package/dist/src/lib/relay/frame.d.ts +20 -0
  49. package/dist/src/lib/relay/frame.js +76 -0
  50. package/dist/src/lib/relay/index.d.ts +13 -0
  51. package/dist/src/lib/relay/index.js +13 -0
  52. package/dist/src/lib/relay/paths.d.ts +43 -0
  53. package/dist/src/lib/relay/paths.js +59 -0
  54. package/dist/src/lib/relay/pid.d.ts +34 -0
  55. package/dist/src/lib/relay/pid.js +72 -0
  56. package/dist/src/lib/relay/reader.d.ts +35 -0
  57. package/dist/src/lib/relay/reader.js +115 -0
  58. package/dist/src/lib/relay/types.d.ts +68 -0
  59. package/dist/src/lib/relay/types.js +76 -0
  60. package/dist/src/lib/relay/writer.d.ts +48 -0
  61. package/dist/src/lib/relay/writer.js +113 -0
  62. package/dist/src/lib/settings.d.ts +31 -1
  63. package/dist/src/lib/settings.js +18 -3
  64. package/dist/src/lib/version-check.d.ts +60 -5
  65. package/dist/src/lib/version-check.js +97 -9
  66. package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
  67. package/dist/src/lib/workflow/batch-executor.js +248 -175
  68. package/dist/src/lib/workflow/config-resolver.js +4 -0
  69. package/dist/src/lib/workflow/heartbeat.d.ts +71 -0
  70. package/dist/src/lib/workflow/heartbeat.js +194 -0
  71. package/dist/src/lib/workflow/phase-executor.d.ts +62 -8
  72. package/dist/src/lib/workflow/phase-executor.js +157 -16
  73. package/dist/src/lib/workflow/phase-mapper.d.ts +3 -2
  74. package/dist/src/lib/workflow/phase-mapper.js +17 -20
  75. package/dist/src/lib/workflow/platforms/github.d.ts +1 -1
  76. package/dist/src/lib/workflow/platforms/github.js +20 -3
  77. package/dist/src/lib/workflow/pr-status.d.ts +18 -2
  78. package/dist/src/lib/workflow/pr-status.js +41 -9
  79. package/dist/src/lib/workflow/qa-stagnation.d.ts +117 -0
  80. package/dist/src/lib/workflow/qa-stagnation.js +179 -0
  81. package/dist/src/lib/workflow/run-orchestrator.d.ts +39 -0
  82. package/dist/src/lib/workflow/run-orchestrator.js +340 -15
  83. package/dist/src/lib/workflow/run-reflect.js +1 -1
  84. package/dist/src/lib/workflow/run-state.d.ts +71 -0
  85. package/dist/src/lib/workflow/run-state.js +14 -0
  86. package/dist/src/lib/workflow/state-cleanup.d.ts +13 -5
  87. package/dist/src/lib/workflow/state-cleanup.js +17 -5
  88. package/dist/src/lib/workflow/state-manager.d.ts +12 -1
  89. package/dist/src/lib/workflow/state-manager.js +37 -0
  90. package/dist/src/lib/workflow/state-schema.d.ts +62 -0
  91. package/dist/src/lib/workflow/state-schema.js +35 -1
  92. package/dist/src/lib/workflow/types.d.ts +74 -1
  93. package/dist/src/lib/workflow/worktree-manager.d.ts +8 -1
  94. package/dist/src/lib/workflow/worktree-manager.js +15 -6
  95. package/dist/src/mcp/tools/run.d.ts +44 -0
  96. package/dist/src/mcp/tools/run.js +104 -13
  97. package/dist/src/ui/tui/App.d.ts +14 -0
  98. package/dist/src/ui/tui/App.js +41 -0
  99. package/dist/src/ui/tui/ElapsedTimer.d.ts +10 -0
  100. package/dist/src/ui/tui/ElapsedTimer.js +31 -0
  101. package/dist/src/ui/tui/Header.d.ts +6 -0
  102. package/dist/src/ui/tui/Header.js +15 -0
  103. package/dist/src/ui/tui/IssueBox.d.ts +16 -0
  104. package/dist/src/ui/tui/IssueBox.js +68 -0
  105. package/dist/src/ui/tui/Spinner.d.ts +9 -0
  106. package/dist/src/ui/tui/Spinner.js +18 -0
  107. package/dist/src/ui/tui/index.d.ts +15 -0
  108. package/dist/src/ui/tui/index.js +29 -0
  109. package/dist/src/ui/tui/theme.d.ts +29 -0
  110. package/dist/src/ui/tui/theme.js +52 -0
  111. package/dist/src/ui/tui/truncate.d.ts +11 -0
  112. package/dist/src/ui/tui/truncate.js +31 -0
  113. package/package.json +10 -3
  114. package/templates/agents/sequant-explorer.md +1 -0
  115. package/templates/agents/sequant-qa-checker.md +2 -1
  116. package/templates/agents/sequant-testgen.md +1 -0
  117. package/templates/hooks/post-tool.sh +11 -0
  118. package/templates/hooks/pre-tool.sh +18 -9
  119. package/templates/hooks/relay-check.sh +107 -0
  120. package/templates/relay/frame.txt +11 -0
  121. package/templates/scripts/cleanup-worktree.sh +25 -3
  122. package/templates/scripts/new-feature.sh +6 -0
  123. package/templates/skills/_shared/references/behavior-rule-detection.md +205 -0
  124. package/templates/skills/_shared/references/subagent-types.md +21 -8
  125. package/templates/skills/assess/SKILL.md +103 -49
  126. package/templates/skills/assess/references/predicted-collision-detection.md +109 -0
  127. package/templates/skills/docs/SKILL.md +141 -22
  128. package/templates/skills/exec/SKILL.md +10 -8
  129. package/templates/skills/fullsolve/SKILL.md +79 -5
  130. package/templates/skills/loop/SKILL.md +28 -0
  131. package/templates/skills/merger/SKILL.md +621 -0
  132. package/templates/skills/qa/SKILL.md +727 -8
  133. package/templates/skills/setup/SKILL.md +6 -0
  134. package/templates/skills/spec/SKILL.md +52 -0
  135. package/templates/skills/spec/references/parallel-groups.md +7 -0
  136. package/templates/skills/spec/references/recommended-workflow.md +4 -2
  137. package/templates/skills/testgen/SKILL.md +24 -17
@@ -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,9 +63,126 @@ 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();
73
+ }
74
+ /**
75
+ * Point-in-time view of the entire run.
76
+ *
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.
81
+ */
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
+ }
38
186
  }
39
187
  /**
40
188
  * Pure config resolution — no side effects.
@@ -134,6 +282,9 @@ export class RunOrchestrator {
134
282
  startCommit: getCommitHash(process.cwd()),
135
283
  });
136
284
  await logWriter.initialize(runConfig);
285
+ const runId = logWriter.getRunId();
286
+ if (runId)
287
+ emitRunIdLine(runId);
137
288
  }
138
289
  catch (err) {
139
290
  const msg = err instanceof Error ? err.message : String(err);
@@ -203,6 +354,63 @@ export class RunOrchestrator {
203
354
  }
204
355
  }
205
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
+ }
206
414
  // ── Issue info + worktree setup ────────────────────────────────────
207
415
  const issueInfoMap = new Map();
208
416
  for (const issueNumber of issueNumbers) {
@@ -233,17 +441,18 @@ export class RunOrchestrator {
233
441
  }
234
442
  // ── Execute ────────────────────────────────────────────────────────
235
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);
236
455
  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
456
  if (resolvedBatches) {
248
457
  for (let batchIdx = 0; batchIdx < resolvedBatches.length; batchIdx++) {
249
458
  const batch = resolvedBatches[batchIdx];
@@ -276,10 +485,11 @@ export class RunOrchestrator {
276
485
  logNonFatalWarning(" ! Metrics recording failed, continuing...", metricsError, config.verbose);
277
486
  }
278
487
  }
488
+ const allResults = [...lockedResults, ...results];
279
489
  return {
280
- results,
490
+ results: allResults,
281
491
  logPath,
282
- exitCode: results.some((r) => !r.success) && !config.dryRun ? 1 : 0,
492
+ exitCode: allResults.some((r) => !r.success) && !config.dryRun ? 1 : 0,
283
493
  worktreeMap,
284
494
  issueInfoMap,
285
495
  config,
@@ -288,6 +498,7 @@ export class RunOrchestrator {
288
498
  };
289
499
  }
290
500
  finally {
501
+ orchestrator.markDone();
291
502
  shutdown.dispose();
292
503
  }
293
504
  }
@@ -345,11 +556,34 @@ export class RunOrchestrator {
345
556
  if (shutdown?.shuttingDown) {
346
557
  break;
347
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
+ }
348
577
  const result = await this.executeOneIssue({
349
578
  issueNumber,
350
579
  batchCtx,
351
580
  chain: options.chain
352
- ? { enabled: true, isLast: i === issueNumbers.length - 1 }
581
+ ? {
582
+ enabled: true,
583
+ isLast: i === issueNumbers.length - 1,
584
+ predecessorBranch,
585
+ stackManifest,
586
+ }
353
587
  : undefined,
354
588
  });
355
589
  results.push(result);
@@ -501,6 +735,97 @@ export class RunOrchestrator {
501
735
  }
502
736
  }
503
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
+ }
504
829
  /** Log a non-fatal warning: one-line summary always, detail in verbose. */
505
830
  export function logNonFatalWarning(message, error, verbose) {
506
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
@@ -188,10 +188,18 @@ export async function cleanupStaleEntries(options = {}) {
188
188
  /**
189
189
  * Lightweight state reconciliation at run start
190
190
  *
191
- * Checks issues in `ready_for_merge` state and advances them to `merged`
192
- * if their PRs are merged or their branches are in main.
191
+ * Checks issues in `ready_for_merge`, `in_progress`, or `waiting_for_qa_gate`
192
+ * state and advances them to `merged` if their PRs are merged or their branches
193
+ * are in main.
193
194
  *
194
- * This prevents re-running already completed issues.
195
+ * Including `in_progress` covers the case where a PR was merged outside
196
+ * this sequant session (separate process, `gh pr merge`, web UI) — without
197
+ * it, the run command would re-execute already-merged issues. See #592.
198
+ *
199
+ * Including `waiting_for_qa_gate` covers the symmetric case where a PR
200
+ * awaiting human QA-gate approval is merged externally before the next
201
+ * sequant session — without it, `sequant run <N>` re-executes the QA phase
202
+ * against already-merged work. See #606.
195
203
  *
196
204
  * @param options - Reconciliation options
197
205
  * @returns Result with lists of advanced and still-pending issues
@@ -213,9 +221,13 @@ export async function reconcileStateAtStartup(options = {}) {
213
221
  const state = await manager.getState();
214
222
  const advanced = [];
215
223
  const stillPending = [];
216
- // Find issues in ready_for_merge state
224
+ // Find issues in ready_for_merge, in_progress, or waiting_for_qa_gate state.
225
+ // in_progress covers PRs merged outside this session (#592).
226
+ // waiting_for_qa_gate covers PRs merged before the next QA-gate run (#606).
217
227
  for (const [issueNumStr, issueState] of Object.entries(state.issues)) {
218
- if (issueState.status !== "ready_for_merge") {
228
+ if (issueState.status !== "ready_for_merge" &&
229
+ issueState.status !== "in_progress" &&
230
+ issueState.status !== "waiting_for_qa_gate") {
219
231
  continue;
220
232
  }
221
233
  const issueNum = parseInt(issueNumStr, 10);
@@ -20,7 +20,7 @@
20
20
  * const state = await manager.getIssueState(42);
21
21
  * ```
22
22
  */
23
- import { type WorkflowState, type IssueState, type Phase, type PhaseStatus, type IssueStatus, type PRInfo, type AcceptanceCriteria, type ACStatus } from "./state-schema.js";
23
+ import { type WorkflowState, type IssueState, type Phase, type PhaseStatus, type IssueStatus, type PRInfo, type AcceptanceCriteria, type ACStatus, type RelayState } from "./state-schema.js";
24
24
  import type { ScopeAssessment } from "../scope/types.js";
25
25
  export interface StateManagerOptions {
26
26
  /** Path to state file (default: .sequant/state.json) */
@@ -133,6 +133,17 @@ export declare class StateManager {
133
133
  * Update session ID for an issue (for resume)
134
134
  */
135
135
  updateSessionId(issueNumber: number, sessionId: string): Promise<void>;
136
+ /**
137
+ * Set or clear the relay state for an issue (#383).
138
+ *
139
+ * Pass `null` to remove the relay field entirely (deactivation). Pass an
140
+ * object to overwrite the current relay state (activation or refresh).
141
+ */
142
+ setRelayState(issueNumber: number, relay: RelayState | null): Promise<void>;
143
+ /**
144
+ * Increment the relay message counter. No-op when relay isn't active.
145
+ */
146
+ incrementRelayMessageCount(issueNumber: number, delta?: number): Promise<void>;
136
147
  /**
137
148
  * Update loop iteration for an issue
138
149
  */
@@ -392,6 +392,43 @@ export class StateManager {
392
392
  await this.saveState(state);
393
393
  });
394
394
  }
395
+ /**
396
+ * Set or clear the relay state for an issue (#383).
397
+ *
398
+ * Pass `null` to remove the relay field entirely (deactivation). Pass an
399
+ * object to overwrite the current relay state (activation or refresh).
400
+ */
401
+ async setRelayState(issueNumber, relay) {
402
+ await this.withLock(async () => {
403
+ const state = await this.getState();
404
+ const issueState = state.issues[String(issueNumber)];
405
+ if (!issueState) {
406
+ throw new Error(`Issue #${issueNumber} not found in state`);
407
+ }
408
+ if (relay === null) {
409
+ delete issueState.relay;
410
+ }
411
+ else {
412
+ issueState.relay = relay;
413
+ }
414
+ issueState.lastActivity = new Date().toISOString();
415
+ await this.saveState(state);
416
+ });
417
+ }
418
+ /**
419
+ * Increment the relay message counter. No-op when relay isn't active.
420
+ */
421
+ async incrementRelayMessageCount(issueNumber, delta = 1) {
422
+ await this.withLock(async () => {
423
+ const state = await this.getState();
424
+ const issueState = state.issues[String(issueNumber)];
425
+ if (!issueState || !issueState.relay)
426
+ return;
427
+ issueState.relay.messageCount += delta;
428
+ issueState.lastActivity = new Date().toISOString();
429
+ await this.saveState(state);
430
+ });
431
+ }
395
432
  /**
396
433
  * Update loop iteration for an issue
397
434
  */