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
@@ -0,0 +1,1173 @@
1
+ /**
2
+ * RunRenderer — single coordinator for all `sequant run` stdout output.
3
+ *
4
+ * Replaces the dual-output regression where `PhaseSpinner` (legacy, #244) and
5
+ * the parallel-mode `▸/✔` lines (#458) both wrote to stdout for single-issue
6
+ * runs and produced overwritten / missing-duration lines.
7
+ *
8
+ * Three modes implement the same `RunRenderer` interface:
9
+ * - TTYRenderer: live grid (top, redrawn ~1Hz) + events log (below)
10
+ * - NonTTYRenderer: append-only `[HH:MM:SS]` events + 60s heartbeat
11
+ * - OrchestratorRenderer: no-op when SEQUANT_ORCHESTRATOR is set so MCP's
12
+ * `emitProgressLine` JSON is the only stdout
13
+ *
14
+ * See issue #618.
15
+ */
16
+ import chalk from "chalk";
17
+ import logUpdate from "log-update";
18
+ import stringWidth from "string-width";
19
+ import { formatElapsedTime, formatTimestamp } from "./format.js";
20
+ const DEFAULT_LIVE_TICK_MS = 1000;
21
+ const DEFAULT_NON_TTY_HEARTBEAT_MS = 60_000;
22
+ const NARROW_TERMINAL_THRESHOLD = 80;
23
+ const DEFAULT_MULTI_ISSUE_ROW_CAP = 10;
24
+ // Generous default: in production, TTY mode always has `process.stdout.rows`
25
+ // set, so this only matters in tests and detached stdout. A high default avoids
26
+ // over-constraining multi-issue test scenarios while keeping the height cap
27
+ // active enough to catch pathological frames.
28
+ const DEFAULT_TERMINAL_ROWS = 100;
29
+ const DEFAULT_MAX_LOOP_ITERATIONS = 3;
30
+ const SUMMARY_COLUMN_CAP = 110;
31
+ const FAILURE_SIGNATURE_LENGTH = 80;
32
+ const FAIL_REASON_TRUNCATE_LENGTH = 40;
33
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
34
+ // Colour helpers — bypassed cleanly when `noColor` is true.
35
+ function colorize(noColor) {
36
+ if (noColor) {
37
+ const id = (s) => s;
38
+ return {
39
+ dim: id,
40
+ green: id,
41
+ red: id,
42
+ yellow: id,
43
+ cyan: id,
44
+ gray: id,
45
+ bold: id,
46
+ };
47
+ }
48
+ return {
49
+ dim: chalk.dim,
50
+ green: chalk.green,
51
+ red: chalk.red,
52
+ yellow: chalk.yellow,
53
+ cyan: chalk.cyan,
54
+ gray: chalk.gray,
55
+ bold: chalk.bold,
56
+ };
57
+ }
58
+ // ============================================================================
59
+ // Shared helpers (#624)
60
+ // ============================================================================
61
+ /**
62
+ * #624 Item 4: normalized failure signature for dedup decisions.
63
+ *
64
+ * Strips ANSI escape sequences, lowercases, trims whitespace, and truncates to
65
+ * the first 80 visible chars. The plan deliberately chose a length-bounded
66
+ * prefix over a crypto hash so debugging can match signatures by eye.
67
+ */
68
+ export function failureSignature(error) {
69
+ if (!error)
70
+ return "";
71
+ // eslint-disable-next-line no-control-regex
72
+ const stripped = error.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
73
+ return stripped.trim().toLowerCase().slice(0, FAILURE_SIGNATURE_LENGTH);
74
+ }
75
+ /**
76
+ * #624 Item 3 / Derived AC-D2: shared suffix builder used by all three retry
77
+ * sites (NonTTY events log, TTY events log, TTY status header). Centralizes
78
+ * the `(attempt N/M)` / `loop N/M` literals so they cannot drift between paths.
79
+ *
80
+ * `kind` selects the surface:
81
+ * - "events" → events-log line: leading space + parentheses
82
+ * - "header" → status cell: leading space, no parentheses
83
+ *
84
+ * Returns the empty string when the attempt counter does not apply
85
+ * (no iteration, iteration === 1, or non-positive).
86
+ */
87
+ export function formatRetrySuffix(iteration, maxIterations, kind) {
88
+ if (!iteration || iteration <= 1)
89
+ return "";
90
+ const counter = `${iteration}/${maxIterations}`;
91
+ if (kind === "events")
92
+ return ` (attempt ${counter})`;
93
+ return ` ${counter}`;
94
+ }
95
+ // ============================================================================
96
+ // Shared state machine
97
+ // ============================================================================
98
+ class BaseRenderer {
99
+ issues = new Map();
100
+ stdoutWrite;
101
+ stderrWrite;
102
+ now;
103
+ wallClock;
104
+ noColor;
105
+ runStartedAt;
106
+ paused = false;
107
+ disposed = false;
108
+ constructor(options) {
109
+ this.stdoutWrite =
110
+ options.stdoutWrite ?? ((s) => void process.stdout.write(s));
111
+ this.stderrWrite =
112
+ options.stderrWrite ?? ((s) => void process.stderr.write(s));
113
+ this.now = options.now ?? Date.now;
114
+ this.wallClock = options.wallClock ?? (() => new Date());
115
+ this.noColor = Boolean(options.noColor) || Boolean(process.env.NO_COLOR);
116
+ this.runStartedAt = this.now();
117
+ }
118
+ registerIssue(reg) {
119
+ if (this.issues.has(reg.issueNumber))
120
+ return;
121
+ this.issues.set(reg.issueNumber, {
122
+ issueNumber: reg.issueNumber,
123
+ title: reg.title,
124
+ worktreePath: reg.worktreePath,
125
+ branch: reg.branch,
126
+ autoDetect: reg.autoDetect,
127
+ status: "queued",
128
+ phases: [],
129
+ });
130
+ this.afterStateChange();
131
+ }
132
+ onEvent(event) {
133
+ if (this.disposed)
134
+ return;
135
+ let state = this.issues.get(event.issue);
136
+ if (!state) {
137
+ state = {
138
+ issueNumber: event.issue,
139
+ status: "queued",
140
+ phases: [],
141
+ };
142
+ this.issues.set(event.issue, state);
143
+ }
144
+ this.applyEvent(state, event);
145
+ this.afterEvent(event, state);
146
+ }
147
+ setPullRequest(issue, prNumber, prUrl) {
148
+ const state = this.issues.get(issue);
149
+ if (!state)
150
+ return;
151
+ state.prNumber = prNumber;
152
+ state.prUrl = prUrl;
153
+ this.afterStateChange();
154
+ }
155
+ pause() {
156
+ this.paused = true;
157
+ this.onPause();
158
+ }
159
+ resume() {
160
+ if (!this.paused)
161
+ return;
162
+ this.paused = false;
163
+ this.onResume();
164
+ }
165
+ dispose() {
166
+ if (this.disposed)
167
+ return;
168
+ this.disposed = true;
169
+ this.onDispose();
170
+ }
171
+ // ------------ State machine ------------
172
+ applyEvent(state, event) {
173
+ const phaseName = event.phase;
174
+ let phase = state.phases.find((p) => p.name === phaseName);
175
+ if (!phase) {
176
+ phase = { name: phaseName, status: "pending" };
177
+ state.phases.push(phase);
178
+ }
179
+ if (event.event === "start") {
180
+ if (state.startedAt === undefined)
181
+ state.startedAt = this.now();
182
+ state.status = "running";
183
+ state.currentPhase = phaseName;
184
+ phase.status = "running";
185
+ phase.startedAt = this.now();
186
+ if (event.iteration !== undefined) {
187
+ phase.loopIteration = event.iteration;
188
+ }
189
+ // Clear any sub-status from a prior phase.
190
+ state.subStatus = undefined;
191
+ return;
192
+ }
193
+ if (event.event === "complete") {
194
+ phase.status = "done";
195
+ if (event.durationSeconds !== undefined) {
196
+ phase.durationMs = event.durationSeconds * 1000;
197
+ }
198
+ else if (phase.startedAt !== undefined) {
199
+ phase.durationMs = this.now() - phase.startedAt;
200
+ }
201
+ if (state.currentPhase === phaseName)
202
+ state.currentPhase = undefined;
203
+ return;
204
+ }
205
+ // failed
206
+ phase.status = "failed";
207
+ if (event.durationSeconds !== undefined) {
208
+ phase.durationMs = event.durationSeconds * 1000;
209
+ }
210
+ else if (phase.startedAt !== undefined) {
211
+ phase.durationMs = this.now() - phase.startedAt;
212
+ }
213
+ state.status = "failed";
214
+ state.completedAt = this.now();
215
+ state.currentPhase = undefined;
216
+ if (event.error !== undefined)
217
+ state.failureReason = event.error;
218
+ // #624 Item 4: update failure dedup metadata on the PHASE (not the issue).
219
+ // Per-phase tracking ensures "same failure as attempt N" only references
220
+ // prior attempts of THIS phase — exec failing with "boom" followed by qa
221
+ // failing with "boom" no longer abbreviates qa as "same failure as attempt 1"
222
+ // when attempt 1 was an exec failure.
223
+ const sig = failureSignature(event.error);
224
+ const currentAttempt = phase.loopIteration ?? 1;
225
+ if (phase.lastFailureSignature !== sig) {
226
+ phase.lastFailureSignature = sig;
227
+ phase.firstAttemptForSignature = currentAttempt;
228
+ }
229
+ }
230
+ /** Mark an issue done after PR is recorded — derived from phase completion. */
231
+ maybeMarkIssueDone(state) {
232
+ if (state.status === "failed")
233
+ return;
234
+ const allTerminal = state.phases.every((p) => p.status === "done" || p.status === "failed");
235
+ if (allTerminal && state.phases.length > 0) {
236
+ state.status = state.phases.some((p) => p.status === "failed")
237
+ ? "failed"
238
+ : "done";
239
+ state.completedAt = this.now();
240
+ }
241
+ }
242
+ // ------------ Hooks for subclasses ------------
243
+ afterEvent(_event, state) {
244
+ if (state.status !== "failed")
245
+ this.maybeMarkIssueDone(state);
246
+ this.afterStateChange();
247
+ }
248
+ afterStateChange() {
249
+ /* default: no-op */
250
+ }
251
+ onPause() {
252
+ /* default: no-op */
253
+ }
254
+ onResume() {
255
+ /* default: no-op */
256
+ }
257
+ onDispose() {
258
+ /* default: no-op */
259
+ }
260
+ }
261
+ // ============================================================================
262
+ // Orchestrator (MCP) renderer — fully suppressed
263
+ // ============================================================================
264
+ /**
265
+ * No-op renderer used when `SEQUANT_ORCHESTRATOR` is set.
266
+ *
267
+ * The orchestrator (e.g. MCP server) consumes `emitProgressLine` JSON from
268
+ * batch-executor directly. Rendering anything else from the CLI would be
269
+ * double-emission. We still track state so `renderSummary` could be useful
270
+ * if explicitly invoked, but neither stdout nor stderr is touched.
271
+ */
272
+ export class OrchestratorRenderer extends BaseRenderer {
273
+ renderSummary() {
274
+ /* AC-18: orchestrator path emits no human-readable summary. */
275
+ }
276
+ }
277
+ // ============================================================================
278
+ // Non-TTY renderer — append-only with timestamps + 60s heartbeat
279
+ // ============================================================================
280
+ export class NonTTYRenderer extends BaseRenderer {
281
+ heartbeatTimer = null;
282
+ heartbeatMs;
283
+ columnsOverride;
284
+ maxLoopIterations;
285
+ lastEventAt;
286
+ constructor(options) {
287
+ super(options);
288
+ this.heartbeatMs =
289
+ options.nonTtyHeartbeatMs ?? DEFAULT_NON_TTY_HEARTBEAT_MS;
290
+ this.columnsOverride = options.columns;
291
+ this.maxLoopIterations =
292
+ options.maxLoopIterations ?? DEFAULT_MAX_LOOP_ITERATIONS;
293
+ this.lastEventAt = this.now();
294
+ this.startHeartbeat();
295
+ }
296
+ getColumns() {
297
+ return (this.columnsOverride ??
298
+ (process.stdout.columns && process.stdout.columns > 0
299
+ ? process.stdout.columns
300
+ : 100));
301
+ }
302
+ startHeartbeat() {
303
+ if (this.heartbeatMs <= 0)
304
+ return;
305
+ this.heartbeatTimer = setInterval(() => this.tickHeartbeat(), this.heartbeatMs);
306
+ if (typeof this.heartbeatTimer.unref === "function") {
307
+ this.heartbeatTimer.unref();
308
+ }
309
+ }
310
+ /** Test hook: drive a heartbeat without waiting on real timers. */
311
+ tickHeartbeatNow() {
312
+ this.tickHeartbeat();
313
+ }
314
+ tickHeartbeat() {
315
+ if (this.disposed || this.paused)
316
+ return;
317
+ if (this.now() - this.lastEventAt < this.heartbeatMs)
318
+ return;
319
+ const running = [...this.issues.values()].filter((s) => s.status === "running" && s.currentPhase);
320
+ if (running.length === 0)
321
+ return;
322
+ const parts = running.map((s) => {
323
+ const elapsedSec = s.startedAt !== undefined ? (this.now() - s.startedAt) / 1000 : 0;
324
+ return `#${s.issueNumber} ${s.currentPhase} (${formatElapsedTime(elapsedSec)})`;
325
+ });
326
+ this.emitLine(`⏱ still running: ${parts.join(", ")}`);
327
+ }
328
+ afterEvent(event, state) {
329
+ super.afterEvent(event, state);
330
+ this.lastEventAt = this.now();
331
+ this.emitEventLine(event, state);
332
+ }
333
+ emitEventLine(event, state) {
334
+ const c = colorize(this.noColor);
335
+ const phase = state.phases.find((p) => p.name === event.phase);
336
+ // #624 Item 3: non-loop phase events on retry get `(attempt N/M)`.
337
+ // The loop phase itself stays unannotated in the events log (the live zone
338
+ // shows `loop N/M · last fail: …` already; double-counting would be noise).
339
+ const retrySuffix = event.phase === "loop"
340
+ ? ""
341
+ : formatRetrySuffix(phase?.loopIteration, this.maxLoopIterations, "events");
342
+ if (event.event === "start") {
343
+ this.emitLine(`${c.cyan("▸")} #${event.issue} ${event.phase}${retrySuffix}`);
344
+ }
345
+ else if (event.event === "complete") {
346
+ const durStr = event.durationSeconds !== undefined
347
+ ? ` ${formatElapsedTime(event.durationSeconds)}`
348
+ : "";
349
+ this.emitLine(`${c.green("✔")} #${event.issue} ${event.phase}${retrySuffix}${durStr}`);
350
+ }
351
+ else {
352
+ // #624 Item 4: failure dedup. The third tier (final attempt) emits the
353
+ // full text even when the signature repeats, so divergent failures stay
354
+ // visible right up to max-iter. Dedup state is per-phase so cross-phase
355
+ // signature collisions don't produce misleading "attempt N" references.
356
+ const attempt = phase?.loopIteration ?? 1;
357
+ const dedup = phase
358
+ ? decideDedup(phase, attempt, this.maxLoopIterations)
359
+ : "full";
360
+ if (dedup === "abbreviated" && phase) {
361
+ this.emitLine(`${c.red("✘")} #${event.issue} ${event.phase}${retrySuffix} (same failure as attempt ${phase.firstAttemptForSignature})`);
362
+ }
363
+ else {
364
+ const errStr = event.error ? ` ${c.red(event.error)}` : "";
365
+ this.emitLine(`${c.red("✘")} #${event.issue} ${event.phase}${retrySuffix}${errStr}`);
366
+ }
367
+ }
368
+ }
369
+ /** Append a single `\n`-terminated line with `[HH:MM:SS]` prefix. */
370
+ emitLine(text) {
371
+ const ts = formatTimestamp(this.wallClock());
372
+ const c = colorize(this.noColor);
373
+ this.stdoutWrite(`${c.dim(`[${ts}]`)} ${text}\n`);
374
+ }
375
+ setPullRequest(issue, prNumber, prUrl) {
376
+ super.setPullRequest(issue, prNumber, prUrl);
377
+ const c = colorize(this.noColor);
378
+ this.emitLine(`${c.green("→")} #${issue} PR #${prNumber} ${c.dim(prUrl)}`);
379
+ }
380
+ renderSummary(input) {
381
+ if (this.disposed)
382
+ return;
383
+ // #624 Item 2 (AC-2.3): share the same column source/cap as the TTY path
384
+ // so the summary table is never rendered at a divergent width that pushes
385
+ // the rightmost border off-screen.
386
+ renderSummaryBlock(input, {
387
+ stdoutWrite: this.stdoutWrite,
388
+ noColor: this.noColor,
389
+ columns: Math.min(this.getColumns(), SUMMARY_COLUMN_CAP),
390
+ });
391
+ }
392
+ onDispose() {
393
+ if (this.heartbeatTimer !== null) {
394
+ clearInterval(this.heartbeatTimer);
395
+ this.heartbeatTimer = null;
396
+ }
397
+ }
398
+ }
399
+ // ============================================================================
400
+ // Failure dedup shared decision (#624 Item 4)
401
+ // ============================================================================
402
+ /**
403
+ * Three-state machine for failure dedup. Returns "abbreviated" when the
404
+ * incoming failure signature matches a prior attempt of THIS phase AND we
405
+ * haven't yet reached the final allowed attempt; otherwise "full" so
406
+ * divergence and last-chance failures stay fully visible in the events log.
407
+ *
408
+ * Per-phase (not per-issue) so cross-phase signature collisions don't produce
409
+ * misleading "same failure as attempt N" text — N would otherwise point at a
410
+ * different phase's attempt.
411
+ */
412
+ function decideDedup(phase, currentAttempt, maxIterations) {
413
+ // `applyEvent` already updated `phase.lastFailureSignature` and
414
+ // `phase.firstAttemptForSignature` before the emit code runs. So we dedup
415
+ // when the first-seen attempt is strictly earlier than the current one.
416
+ const firstAttempt = phase.firstAttemptForSignature;
417
+ if (firstAttempt === undefined || firstAttempt >= currentAttempt) {
418
+ return "full";
419
+ }
420
+ if (currentAttempt >= maxIterations) {
421
+ return "full";
422
+ }
423
+ return "abbreviated";
424
+ }
425
+ export class TTYRenderer extends BaseRenderer {
426
+ liveTickMs;
427
+ liveTimer = null;
428
+ spinnerFrame = 0;
429
+ columnsOverride;
430
+ rowsOverride;
431
+ noSignalListeners;
432
+ stallThresholdMs;
433
+ multiIssueRowCap;
434
+ maxLoopIterations;
435
+ logUpdateImpl;
436
+ logUpdateClear;
437
+ logUpdateDone;
438
+ resizeListener = null;
439
+ banner = null;
440
+ _testStub;
441
+ constructor(options) {
442
+ super(options);
443
+ this.liveTickMs = options.liveTickMs ?? DEFAULT_LIVE_TICK_MS;
444
+ this.columnsOverride = options.columns;
445
+ this.rowsOverride = options.rows;
446
+ this.noSignalListeners = Boolean(options.noSignalListeners);
447
+ // AC-26: stall threshold disabled by default (Number.POSITIVE_INFINITY); the
448
+ // wiring layer derives a real value from settings.run.timeout when available.
449
+ this.stallThresholdMs =
450
+ options.stallThresholdMs ?? Number.POSITIVE_INFINITY;
451
+ this.multiIssueRowCap =
452
+ options.multiIssueRowCap ?? DEFAULT_MULTI_ISSUE_ROW_CAP;
453
+ this.maxLoopIterations =
454
+ options.maxLoopIterations ?? DEFAULT_MAX_LOOP_ITERATIONS;
455
+ // log-update writes to process.stdout via a mutable global instance. When
456
+ // tests inject `stdoutWrite`, route renders through it instead so capture
457
+ // works deterministically.
458
+ if (options.stdoutWrite) {
459
+ // #624 Derived AC-D1: replacement-aware test stub. Tracks each frame
460
+ // replacement so tests can assert on frame churn without parsing buf.out.
461
+ // `clearCalls` / `doneCalls` verify the renderer actually invokes the
462
+ // teardown methods (not just resets local state).
463
+ const stub = {
464
+ replacementCount: 0,
465
+ lastFrame: "",
466
+ clearCalls: 0,
467
+ doneCalls: 0,
468
+ };
469
+ this._testStub = stub;
470
+ this.logUpdateImpl = (text) => {
471
+ if (stub.lastFrame)
472
+ stub.replacementCount++;
473
+ stub.lastFrame = text;
474
+ options.stdoutWrite(text + "\n");
475
+ };
476
+ this.logUpdateClear = () => {
477
+ stub.clearCalls++;
478
+ stub.lastFrame = "";
479
+ };
480
+ this.logUpdateDone = () => {
481
+ stub.doneCalls++;
482
+ stub.lastFrame = "";
483
+ };
484
+ }
485
+ else {
486
+ this._testStub = null;
487
+ this.logUpdateImpl = (text) => logUpdate(text);
488
+ this.logUpdateClear = () => logUpdate.clear();
489
+ this.logUpdateDone = () => logUpdate.done();
490
+ }
491
+ this.startLiveTimer();
492
+ this.installSignalListeners();
493
+ }
494
+ /**
495
+ * #624 Derived AC-D1: expose the test-only log-update stub. Returns `null`
496
+ * when not in test mode (production renders go through real `log-update`).
497
+ */
498
+ getTestStub() {
499
+ return this._testStub;
500
+ }
501
+ startLiveTimer() {
502
+ if (this.liveTickMs <= 0)
503
+ return;
504
+ this.liveTimer = setInterval(() => this.tick(), this.liveTickMs);
505
+ if (typeof this.liveTimer.unref === "function") {
506
+ this.liveTimer.unref();
507
+ }
508
+ }
509
+ installSignalListeners() {
510
+ if (this.noSignalListeners)
511
+ return;
512
+ this.resizeListener = () => this.redraw();
513
+ process.on("SIGWINCH", this.resizeListener);
514
+ }
515
+ /** Test hook: drive a tick without waiting on real timers. */
516
+ tickNow() {
517
+ this.tick();
518
+ }
519
+ tick() {
520
+ if (this.disposed || this.paused)
521
+ return;
522
+ this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
523
+ this.redraw();
524
+ }
525
+ afterEvent(event, state) {
526
+ super.afterEvent(event, state);
527
+ if (this.paused || this.disposed)
528
+ return;
529
+ // Append the event line first (above the live zone in append order),
530
+ // then redraw the live zone below it.
531
+ this.appendEventLine(event, state);
532
+ this.redraw();
533
+ }
534
+ afterStateChange() {
535
+ if (this.paused || this.disposed)
536
+ return;
537
+ this.redraw();
538
+ }
539
+ /** Set a banner that renders above the live grid (e.g. worktree-loss). */
540
+ setBanner(text) {
541
+ this.banner = text;
542
+ this.redraw();
543
+ }
544
+ appendEventLine(event, state) {
545
+ // Clear the live zone so the appended event becomes a real `console.log`
546
+ // line above it; the live zone redraws below.
547
+ this.logUpdateClear();
548
+ const c = colorize(this.noColor);
549
+ const phase = state.phases.find((p) => p.name === event.phase);
550
+ // #624 Item 3: shared retry-suffix helper. The `loop` phase has its own
551
+ // running indicator in the live zone, so we don't double-annotate it here.
552
+ const retrySuffix = event.phase === "loop"
553
+ ? ""
554
+ : formatRetrySuffix(phase?.loopIteration, this.maxLoopIterations, "events");
555
+ let line;
556
+ if (event.event === "start") {
557
+ line = ` ${c.cyan("▸")} #${event.issue} ${event.phase}${retrySuffix}`;
558
+ }
559
+ else if (event.event === "complete") {
560
+ const durStr = event.durationSeconds !== undefined
561
+ ? ` ${formatElapsedTime(event.durationSeconds)}`
562
+ : "";
563
+ const prSuffix = state.prUrl ? ` → PR #${state.prNumber}` : "";
564
+ line = ` ${c.green("✔")} #${event.issue} ${event.phase}${retrySuffix}${durStr}${prSuffix}`;
565
+ }
566
+ else {
567
+ // #624 Item 4: failure dedup. Abbreviated form only fires when the
568
+ // signature matches a *prior* attempt of THIS phase and we are not at
569
+ // the final allowed iteration (preserves divergence visibility).
570
+ const attempt = phase?.loopIteration ?? 1;
571
+ const dedup = phase
572
+ ? decideDedup(phase, attempt, this.maxLoopIterations)
573
+ : "full";
574
+ if (dedup === "abbreviated" && phase) {
575
+ line = ` ${c.red("✘")} #${event.issue} ${event.phase}${retrySuffix} (same failure as attempt ${phase.firstAttemptForSignature})`;
576
+ }
577
+ else {
578
+ const errStr = event.error ? ` ${c.red(event.error)}` : "";
579
+ line = ` ${c.red("✘")} #${event.issue} ${event.phase}${retrySuffix}${errStr}`;
580
+ }
581
+ }
582
+ this.stdoutWrite(line + "\n");
583
+ }
584
+ setPullRequest(issue, prNumber, prUrl) {
585
+ super.setPullRequest(issue, prNumber, prUrl);
586
+ if (this.paused || this.disposed)
587
+ return;
588
+ this.logUpdateClear();
589
+ const c = colorize(this.noColor);
590
+ this.stdoutWrite(` ${c.green("→")} #${issue} PR #${prNumber} ${c.dim(prUrl)}\n`);
591
+ this.redraw();
592
+ }
593
+ renderSummary(input) {
594
+ if (this.disposed)
595
+ return;
596
+ // #624 Item 2: tear down the live zone *before* writing the summary so any
597
+ // subsequent `console.log` from displaySummary (reflection block, merge
598
+ // tip) cannot overlap with the trailing border of the summary table.
599
+ this.logUpdateClear();
600
+ this.logUpdateDone();
601
+ if (this.liveTimer !== null) {
602
+ clearInterval(this.liveTimer);
603
+ this.liveTimer = null;
604
+ }
605
+ // #624 Item 2 (AC-2.3): clamp summary columns to SUMMARY_COLUMN_CAP so wide
606
+ // terminals don't produce a grid that overflows narrower readers (CI logs,
607
+ // VS Code terminal panes).
608
+ renderSummaryBlock(input, {
609
+ stdoutWrite: this.stdoutWrite,
610
+ noColor: this.noColor,
611
+ columns: Math.min(this.getColumns(), SUMMARY_COLUMN_CAP),
612
+ });
613
+ }
614
+ onPause() {
615
+ // Clear the live zone so verbose streaming has clean stdout.
616
+ this.logUpdateClear();
617
+ }
618
+ onResume() {
619
+ // Live zone redraws on next tick / event automatically.
620
+ this.redraw();
621
+ }
622
+ onDispose() {
623
+ if (this.liveTimer !== null) {
624
+ clearInterval(this.liveTimer);
625
+ this.liveTimer = null;
626
+ }
627
+ if (this.resizeListener) {
628
+ process.removeListener("SIGWINCH", this.resizeListener);
629
+ this.resizeListener = null;
630
+ }
631
+ this.logUpdateClear();
632
+ this.logUpdateDone();
633
+ }
634
+ // ---------------- Layout ----------------
635
+ getColumns() {
636
+ return (this.columnsOverride ??
637
+ (process.stdout.columns && process.stdout.columns > 0
638
+ ? process.stdout.columns
639
+ : 100));
640
+ }
641
+ /**
642
+ * #624 Item 1: terminal row count, with a safe default for piped / detached
643
+ * stdout where `process.stdout.rows` is undefined.
644
+ */
645
+ getRows() {
646
+ if (this.rowsOverride !== undefined)
647
+ return this.rowsOverride;
648
+ const r = process.stdout.rows;
649
+ return r && r > 0 ? r : DEFAULT_TERMINAL_ROWS;
650
+ }
651
+ /**
652
+ * #624 Item 1: hard ceiling on live-zone height. The cap is
653
+ * `max(8, rows - 5)`, dropping to `max(8, rows - 7)` when a banner is active
654
+ * so the banner + a few separator rows still fit. The floor of 8 prevents
655
+ * the live zone from collapsing on tiny terminals.
656
+ */
657
+ getMaxLiveRows() {
658
+ const reservation = this.banner ? 7 : 5;
659
+ return Math.max(8, this.getRows() - reservation);
660
+ }
661
+ redraw() {
662
+ if (this.disposed || this.paused)
663
+ return;
664
+ const cols = this.getColumns();
665
+ const text = this.renderLiveFrame(cols);
666
+ if (!text) {
667
+ this.logUpdateClear();
668
+ return;
669
+ }
670
+ this.logUpdateImpl(text);
671
+ }
672
+ /** Public for tests — render the live zone to a string without emitting. */
673
+ renderLiveFrame(columns) {
674
+ const cols = columns ?? this.getColumns();
675
+ if (this.issues.size === 0)
676
+ return "";
677
+ const text = cols < NARROW_TERMINAL_THRESHOLD
678
+ ? this.renderNarrowFrame()
679
+ : this.issues.size === 1
680
+ ? this.renderSingleIssueFrame(cols)
681
+ : this.renderMultiIssueFrame(cols);
682
+ return this.clampFrameHeight(text);
683
+ }
684
+ /**
685
+ * #624 Item 1 (AC-1.1): hard ceiling on rendered frame height. The interior
686
+ * `applyRowCap` already collapses excess issues into the rollup row, but the
687
+ * frame can still drift over the cap if many issues have multi-line status
688
+ * cells (sub-status + phase sequence). This is the belt-and-braces clamp
689
+ * that guarantees `log-update` never sees a frame taller than the terminal.
690
+ *
691
+ * Always engages — when `rows` isn't explicitly provided, `getRows()` returns
692
+ * `DEFAULT_TERMINAL_ROWS` (100) so the cap is generous but never disengaged.
693
+ */
694
+ clampFrameHeight(text) {
695
+ const lines = text.split("\n");
696
+ const maxRows = this.getMaxLiveRows();
697
+ if (lines.length <= maxRows)
698
+ return text;
699
+ const c = colorize(this.noColor);
700
+ // Reserve the last visible row for an overflow indicator so the cap is
701
+ // observable from the rendered output.
702
+ const truncated = lines.slice(0, Math.max(1, maxRows - 1));
703
+ const hidden = lines.length - truncated.length;
704
+ truncated.push(c.dim(` … ${hidden} more line${hidden === 1 ? "" : "s"} (terminal too short)`));
705
+ return truncated.join("\n");
706
+ }
707
+ renderNarrowFrame() {
708
+ // AC-25: <80 columns → indented key:value pairs, no box-drawing.
709
+ const c = colorize(this.noColor);
710
+ const header = `SEQUANT WORKFLOW · ${this.runHeader()}`;
711
+ const lines = [c.bold(header), ""];
712
+ if (this.banner)
713
+ lines.push(c.yellow(this.banner), "");
714
+ const { rolledUpDoneCount, visibleStates, totalCount } = this.applyRowCap();
715
+ if (rolledUpDoneCount > 0) {
716
+ lines.push(` ${c.green(`✔ ${rolledUpDoneCount} done`)}`);
717
+ }
718
+ for (const state of visibleStates) {
719
+ lines.push(` #${state.issueNumber} ${this.statusHeader(state)}`);
720
+ const subLines = this.statusSubLines(state);
721
+ for (const line of subLines)
722
+ lines.push(` ${line}`);
723
+ }
724
+ if (rolledUpDoneCount > 0) {
725
+ lines.push("", c.dim(` (${visibleStates.length} of ${totalCount} shown)`));
726
+ }
727
+ lines.push("", this.rollupLine());
728
+ return lines.join("\n");
729
+ }
730
+ renderSingleIssueFrame(cols) {
731
+ // AC-11: Single-issue runs use a key:value full-grid table.
732
+ const state = [...this.issues.values()][0];
733
+ const c = colorize(this.noColor);
734
+ const header = `SEQUANT WORKFLOW · #${state.issueNumber} · ${formatElapsedTime((this.now() - this.runStartedAt) / 1000)} elapsed`;
735
+ const labelWidth = 10;
736
+ const innerWidth = Math.max(40, Math.min(cols, 110) - labelWidth - 7);
737
+ const valueWidth = innerWidth;
738
+ const rows = [];
739
+ const titleSuffix = state.title ? ` — ${state.title}` : "";
740
+ rows.push([
741
+ "Issue",
742
+ [`#${state.issueNumber}${titleSuffix}`.slice(0, valueWidth)],
743
+ ]);
744
+ if (state.worktreePath) {
745
+ rows.push(["Worktree", [truncate(state.worktreePath, valueWidth)]]);
746
+ }
747
+ if (state.branch) {
748
+ rows.push(["Branch", [truncate(state.branch, valueWidth)]]);
749
+ }
750
+ rows.push(["Status", this.statusCellLines(state)]);
751
+ const lines = [c.bold(header), ""];
752
+ if (this.banner)
753
+ lines.push(c.yellow(this.banner), "");
754
+ lines.push(this.drawKeyValueTable(rows, labelWidth, valueWidth));
755
+ return lines.join("\n");
756
+ }
757
+ renderMultiIssueFrame(cols) {
758
+ // AC-5/6/7: per-issue grid with active expanded, done collapsed.
759
+ // AC-28: when total issues exceed the row cap, oldest done issues collapse
760
+ // into a single `✔ {N} done` row at the top and a `(M of N shown)` indicator
761
+ // is appended.
762
+ const c = colorize(this.noColor);
763
+ const header = `SEQUANT WORKFLOW · ${this.runHeader()}`;
764
+ const issueColW = 8;
765
+ const innerWidth = Math.max(50, Math.min(cols, 110) - issueColW - 7);
766
+ const statusColW = innerWidth;
767
+ const lines = [c.bold(header), ""];
768
+ if (this.banner)
769
+ lines.push(c.yellow(this.banner), "");
770
+ const { rolledUpDoneCount, visibleStates, totalCount } = this.applyRowCap();
771
+ const rows = [];
772
+ if (rolledUpDoneCount > 0) {
773
+ rows.push({
774
+ issueLabel: c.green(`✔ ${rolledUpDoneCount}`),
775
+ statusLines: [c.green(`${rolledUpDoneCount} done · rolled up`)],
776
+ });
777
+ }
778
+ for (const state of visibleStates) {
779
+ rows.push({
780
+ issueLabel: `#${state.issueNumber}`,
781
+ statusLines: this.statusCellLines(state),
782
+ });
783
+ }
784
+ lines.push(this.drawIssueGrid(rows, issueColW, statusColW));
785
+ lines.push("");
786
+ if (rolledUpDoneCount > 0) {
787
+ lines.push(c.dim(` (${visibleStates.length} of ${totalCount} shown)`));
788
+ }
789
+ lines.push(" " + this.rollupLine());
790
+ return lines.join("\n");
791
+ }
792
+ /**
793
+ * AC-28 + #624 Item 1: enforce the per-frame issue row cap, derived from
794
+ * the smaller of `multiIssueRowCap` (static config) and a dynamic ceiling
795
+ * computed from terminal height. The dynamic ceiling reserves ~3 lines per
796
+ * issue (status header + sub-status + separator) and a fixed overhead for
797
+ * the frame header, blank lines, grid borders, and the rollup line.
798
+ *
799
+ * If the cap is not exceeded, returns all issues unchanged. Otherwise: keep
800
+ * all non-done rows, then fill remaining slots with the most recently
801
+ * completed done rows; older done rows roll up into a single summary entry.
802
+ */
803
+ applyRowCap() {
804
+ const all = [...this.issues.values()];
805
+ const totalCount = all.length;
806
+ const cap = this.effectiveRowCap();
807
+ if (totalCount <= cap) {
808
+ return { rolledUpDoneCount: 0, visibleStates: all, totalCount };
809
+ }
810
+ const active = all.filter((s) => s.status !== "done");
811
+ const done = all
812
+ .filter((s) => s.status === "done")
813
+ .sort((a, b) => (b.completedAt ?? 0) - (a.completedAt ?? 0));
814
+ // Reserve one row for the rollup line, leave the rest for visible issues.
815
+ const visibleSlots = Math.max(1, cap - 1);
816
+ const remainingSlotsForDone = Math.max(0, visibleSlots - active.length);
817
+ const visibleDone = done.slice(0, remainingSlotsForDone);
818
+ const rolledUpDoneCount = done.length - visibleDone.length;
819
+ return {
820
+ rolledUpDoneCount,
821
+ visibleStates: [...active, ...visibleDone],
822
+ totalCount,
823
+ };
824
+ }
825
+ /**
826
+ * #624 Item 1 (AC-1.1): smaller of the configured static cap and the
827
+ * dynamic terminal-height-derived cap. Each issue row takes roughly 3 grid
828
+ * lines (header, sub-status, separator); the fixed overhead covers the
829
+ * frame title, blank lines, grid borders, and the rollup row.
830
+ *
831
+ * Always engages — when `rows` isn't explicitly provided, `getRows()` returns
832
+ * `DEFAULT_TERMINAL_ROWS` (100) which produces a dynamic cap of ~30, well
833
+ * above the static `multiIssueRowCap` default of 10, so the static cap stays
834
+ * in charge for normal-height terminals.
835
+ */
836
+ effectiveRowCap() {
837
+ const LINES_PER_ISSUE = 3;
838
+ const FIXED_OVERHEAD = 8;
839
+ const maxLiveRows = this.getMaxLiveRows();
840
+ const dynamicCap = Math.max(2, Math.floor((maxLiveRows - FIXED_OVERHEAD) / LINES_PER_ISSUE));
841
+ return Math.min(this.multiIssueRowCap, dynamicCap);
842
+ }
843
+ // ---------------- Per-issue status content ----------------
844
+ statusHeader(state) {
845
+ const c = colorize(this.noColor);
846
+ if (state.status === "done") {
847
+ const total = state.startedAt !== undefined && state.completedAt !== undefined
848
+ ? formatElapsedTime((state.completedAt - state.startedAt) / 1000)
849
+ : "";
850
+ const phaseSeq = state.phases
851
+ .map((p) => p.name)
852
+ .filter((n) => n !== "loop")
853
+ .join("→");
854
+ const prSuffix = state.prNumber ? ` · PR #${state.prNumber}` : "";
855
+ return c.green(`✔ done · ${total} · ${phaseSeq}${prSuffix}`);
856
+ }
857
+ if (state.status === "failed") {
858
+ const total = state.startedAt !== undefined
859
+ ? formatElapsedTime(((state.completedAt ?? this.now()) - state.startedAt) / 1000)
860
+ : "";
861
+ return c.red(`✘ failed${total ? ` · ${total}` : ""}${state.failureReason ? ` · ${state.failureReason}` : ""}`);
862
+ }
863
+ if (state.status === "queued") {
864
+ return c.gray("· queued");
865
+ }
866
+ // running
867
+ const cur = state.currentPhase ?? "starting";
868
+ const phase = state.phases.find((p) => p.name === cur);
869
+ const elapsedMs = phase?.startedAt !== undefined ? this.now() - phase.startedAt : 0;
870
+ const elapsed = formatElapsedTime(elapsedMs / 1000);
871
+ const spinner = SPINNER_FRAMES[this.spinnerFrame];
872
+ // AC-23: in auto-detect mode, render `Phase: detecting…` while spec is
873
+ // running and no other phase has started yet (no resolved plan known).
874
+ if (state.autoDetect &&
875
+ cur === "spec" &&
876
+ phase?.status === "running" &&
877
+ state.phases.length === 1) {
878
+ return c.cyan(`${spinner} Phase: detecting… · ${elapsed}`);
879
+ }
880
+ // AC-26: when a phase has been running past the stall threshold, flip the
881
+ // status header to a yellow stalled marker. The phase keeps ticking; this
882
+ // is informational only.
883
+ if (elapsedMs > this.stallThresholdMs) {
884
+ return c.yellow(`⚠ stalled · ${cur} · ${elapsed}`);
885
+ }
886
+ // #624 Item 3 (AC-3.3): while in the `loop` phase, surface both the loop
887
+ // iteration counter and the last failure reason so the user can see what
888
+ // the loop is reacting to. The first loop iteration starts at 1/M.
889
+ if (cur === "loop") {
890
+ const iter = phase?.loopIteration ?? 1;
891
+ const failReason = state.failureReason
892
+ ? ` · last fail: ${truncate(state.failureReason, FAIL_REASON_TRUNCATE_LENGTH)}`
893
+ : "";
894
+ return c.cyan(`${spinner} loop ${iter}/${this.maxLoopIterations}${failReason} · ${elapsed}`);
895
+ }
896
+ // #624 Derived AC-D2: shared retry-suffix helper. Eliminates the hardcoded
897
+ // `/3` literal so `maxLoopIterations` from settings flows through.
898
+ const loopLabel = formatRetrySuffix(phase?.loopIteration, this.maxLoopIterations, "header");
899
+ const loopPrefix = loopLabel ? ` loop${loopLabel}` : "";
900
+ return c.cyan(`${spinner} ${cur}${loopPrefix} · ${elapsed}`);
901
+ }
902
+ statusSubLines(state) {
903
+ const c = colorize(this.noColor);
904
+ const lines = [];
905
+ if (state.status === "running" || state.status === "queued") {
906
+ const seq = state.phases
907
+ .filter((p) => p.name !== "loop")
908
+ .map((p) => {
909
+ if (p.status === "done")
910
+ return c.green(`${p.name} ✔${p.durationMs ? ` ${formatElapsedTime(p.durationMs / 1000)}` : ""}`);
911
+ if (p.status === "failed")
912
+ return c.red(`${p.name} ✘`);
913
+ if (p.status === "running")
914
+ return c.cyan(`${p.name} running`);
915
+ return c.gray(`${p.name} queued`);
916
+ })
917
+ .join(" → ");
918
+ if (seq)
919
+ lines.push(seq);
920
+ if (state.subStatus)
921
+ lines.push(c.dim(state.subStatus));
922
+ }
923
+ return lines;
924
+ }
925
+ statusCellLines(state) {
926
+ if (state.status === "done")
927
+ return [this.statusHeader(state)];
928
+ return [this.statusHeader(state), ...this.statusSubLines(state)];
929
+ }
930
+ // ---------------- Rollup / header ----------------
931
+ runHeader() {
932
+ const elapsed = formatElapsedTime((this.now() - this.runStartedAt) / 1000);
933
+ const issues = this.issues.size;
934
+ return `${issues} issue${issues === 1 ? "" : "s"} · ${elapsed}`;
935
+ }
936
+ rollupLine() {
937
+ const c = colorize(this.noColor);
938
+ let done = 0, running = 0, queued = 0, failed = 0;
939
+ for (const s of this.issues.values()) {
940
+ switch (s.status) {
941
+ case "done":
942
+ done++;
943
+ break;
944
+ case "running":
945
+ running++;
946
+ break;
947
+ case "queued":
948
+ queued++;
949
+ break;
950
+ case "failed":
951
+ failed++;
952
+ break;
953
+ }
954
+ }
955
+ return c.dim(`${done} done · ${running} running · ${queued} queued · ${failed} failed`);
956
+ }
957
+ // ---------------- Box drawing ----------------
958
+ drawKeyValueTable(rows, labelW, valueW) {
959
+ const c = colorize(this.noColor);
960
+ const dim = c.dim;
961
+ const total = labelW + valueW + 3;
962
+ const top = dim(" ┌" + "─".repeat(labelW + 2) + "┬" + "─".repeat(valueW + 2) + "┐");
963
+ const sep = dim(" ├" + "─".repeat(labelW + 2) + "┼" + "─".repeat(valueW + 2) + "┤");
964
+ const bottom = dim(" └" + "─".repeat(labelW + 2) + "┴" + "─".repeat(valueW + 2) + "┘");
965
+ const out = [top];
966
+ rows.forEach(([label, lines], i) => {
967
+ const labelPadded = padEndVisible(label, labelW);
968
+ lines.forEach((line, idx) => {
969
+ const labelCell = idx === 0 ? c.cyan(labelPadded) : " ".repeat(labelW);
970
+ const valuePadded = padEndVisible(line, valueW);
971
+ out.push(` ${dim("│")} ${labelCell} ${dim("│")} ${valuePadded} ${dim("│")}`);
972
+ });
973
+ if (i < rows.length - 1)
974
+ out.push(sep);
975
+ });
976
+ out.push(bottom);
977
+ void total;
978
+ return out.join("\n");
979
+ }
980
+ drawIssueGrid(rows, issueW, statusW) {
981
+ const c = colorize(this.noColor);
982
+ const dim = c.dim;
983
+ const top = dim(" ┌" + "─".repeat(issueW + 2) + "┬" + "─".repeat(statusW + 2) + "┐");
984
+ const sep = dim(" ├" + "─".repeat(issueW + 2) + "┼" + "─".repeat(statusW + 2) + "┤");
985
+ const bottom = dim(" └" + "─".repeat(issueW + 2) + "┴" + "─".repeat(statusW + 2) + "┘");
986
+ const headerRow = ` ${dim("│")} ${c.cyan(padEndVisible("Issue", issueW))} ${dim("│")} ${c.cyan(padEndVisible("Status", statusW))} ${dim("│")}`;
987
+ const out = [top, headerRow, sep];
988
+ rows.forEach((row, i) => {
989
+ row.statusLines.forEach((line, idx) => {
990
+ const issueCell = idx === 0
991
+ ? padEndVisible(row.issueLabel, issueW)
992
+ : " ".repeat(issueW);
993
+ const statusPadded = padEndVisible(line, statusW);
994
+ out.push(` ${dim("│")} ${issueCell} ${dim("│")} ${statusPadded} ${dim("│")}`);
995
+ });
996
+ if (i < rows.length - 1)
997
+ out.push(sep);
998
+ });
999
+ out.push(bottom);
1000
+ return out.join("\n");
1001
+ }
1002
+ }
1003
+ function renderSummaryBlock(input, ctx) {
1004
+ const { issues } = input;
1005
+ if (issues.length === 0 && !input.dryRun)
1006
+ return;
1007
+ const c = colorize(ctx.noColor);
1008
+ const passed = issues.filter((i) => i.success).length;
1009
+ const failed = issues.filter((i) => !i.success).length;
1010
+ const totalStr = input.totalDurationSeconds !== undefined
1011
+ ? ` · ${formatElapsedTime(input.totalDurationSeconds)}`
1012
+ : "";
1013
+ const out = [];
1014
+ out.push("");
1015
+ out.push(c.bold(`SUMMARY · ${issues.length} issue${issues.length === 1 ? "" : "s"}${totalStr} · ${passed} passed · ${failed} failed`));
1016
+ out.push("");
1017
+ if (ctx.columns < NARROW_TERMINAL_THRESHOLD) {
1018
+ // AC-25 fallback: indented key:value pairs.
1019
+ for (const r of issues) {
1020
+ const status = r.success ? c.green("✔ passed") : c.red("✘ failed");
1021
+ const detail = renderSummaryDetail(r, ctx);
1022
+ out.push(` ${status} #${r.issueNumber} ${detail.summary}`);
1023
+ for (const extra of detail.extras)
1024
+ out.push(` ${extra}`);
1025
+ if (r.durationSeconds !== undefined) {
1026
+ out.push(` ${c.dim(formatElapsedTime(r.durationSeconds))}`);
1027
+ }
1028
+ }
1029
+ }
1030
+ else {
1031
+ out.push(renderSummaryGrid(issues, ctx));
1032
+ }
1033
+ out.push("");
1034
+ out.push(` ${c.green(`${passed} passed`)} · ${c.red(`${failed} failed`)}`);
1035
+ if (input.logPath) {
1036
+ out.push(` ${c.dim(`Log: ${input.logPath}`)}`);
1037
+ }
1038
+ out.push("");
1039
+ ctx.stdoutWrite(out.join("\n"));
1040
+ }
1041
+ function renderSummaryDetail(r, ctx) {
1042
+ const c = colorize(ctx.noColor);
1043
+ if (r.success) {
1044
+ const phaseSeq = r.phases
1045
+ .map((p) => (p.success ? c.green(p.name) : c.red(p.name)))
1046
+ .join(" → ");
1047
+ const pr = r.prNumber ? ` · PR #${r.prNumber}` : "";
1048
+ return { summary: `${phaseSeq}${pr}`, extras: [] };
1049
+ }
1050
+ // Failed → multi-line detail.
1051
+ const reason = r.failureReason ?? "failure";
1052
+ const extras = [];
1053
+ if (r.qaVerdict) {
1054
+ const unmet = r.unmetCount !== undefined ? ` (${r.unmetCount} unmet)` : "";
1055
+ extras.push(c.red(`${r.qaVerdict}${unmet}`));
1056
+ }
1057
+ return { summary: c.red(reason), extras };
1058
+ }
1059
+ function renderSummaryGrid(issues, ctx) {
1060
+ const c = colorize(ctx.noColor);
1061
+ const dim = c.dim;
1062
+ const issueW = 8;
1063
+ const resultW = 10;
1064
+ const totalW = 10;
1065
+ const detailW = Math.max(28, Math.min(ctx.columns, 100) - issueW - resultW - totalW - 11);
1066
+ const top = dim(" ┌" +
1067
+ "─".repeat(issueW + 2) +
1068
+ "┬" +
1069
+ "─".repeat(resultW + 2) +
1070
+ "┬" +
1071
+ "─".repeat(detailW + 2) +
1072
+ "┬" +
1073
+ "─".repeat(totalW + 2) +
1074
+ "┐");
1075
+ const sep = dim(" ├" +
1076
+ "─".repeat(issueW + 2) +
1077
+ "┼" +
1078
+ "─".repeat(resultW + 2) +
1079
+ "┼" +
1080
+ "─".repeat(detailW + 2) +
1081
+ "┼" +
1082
+ "─".repeat(totalW + 2) +
1083
+ "┤");
1084
+ const bottom = dim(" └" +
1085
+ "─".repeat(issueW + 2) +
1086
+ "┴" +
1087
+ "─".repeat(resultW + 2) +
1088
+ "┴" +
1089
+ "─".repeat(detailW + 2) +
1090
+ "┴" +
1091
+ "─".repeat(totalW + 2) +
1092
+ "┘");
1093
+ const headerRow = ` ${dim("│")} ${c.cyan(padEndVisible("Issue", issueW))} ${dim("│")} ${c.cyan(padEndVisible("Result", resultW))} ${dim("│")} ${c.cyan(padEndVisible("Detail", detailW))} ${dim("│")} ${c.cyan(padEndVisible("Total", totalW))} ${dim("│")}`;
1094
+ const out = [top, headerRow, sep];
1095
+ issues.forEach((r, i) => {
1096
+ const result = r.success ? c.green("✔ passed") : c.red("✘ failed");
1097
+ const detail = renderSummaryDetail(r, ctx);
1098
+ const detailLines = [detail.summary, ...detail.extras];
1099
+ const total = r.durationSeconds !== undefined
1100
+ ? formatElapsedTime(r.durationSeconds)
1101
+ : "";
1102
+ detailLines.forEach((line, idx) => {
1103
+ const issueCell = idx === 0
1104
+ ? padEndVisible(`#${r.issueNumber}`, issueW)
1105
+ : " ".repeat(issueW);
1106
+ const resultCell = idx === 0 ? padEndVisible(result, resultW) : " ".repeat(resultW);
1107
+ const totalCell = idx === 0 ? padEndVisible(total, totalW) : " ".repeat(totalW);
1108
+ out.push(` ${dim("│")} ${issueCell} ${dim("│")} ${resultCell} ${dim("│")} ${padEndVisible(line, detailW)} ${dim("│")} ${totalCell} ${dim("│")}`);
1109
+ });
1110
+ if (i < issues.length - 1)
1111
+ out.push(sep);
1112
+ });
1113
+ out.push(bottom);
1114
+ return out.join("\n");
1115
+ }
1116
+ // ============================================================================
1117
+ // Helpers
1118
+ // ============================================================================
1119
+ /**
1120
+ * Pad/truncate a string to a visible-width column, ignoring ANSI escape
1121
+ * sequences. Uses `string-width` (already a dependency) so wide CJK and
1122
+ * emoji characters don't break alignment.
1123
+ */
1124
+ function padEndVisible(s, width) {
1125
+ const visible = stringWidth(s);
1126
+ if (visible >= width)
1127
+ return truncate(s, width);
1128
+ return s + " ".repeat(width - visible);
1129
+ }
1130
+ function truncate(s, max) {
1131
+ // Strip nothing — assumes input is plain text, not ANSI. Visible truncation
1132
+ // is best-effort; box drawing happens after this so ANSI is added later.
1133
+ if (s.length <= max)
1134
+ return s;
1135
+ return s.slice(0, Math.max(1, max - 1)) + "…";
1136
+ }
1137
+ /**
1138
+ * Create a `RunRenderer` matching the current execution context.
1139
+ *
1140
+ * Detection order:
1141
+ * 1. Explicit `mode` override.
1142
+ * 2. `SEQUANT_ORCHESTRATOR` env var → orchestrator (no-op).
1143
+ * 3. `process.stdout.isTTY` (or `options.isTTY`) → TTY renderer.
1144
+ * 4. Otherwise → non-TTY renderer.
1145
+ */
1146
+ export function createRunRenderer(options = {}) {
1147
+ const mode = options.mode ??
1148
+ (process.env.SEQUANT_ORCHESTRATOR
1149
+ ? "orchestrator"
1150
+ : (options.isTTY ?? Boolean(process.stdout.isTTY))
1151
+ ? "tty"
1152
+ : "non-tty");
1153
+ if (mode === "orchestrator")
1154
+ return new OrchestratorRenderer(options);
1155
+ if (mode === "tty")
1156
+ return new TTYRenderer(options);
1157
+ return new NonTTYRenderer(options);
1158
+ }
1159
+ /**
1160
+ * Public summary helper — used by the legacy displaySummary path so callers
1161
+ * that bypass the renderer still get the new grid layout.
1162
+ */
1163
+ export function renderRunSummary(input, options = {}) {
1164
+ // #624 Item 2 (AC-2.3): apply the same SUMMARY_COLUMN_CAP as the TTY and
1165
+ // non-TTY paths so the legacy renderless path can't produce a divergent
1166
+ // wide grid that overflows.
1167
+ const rawColumns = options.columns ?? process.stdout.columns ?? 100;
1168
+ renderSummaryBlock(input, {
1169
+ stdoutWrite: options.stdoutWrite ?? ((s) => void process.stdout.write(s)),
1170
+ noColor: Boolean(options.noColor) || Boolean(process.env.NO_COLOR),
1171
+ columns: Math.min(rawColumns, SUMMARY_COLUMN_CAP),
1172
+ });
1173
+ }