sequant 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +81 -5
  4. package/dist/bin/cli.js +140 -13
  5. package/dist/src/commands/abort.d.ts +36 -0
  6. package/dist/src/commands/abort.js +138 -0
  7. package/dist/src/commands/doctor.d.ts +25 -0
  8. package/dist/src/commands/doctor.js +36 -1
  9. package/dist/src/commands/locks.d.ts +67 -0
  10. package/dist/src/commands/locks.js +290 -0
  11. package/dist/src/commands/merge.js +11 -0
  12. package/dist/src/commands/prompt.d.ts +46 -0
  13. package/dist/src/commands/prompt.js +273 -0
  14. package/dist/src/commands/run-display.d.ts +11 -2
  15. package/dist/src/commands/run-display.js +62 -28
  16. package/dist/src/commands/run-progress.d.ts +42 -0
  17. package/dist/src/commands/run-progress.js +93 -0
  18. package/dist/src/commands/run.js +90 -18
  19. package/dist/src/commands/stats.d.ts +2 -0
  20. package/dist/src/commands/stats.js +94 -8
  21. package/dist/src/commands/status.js +12 -0
  22. package/dist/src/commands/watch.d.ts +18 -0
  23. package/dist/src/commands/watch.js +211 -0
  24. package/dist/src/lib/ac-linter.d.ts +1 -1
  25. package/dist/src/lib/ac-linter.js +81 -0
  26. package/dist/src/lib/assess-collision-detect.d.ts +91 -0
  27. package/dist/src/lib/assess-collision-detect.js +217 -0
  28. package/dist/src/lib/assess-comment-parser.d.ts +59 -1
  29. package/dist/src/lib/assess-comment-parser.js +124 -2
  30. package/dist/src/lib/cli-ui/format.d.ts +19 -0
  31. package/dist/src/lib/cli-ui/format.js +34 -0
  32. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +220 -0
  33. package/dist/src/lib/cli-ui/run-renderer-types.js +7 -0
  34. package/dist/src/lib/cli-ui/run-renderer.d.ts +265 -0
  35. package/dist/src/lib/cli-ui/run-renderer.js +1390 -0
  36. package/dist/src/lib/cli-ui/scrollback-harness.d.ts +112 -0
  37. package/dist/src/lib/cli-ui/scrollback-harness.js +294 -0
  38. package/dist/src/lib/heuristics/behavior-rule-detector.d.ts +94 -0
  39. package/dist/src/lib/heuristics/behavior-rule-detector.js +467 -0
  40. package/dist/src/lib/locks/index.d.ts +7 -0
  41. package/dist/src/lib/locks/index.js +5 -0
  42. package/dist/src/lib/locks/lock-manager.d.ts +168 -0
  43. package/dist/src/lib/locks/lock-manager.js +433 -0
  44. package/dist/src/lib/locks/types.d.ts +59 -0
  45. package/dist/src/lib/locks/types.js +31 -0
  46. package/dist/src/lib/merge-check/types.js +1 -1
  47. package/dist/src/lib/qa/markdown-only-ci.d.ts +46 -0
  48. package/dist/src/lib/qa/markdown-only-ci.js +74 -0
  49. package/dist/src/lib/relay/activation.d.ts +60 -0
  50. package/dist/src/lib/relay/activation.js +122 -0
  51. package/dist/src/lib/relay/archive.d.ts +34 -0
  52. package/dist/src/lib/relay/archive.js +112 -0
  53. package/dist/src/lib/relay/frame.d.ts +20 -0
  54. package/dist/src/lib/relay/frame.js +76 -0
  55. package/dist/src/lib/relay/index.d.ts +13 -0
  56. package/dist/src/lib/relay/index.js +13 -0
  57. package/dist/src/lib/relay/paths.d.ts +43 -0
  58. package/dist/src/lib/relay/paths.js +59 -0
  59. package/dist/src/lib/relay/pid.d.ts +34 -0
  60. package/dist/src/lib/relay/pid.js +72 -0
  61. package/dist/src/lib/relay/reader.d.ts +35 -0
  62. package/dist/src/lib/relay/reader.js +115 -0
  63. package/dist/src/lib/relay/types.d.ts +70 -0
  64. package/dist/src/lib/relay/types.js +85 -0
  65. package/dist/src/lib/relay/writer.d.ts +48 -0
  66. package/dist/src/lib/relay/writer.js +113 -0
  67. package/dist/src/lib/settings.d.ts +31 -1
  68. package/dist/src/lib/settings.js +18 -3
  69. package/dist/src/lib/version-check.d.ts +60 -5
  70. package/dist/src/lib/version-check.js +97 -9
  71. package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
  72. package/dist/src/lib/workflow/batch-executor.js +274 -185
  73. package/dist/src/lib/workflow/config-resolver.js +4 -0
  74. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +48 -1
  75. package/dist/src/lib/workflow/drivers/aider.d.ts +7 -1
  76. package/dist/src/lib/workflow/drivers/aider.js +9 -0
  77. package/dist/src/lib/workflow/drivers/claude-code.d.ts +17 -1
  78. package/dist/src/lib/workflow/drivers/claude-code.js +51 -2
  79. package/dist/src/lib/workflow/drivers/index.d.ts +1 -1
  80. package/dist/src/lib/workflow/event-emitter.d.ts +157 -0
  81. package/dist/src/lib/workflow/event-emitter.js +102 -0
  82. package/dist/src/lib/workflow/heartbeat.d.ts +71 -0
  83. package/dist/src/lib/workflow/heartbeat.js +194 -0
  84. package/dist/src/lib/workflow/notice.d.ts +32 -0
  85. package/dist/src/lib/workflow/notice.js +38 -0
  86. package/dist/src/lib/workflow/phase-executor.d.ts +58 -16
  87. package/dist/src/lib/workflow/phase-executor.js +244 -130
  88. package/dist/src/lib/workflow/phase-mapper.d.ts +27 -13
  89. package/dist/src/lib/workflow/phase-mapper.js +70 -51
  90. package/dist/src/lib/workflow/phase-registry.d.ts +127 -0
  91. package/dist/src/lib/workflow/phase-registry.js +233 -0
  92. package/dist/src/lib/workflow/platforms/github.d.ts +1 -1
  93. package/dist/src/lib/workflow/platforms/github.js +20 -3
  94. package/dist/src/lib/workflow/pr-status.d.ts +18 -2
  95. package/dist/src/lib/workflow/pr-status.js +41 -9
  96. package/dist/src/lib/workflow/qa-stagnation.d.ts +117 -0
  97. package/dist/src/lib/workflow/qa-stagnation.js +179 -0
  98. package/dist/src/lib/workflow/run-log-schema.d.ts +5 -55
  99. package/dist/src/lib/workflow/run-orchestrator.d.ts +70 -1
  100. package/dist/src/lib/workflow/run-orchestrator.js +464 -25
  101. package/dist/src/lib/workflow/run-reflect.js +1 -1
  102. package/dist/src/lib/workflow/run-state.d.ts +71 -0
  103. package/dist/src/lib/workflow/run-state.js +14 -0
  104. package/dist/src/lib/workflow/state-cleanup.d.ts +13 -5
  105. package/dist/src/lib/workflow/state-cleanup.js +17 -5
  106. package/dist/src/lib/workflow/state-manager.d.ts +31 -2
  107. package/dist/src/lib/workflow/state-manager.js +64 -1
  108. package/dist/src/lib/workflow/state-schema.d.ts +82 -35
  109. package/dist/src/lib/workflow/state-schema.js +63 -4
  110. package/dist/src/lib/workflow/types.d.ts +139 -16
  111. package/dist/src/lib/workflow/types.js +18 -13
  112. package/dist/src/lib/workflow/worktree-manager.d.ts +8 -1
  113. package/dist/src/lib/workflow/worktree-manager.js +15 -6
  114. package/dist/src/mcp/tools/run.d.ts +44 -0
  115. package/dist/src/mcp/tools/run.js +104 -13
  116. package/dist/src/ui/tui/App.d.ts +14 -0
  117. package/dist/src/ui/tui/App.js +41 -0
  118. package/dist/src/ui/tui/ElapsedTimer.d.ts +10 -0
  119. package/dist/src/ui/tui/ElapsedTimer.js +31 -0
  120. package/dist/src/ui/tui/Header.d.ts +6 -0
  121. package/dist/src/ui/tui/Header.js +15 -0
  122. package/dist/src/ui/tui/IssueBox.d.ts +16 -0
  123. package/dist/src/ui/tui/IssueBox.js +68 -0
  124. package/dist/src/ui/tui/Spinner.d.ts +9 -0
  125. package/dist/src/ui/tui/Spinner.js +18 -0
  126. package/dist/src/ui/tui/index.d.ts +15 -0
  127. package/dist/src/ui/tui/index.js +29 -0
  128. package/dist/src/ui/tui/theme.d.ts +29 -0
  129. package/dist/src/ui/tui/theme.js +52 -0
  130. package/dist/src/ui/tui/truncate.d.ts +11 -0
  131. package/dist/src/ui/tui/truncate.js +31 -0
  132. package/package.json +14 -6
  133. package/templates/agents/sequant-explorer.md +1 -0
  134. package/templates/agents/sequant-qa-checker.md +2 -1
  135. package/templates/agents/sequant-testgen.md +1 -0
  136. package/templates/hooks/post-tool.sh +92 -0
  137. package/templates/hooks/pre-tool.sh +18 -9
  138. package/templates/hooks/relay-check.sh +107 -0
  139. package/templates/relay/frame.txt +11 -0
  140. package/templates/scripts/cleanup-worktree.sh +25 -3
  141. package/templates/scripts/new-feature.sh +6 -0
  142. package/templates/skills/_shared/references/behavior-rule-detection.md +205 -0
  143. package/templates/skills/_shared/references/subagent-types.md +21 -8
  144. package/templates/skills/assess/SKILL.md +122 -68
  145. package/templates/skills/assess/references/predicted-collision-detection.md +109 -0
  146. package/templates/skills/docs/SKILL.md +141 -22
  147. package/templates/skills/exec/SKILL.md +10 -8
  148. package/templates/skills/fullsolve/SKILL.md +79 -5
  149. package/templates/skills/loop/SKILL.md +28 -0
  150. package/templates/skills/merger/SKILL.md +621 -0
  151. package/templates/skills/qa/SKILL.md +727 -8
  152. package/templates/skills/setup/SKILL.md +12 -6
  153. package/templates/skills/spec/SKILL.md +52 -0
  154. package/templates/skills/spec/references/parallel-groups.md +7 -0
  155. package/templates/skills/spec/references/recommended-workflow.md +4 -2
  156. package/templates/skills/testgen/SKILL.md +24 -17
@@ -0,0 +1,1390 @@
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 fs from "node:fs";
17
+ import path from "node:path";
18
+ import chalk from "chalk";
19
+ import logUpdate from "log-update";
20
+ import stringWidth from "string-width";
21
+ import { formatElapsedTime, formatTimestamp } from "./format.js";
22
+ const DEFAULT_LIVE_TICK_MS = 1000;
23
+ const DEFAULT_NON_TTY_HEARTBEAT_MS = 60_000;
24
+ const NARROW_TERMINAL_THRESHOLD = 80;
25
+ const DEFAULT_MULTI_ISSUE_ROW_CAP = 10;
26
+ // Generous default: in production, TTY mode always has `process.stdout.rows`
27
+ // set, so this only matters in tests and detached stdout. A high default avoids
28
+ // over-constraining multi-issue test scenarios while keeping the height cap
29
+ // active enough to catch pathological frames.
30
+ const DEFAULT_TERMINAL_ROWS = 100;
31
+ const DEFAULT_MAX_LOOP_ITERATIONS = 3;
32
+ const SUMMARY_COLUMN_CAP = 110;
33
+ const FAILURE_SIGNATURE_LENGTH = 80;
34
+ const FAIL_REASON_TRUNCATE_LENGTH = 40;
35
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
36
+ // Colour helpers — bypassed cleanly when `noColor` is true.
37
+ function colorize(noColor) {
38
+ if (noColor) {
39
+ const id = (s) => s;
40
+ return {
41
+ dim: id,
42
+ green: id,
43
+ red: id,
44
+ yellow: id,
45
+ cyan: id,
46
+ gray: id,
47
+ bold: id,
48
+ };
49
+ }
50
+ return {
51
+ dim: chalk.dim,
52
+ green: chalk.green,
53
+ red: chalk.red,
54
+ yellow: chalk.yellow,
55
+ cyan: chalk.cyan,
56
+ gray: chalk.gray,
57
+ bold: chalk.bold,
58
+ };
59
+ }
60
+ // ============================================================================
61
+ // Shared helpers (#624)
62
+ // ============================================================================
63
+ /**
64
+ * #624 Item 4: normalized failure signature for dedup decisions.
65
+ *
66
+ * Strips ANSI escape sequences, lowercases, trims whitespace, and truncates to
67
+ * the first 80 visible chars. The plan deliberately chose a length-bounded
68
+ * prefix over a crypto hash so debugging can match signatures by eye.
69
+ */
70
+ export function failureSignature(error) {
71
+ if (!error)
72
+ return "";
73
+ // eslint-disable-next-line no-control-regex
74
+ const stripped = error.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
75
+ return stripped.trim().toLowerCase().slice(0, FAILURE_SIGNATURE_LENGTH);
76
+ }
77
+ /**
78
+ * #624 Item 3 / Derived AC-D2: shared suffix builder used by all three retry
79
+ * sites (NonTTY events log, TTY events log, TTY status header). Centralizes
80
+ * the `(attempt N/M)` / `loop N/M` literals so they cannot drift between paths.
81
+ *
82
+ * `kind` selects the surface:
83
+ * - "events" → events-log line: leading space + parentheses
84
+ * - "header" → status cell: leading space, no parentheses
85
+ *
86
+ * Returns the empty string when the attempt counter does not apply
87
+ * (no iteration, iteration === 1, or non-positive).
88
+ */
89
+ export function formatRetrySuffix(iteration, maxIterations, kind) {
90
+ if (!iteration || iteration <= 1)
91
+ return "";
92
+ const counter = `${iteration}/${maxIterations}`;
93
+ if (kind === "events")
94
+ return ` (attempt ${counter})`;
95
+ return ` ${counter}`;
96
+ }
97
+ // ============================================================================
98
+ // Shared state machine
99
+ // ============================================================================
100
+ class BaseRenderer {
101
+ issues = new Map();
102
+ stdoutWrite;
103
+ stderrWrite;
104
+ now;
105
+ wallClock;
106
+ noColor;
107
+ runStartedAt;
108
+ paused = false;
109
+ disposed = false;
110
+ constructor(options) {
111
+ this.stdoutWrite =
112
+ options.stdoutWrite ?? ((s) => void process.stdout.write(s));
113
+ this.stderrWrite =
114
+ options.stderrWrite ?? ((s) => void process.stderr.write(s));
115
+ this.now = options.now ?? Date.now;
116
+ this.wallClock = options.wallClock ?? (() => new Date());
117
+ this.noColor = Boolean(options.noColor) || Boolean(process.env.NO_COLOR);
118
+ this.runStartedAt = this.now();
119
+ }
120
+ registerIssue(reg) {
121
+ if (this.issues.has(reg.issueNumber))
122
+ return;
123
+ // #672 AC-2: seed pending cells when the plan is known at registration.
124
+ // Empty arrays fall back to streaming-only behaviour (AC-2 edge case).
125
+ const phases = reg.plannedPhases && reg.plannedPhases.length > 0
126
+ ? reg.plannedPhases.map((name) => ({ name, status: "pending" }))
127
+ : [];
128
+ this.issues.set(reg.issueNumber, {
129
+ issueNumber: reg.issueNumber,
130
+ title: reg.title,
131
+ worktreePath: reg.worktreePath,
132
+ branch: reg.branch,
133
+ autoDetect: reg.autoDetect,
134
+ status: "queued",
135
+ phases,
136
+ });
137
+ this.afterStateChange();
138
+ }
139
+ setPhasePlan(issue, phases) {
140
+ const state = this.issues.get(issue);
141
+ if (!state)
142
+ return;
143
+ // #672 AC-2: rebuild the phase array from the resolved plan, preserving
144
+ // any phase state already captured from events that fired before the plan
145
+ // resolved (e.g. spec ran first in auto-detect mode and finished before
146
+ // setPhasePlan landed). Phases already seen keep their state; new planned
147
+ // phases enter as `pending`.
148
+ const existing = new Map(state.phases.map((p) => [p.name, p]));
149
+ state.phases = phases.map((name) => existing.get(name) ?? { name, status: "pending" });
150
+ // Any previously-seen phases that aren't in the new plan still belong on
151
+ // the row — they actually ran. Append them at the end so the planned order
152
+ // is preserved for unplayed phases.
153
+ for (const prev of existing.values()) {
154
+ if (!phases.includes(prev.name))
155
+ state.phases.push(prev);
156
+ }
157
+ this.afterStateChange();
158
+ }
159
+ onEvent(event) {
160
+ if (this.disposed)
161
+ return;
162
+ let state = this.issues.get(event.issue);
163
+ if (!state) {
164
+ state = {
165
+ issueNumber: event.issue,
166
+ status: "queued",
167
+ phases: [],
168
+ };
169
+ this.issues.set(event.issue, state);
170
+ }
171
+ this.applyEvent(state, event);
172
+ this.afterEvent(event, state);
173
+ }
174
+ setPullRequest(issue, prNumber, prUrl) {
175
+ const state = this.issues.get(issue);
176
+ if (!state)
177
+ return;
178
+ state.prNumber = prNumber;
179
+ state.prUrl = prUrl;
180
+ this.afterStateChange();
181
+ }
182
+ pause() {
183
+ this.paused = true;
184
+ this.onPause();
185
+ }
186
+ resume() {
187
+ if (!this.paused)
188
+ return;
189
+ this.paused = false;
190
+ this.onResume();
191
+ }
192
+ /**
193
+ * #647 AC-3: default notice path — just write to the renderer's stdout
194
+ * channel. NonTTYRenderer keeps this default (no live zone to manage).
195
+ * TTYRenderer overrides to clear the live zone before writing so
196
+ * log-update's cursor model stays consistent with the actual terminal.
197
+ */
198
+ appendNotice(message) {
199
+ if (this.disposed)
200
+ return;
201
+ this.stdoutWrite(message + "\n");
202
+ }
203
+ dispose() {
204
+ if (this.disposed)
205
+ return;
206
+ this.disposed = true;
207
+ this.onDispose();
208
+ }
209
+ // ------------ State machine ------------
210
+ applyEvent(state, event) {
211
+ const phaseName = event.phase;
212
+ let phase = state.phases.find((p) => p.name === phaseName);
213
+ if (!phase) {
214
+ phase = { name: phaseName, status: "pending" };
215
+ state.phases.push(phase);
216
+ }
217
+ if (event.event === "start") {
218
+ if (state.startedAt === undefined)
219
+ state.startedAt = this.now();
220
+ state.status = "running";
221
+ state.currentPhase = phaseName;
222
+ phase.status = "running";
223
+ phase.startedAt = this.now();
224
+ if (event.iteration !== undefined) {
225
+ phase.loopIteration = event.iteration;
226
+ }
227
+ // Clear any sub-status from a prior phase.
228
+ state.subStatus = undefined;
229
+ return;
230
+ }
231
+ if (event.event === "complete") {
232
+ phase.status = "done";
233
+ if (event.durationSeconds !== undefined) {
234
+ phase.durationMs = event.durationSeconds * 1000;
235
+ }
236
+ else if (phase.startedAt !== undefined) {
237
+ phase.durationMs = this.now() - phase.startedAt;
238
+ }
239
+ if (state.currentPhase === phaseName)
240
+ state.currentPhase = undefined;
241
+ return;
242
+ }
243
+ // failed
244
+ phase.status = "failed";
245
+ if (event.durationSeconds !== undefined) {
246
+ phase.durationMs = event.durationSeconds * 1000;
247
+ }
248
+ else if (phase.startedAt !== undefined) {
249
+ phase.durationMs = this.now() - phase.startedAt;
250
+ }
251
+ state.status = "failed";
252
+ state.completedAt = this.now();
253
+ state.currentPhase = undefined;
254
+ if (event.error !== undefined)
255
+ state.failureReason = event.error;
256
+ // #624 Item 4: update failure dedup metadata on the PHASE (not the issue).
257
+ // Per-phase tracking ensures "same failure as attempt N" only references
258
+ // prior attempts of THIS phase — exec failing with "boom" followed by qa
259
+ // failing with "boom" no longer abbreviates qa as "same failure as attempt 1"
260
+ // when attempt 1 was an exec failure.
261
+ const sig = failureSignature(event.error);
262
+ const currentAttempt = phase.loopIteration ?? 1;
263
+ if (phase.lastFailureSignature !== sig) {
264
+ phase.lastFailureSignature = sig;
265
+ phase.firstAttemptForSignature = currentAttempt;
266
+ }
267
+ }
268
+ /** Mark an issue done after PR is recorded — derived from phase completion. */
269
+ maybeMarkIssueDone(state) {
270
+ if (state.status === "failed")
271
+ return;
272
+ const allTerminal = state.phases.every((p) => p.status === "done" || p.status === "failed");
273
+ if (allTerminal && state.phases.length > 0) {
274
+ state.status = state.phases.some((p) => p.status === "failed")
275
+ ? "failed"
276
+ : "done";
277
+ state.completedAt = this.now();
278
+ }
279
+ }
280
+ // ------------ Hooks for subclasses ------------
281
+ afterEvent(_event, state) {
282
+ if (state.status !== "failed")
283
+ this.maybeMarkIssueDone(state);
284
+ this.afterStateChange();
285
+ }
286
+ afterStateChange() {
287
+ /* default: no-op */
288
+ }
289
+ onPause() {
290
+ /* default: no-op */
291
+ }
292
+ onResume() {
293
+ /* default: no-op */
294
+ }
295
+ onDispose() {
296
+ /* default: no-op */
297
+ }
298
+ }
299
+ // ============================================================================
300
+ // Orchestrator (MCP) renderer — fully suppressed
301
+ // ============================================================================
302
+ /**
303
+ * No-op renderer used when `SEQUANT_ORCHESTRATOR` is set.
304
+ *
305
+ * The orchestrator (e.g. MCP server) consumes `emitProgressLine` JSON from
306
+ * batch-executor directly. Rendering anything else from the CLI would be
307
+ * double-emission. We still track state so `renderSummary` could be useful
308
+ * if explicitly invoked, but neither stdout nor stderr is touched.
309
+ */
310
+ export class OrchestratorRenderer extends BaseRenderer {
311
+ renderSummary() {
312
+ /* AC-18: orchestrator path emits no human-readable summary. */
313
+ }
314
+ }
315
+ // ============================================================================
316
+ // Non-TTY renderer — append-only with timestamps + 60s heartbeat
317
+ // ============================================================================
318
+ export class NonTTYRenderer extends BaseRenderer {
319
+ heartbeatTimer = null;
320
+ heartbeatMs;
321
+ columnsOverride;
322
+ maxLoopIterations;
323
+ lastEventAt;
324
+ constructor(options) {
325
+ super(options);
326
+ this.heartbeatMs =
327
+ options.nonTtyHeartbeatMs ?? DEFAULT_NON_TTY_HEARTBEAT_MS;
328
+ this.columnsOverride = options.columns;
329
+ this.maxLoopIterations =
330
+ options.maxLoopIterations ?? DEFAULT_MAX_LOOP_ITERATIONS;
331
+ this.lastEventAt = this.now();
332
+ this.startHeartbeat();
333
+ }
334
+ getColumns() {
335
+ return (this.columnsOverride ??
336
+ (process.stdout.columns && process.stdout.columns > 0
337
+ ? process.stdout.columns
338
+ : 100));
339
+ }
340
+ startHeartbeat() {
341
+ if (this.heartbeatMs <= 0)
342
+ return;
343
+ this.heartbeatTimer = setInterval(() => this.tickHeartbeat(), this.heartbeatMs);
344
+ if (typeof this.heartbeatTimer.unref === "function") {
345
+ this.heartbeatTimer.unref();
346
+ }
347
+ }
348
+ /** Test hook: drive a heartbeat without waiting on real timers. */
349
+ tickHeartbeatNow() {
350
+ this.tickHeartbeat();
351
+ }
352
+ tickHeartbeat() {
353
+ if (this.disposed || this.paused)
354
+ return;
355
+ if (this.now() - this.lastEventAt < this.heartbeatMs)
356
+ return;
357
+ const running = [...this.issues.values()].filter((s) => s.status === "running" && s.currentPhase);
358
+ if (running.length === 0)
359
+ return;
360
+ const parts = running.map((s) => {
361
+ const elapsedSec = s.startedAt !== undefined ? (this.now() - s.startedAt) / 1000 : 0;
362
+ return `#${s.issueNumber} ${s.currentPhase} (${formatElapsedTime(elapsedSec)})`;
363
+ });
364
+ this.emitLine(`⏱ still running: ${parts.join(", ")}`);
365
+ }
366
+ afterEvent(event, state) {
367
+ super.afterEvent(event, state);
368
+ this.lastEventAt = this.now();
369
+ this.emitEventLine(event, state);
370
+ }
371
+ emitEventLine(event, state) {
372
+ const c = colorize(this.noColor);
373
+ const phase = state.phases.find((p) => p.name === event.phase);
374
+ // #624 Item 3: non-loop phase events on retry get `(attempt N/M)`.
375
+ // The loop phase itself stays unannotated in the events log (the live zone
376
+ // shows `loop N/M · last fail: …` already; double-counting would be noise).
377
+ const retrySuffix = event.phase === "loop"
378
+ ? ""
379
+ : formatRetrySuffix(phase?.loopIteration, this.maxLoopIterations, "events");
380
+ if (event.event === "start") {
381
+ this.emitLine(`${c.cyan("▸")} #${event.issue} ${event.phase}${retrySuffix}`);
382
+ }
383
+ else if (event.event === "complete") {
384
+ const durStr = event.durationSeconds !== undefined
385
+ ? ` ${formatElapsedTime(event.durationSeconds)}`
386
+ : "";
387
+ this.emitLine(`${c.green("✔")} #${event.issue} ${event.phase}${retrySuffix}${durStr}`);
388
+ }
389
+ else {
390
+ // #624 Item 4: failure dedup. The third tier (final attempt) emits the
391
+ // full text even when the signature repeats, so divergent failures stay
392
+ // visible right up to max-iter. Dedup state is per-phase so cross-phase
393
+ // signature collisions don't produce misleading "attempt N" references.
394
+ const attempt = phase?.loopIteration ?? 1;
395
+ const dedup = phase
396
+ ? decideDedup(phase, attempt, this.maxLoopIterations)
397
+ : "full";
398
+ if (dedup === "abbreviated" && phase) {
399
+ this.emitLine(`${c.red("✘")} #${event.issue} ${event.phase}${retrySuffix} (same failure as attempt ${phase.firstAttemptForSignature})`);
400
+ }
401
+ else {
402
+ const errStr = event.error ? ` ${c.red(event.error)}` : "";
403
+ this.emitLine(`${c.red("✘")} #${event.issue} ${event.phase}${retrySuffix}${errStr}`);
404
+ }
405
+ }
406
+ }
407
+ /** Append a single `\n`-terminated line with `[HH:MM:SS]` prefix. */
408
+ emitLine(text) {
409
+ const ts = formatTimestamp(this.wallClock());
410
+ const c = colorize(this.noColor);
411
+ this.stdoutWrite(`${c.dim(`[${ts}]`)} ${text}\n`);
412
+ }
413
+ setPullRequest(issue, prNumber, prUrl) {
414
+ super.setPullRequest(issue, prNumber, prUrl);
415
+ const c = colorize(this.noColor);
416
+ this.emitLine(`${c.green("→")} #${issue} PR #${prNumber} ${c.dim(prUrl)}`);
417
+ }
418
+ renderSummary(input) {
419
+ if (this.disposed)
420
+ return;
421
+ // #624 Item 2 (AC-2.3): share the same column source/cap as the TTY path
422
+ // so the summary table is never rendered at a divergent width that pushes
423
+ // the rightmost border off-screen.
424
+ renderSummaryBlock(input, {
425
+ stdoutWrite: this.stdoutWrite,
426
+ noColor: this.noColor,
427
+ columns: Math.min(this.getColumns(), SUMMARY_COLUMN_CAP),
428
+ });
429
+ }
430
+ onDispose() {
431
+ if (this.heartbeatTimer !== null) {
432
+ clearInterval(this.heartbeatTimer);
433
+ this.heartbeatTimer = null;
434
+ }
435
+ }
436
+ }
437
+ // ============================================================================
438
+ // Failure dedup shared decision (#624 Item 4)
439
+ // ============================================================================
440
+ /**
441
+ * Three-state machine for failure dedup. Returns "abbreviated" when the
442
+ * incoming failure signature matches a prior attempt of THIS phase AND we
443
+ * haven't yet reached the final allowed attempt; otherwise "full" so
444
+ * divergence and last-chance failures stay fully visible in the events log.
445
+ *
446
+ * Per-phase (not per-issue) so cross-phase signature collisions don't produce
447
+ * misleading "same failure as attempt N" text — N would otherwise point at a
448
+ * different phase's attempt.
449
+ */
450
+ function decideDedup(phase, currentAttempt, maxIterations) {
451
+ // `applyEvent` already updated `phase.lastFailureSignature` and
452
+ // `phase.firstAttemptForSignature` before the emit code runs. So we dedup
453
+ // when the first-seen attempt is strictly earlier than the current one.
454
+ const firstAttempt = phase.firstAttemptForSignature;
455
+ if (firstAttempt === undefined || firstAttempt >= currentAttempt) {
456
+ return "full";
457
+ }
458
+ if (currentAttempt >= maxIterations) {
459
+ return "full";
460
+ }
461
+ return "abbreviated";
462
+ }
463
+ export class TTYRenderer extends BaseRenderer {
464
+ liveTickMs;
465
+ liveTimer = null;
466
+ spinnerFrame = 0;
467
+ columnsOverride;
468
+ rowsOverride;
469
+ noSignalListeners;
470
+ stallThresholdMs;
471
+ multiIssueRowCap;
472
+ maxLoopIterations;
473
+ logUpdateImpl;
474
+ logUpdateClear;
475
+ logUpdateDone;
476
+ resizeListener = null;
477
+ banner = null;
478
+ _testStub;
479
+ constructor(options) {
480
+ super(options);
481
+ this.liveTickMs = options.liveTickMs ?? DEFAULT_LIVE_TICK_MS;
482
+ this.columnsOverride = options.columns;
483
+ this.rowsOverride = options.rows;
484
+ this.noSignalListeners = Boolean(options.noSignalListeners);
485
+ // AC-26: stall threshold disabled by default (Number.POSITIVE_INFINITY); the
486
+ // wiring layer derives a real value from settings.run.timeout when available.
487
+ this.stallThresholdMs =
488
+ options.stallThresholdMs ?? Number.POSITIVE_INFINITY;
489
+ this.multiIssueRowCap =
490
+ options.multiIssueRowCap ?? DEFAULT_MULTI_ISSUE_ROW_CAP;
491
+ this.maxLoopIterations =
492
+ options.maxLoopIterations ?? DEFAULT_MAX_LOOP_ITERATIONS;
493
+ // #647 AC-1: render-state instrumentation gated on `SEQUANT_DEBUG_RENDERER=1`.
494
+ // Emits one JSON line per log-update callsite so a production replay shows
495
+ // exactly which mechanism from the #647 issue body is firing (column/row
496
+ // mismatch, wrap-induced row inflation, etc.). The trace doubles as the
497
+ // evidence required by AC-1's "Pick the fix direction from §2 only after
498
+ // instrumentation confirms the mechanism." sub-bullet.
499
+ //
500
+ // #664: routes to a file sink instead of stderr. In any terminal where
501
+ // stdout and stderr share a pty (the normal case), stderr writes scroll
502
+ // the terminal between log-update redraws — log-update has no record of
503
+ // them, so `eraseLines(previousLineCount)` misses rows and the prior
504
+ // frame's top survives in scrollback. The AC-1 capture's "2181×" headline
505
+ // was 2171× of this amplifier, not the underlying #647 bug. Sinking to
506
+ // a file removes the amplifier while preserving identical JSON schema +
507
+ // per-op cadence for diagnostic replay.
508
+ const debugEnabled = process.env.SEQUANT_DEBUG_RENDERER === "1";
509
+ let debugFd = null;
510
+ if (debugEnabled) {
511
+ // Default sink resolves against `process.cwd()` — matches the rest of
512
+ // the codebase's `.sequant/` convention (see `src/lib/relay/paths.ts:39`,
513
+ // `src/lib/ci/config.ts:42`). Invoking `sequant` from a subdirectory
514
+ // puts the file under that subdirectory's `.sequant/`, where the project
515
+ // root's `.sequant/*` gitignore does not reach — pass an absolute
516
+ // override via `SEQUANT_DEBUG_RENDERER_FILE` if that's a concern.
517
+ //
518
+ // `||` not `??`: treat an empty SEQUANT_DEBUG_RENDERER_FILE as "use
519
+ // default" rather than passing "" to openSync (which would throw and
520
+ // suppress all debug output via the fallback path). Locked in by the
521
+ // "AC-2 + empty string" test in scrollback-harness.test.ts.
522
+ const debugPath = process.env.SEQUANT_DEBUG_RENDERER_FILE ||
523
+ path.join(process.cwd(), ".sequant", "debug-renderer.jsonl");
524
+ try {
525
+ fs.mkdirSync(path.dirname(debugPath), { recursive: true });
526
+ debugFd = fs.openSync(debugPath, "a");
527
+ }
528
+ catch (err) {
529
+ // Fall through to no-op rather than crashing the run. One-shot
530
+ // startup notice so the user sees why debug output didn't appear.
531
+ const msg = err instanceof Error ? err.message : String(err);
532
+ this.stderrWrite(`SEQUANT_DEBUG_RENDERER: file sink unavailable at ${debugPath} (${msg}), debug output suppressed\n`);
533
+ debugFd = null;
534
+ }
535
+ }
536
+ let frameCounter = 0;
537
+ const emitDebug = (op, text) => {
538
+ if (debugFd === null)
539
+ return;
540
+ // log-update's render path is roughly:
541
+ // output = wrapAnsi(text + "\n", stream.columns, {trim:false, hard:true})
542
+ // previousLineCount = output.split("\n").length
543
+ // So `previousLineCount` is wrap-aware: a 100-char line in an 80-col
544
+ // stream counts as 2, not 1. We approximate that here using `stringWidth`
545
+ // (already a dep) instead of `text.split("\n").length`. The metric is
546
+ // intentionally an approximation — wrap-ansi has word-breaking nuances
547
+ // — but it's correct enough to spot the diagnostic case AC-1 cares
548
+ // about: when this count diverges from the actual on-terminal row
549
+ // count, log-update's `eraseLines` will undershoot.
550
+ const streamCols = process.stdout.columns ?? this.getColumns() ?? Infinity;
551
+ let logicalLines;
552
+ let wrappedLineCount;
553
+ if (text !== undefined) {
554
+ const lines = text.split("\n");
555
+ logicalLines = lines.length + (text.endsWith("\n") ? 0 : 1);
556
+ wrappedLineCount = lines.reduce((acc, line) => {
557
+ const w = stringWidth(line);
558
+ return acc + Math.max(1, Math.ceil(w / streamCols));
559
+ }, 0);
560
+ // log-update appends a trailing \n before wrapping, so count it.
561
+ if (!text.endsWith("\n"))
562
+ wrappedLineCount++;
563
+ }
564
+ const record = {
565
+ t: this.now() - this.runStartedAt,
566
+ op,
567
+ frame: frameCounter,
568
+ rendererCols: this.getColumns(),
569
+ rendererRows: this.getRows(),
570
+ stdoutCols: process.stdout.columns ?? null,
571
+ stdoutRows: process.stdout.rows ?? null,
572
+ logicalLines,
573
+ wrappedLineCount,
574
+ };
575
+ // Sync append. `O_APPEND` guarantees atomic per-line writes on POSIX,
576
+ // and the fd lives for the process lifetime — no close on dispose
577
+ // because late-fire callbacks could still emit after teardown begins.
578
+ fs.writeSync(debugFd, `SEQUANT_DEBUG_RENDERER ${JSON.stringify(record)}\n`);
579
+ };
580
+ // log-update writes to process.stdout via a mutable global instance. When
581
+ // tests inject `stdoutWrite`, route renders through it instead so capture
582
+ // works deterministically. The #647 harness tests instead inject a real
583
+ // `log-update` instance bound to a virtual terminal — that path bypasses
584
+ // the stub so we can assert on actual cursor/erase semantics.
585
+ if (options.logUpdateInstance) {
586
+ // #647: harness path — drive a real `createLogUpdate(stream)` instance
587
+ // so the scrollback-aware regression test sees the same ANSI cursor
588
+ // operations a production user's terminal would receive. Stub is left
589
+ // null because the harness asserts on the VirtualTerminal directly.
590
+ const lu = options.logUpdateInstance;
591
+ this._testStub = null;
592
+ this.logUpdateImpl = (text) => {
593
+ frameCounter++;
594
+ emitDebug("impl", text);
595
+ lu(text);
596
+ };
597
+ this.logUpdateClear = () => {
598
+ emitDebug("clear");
599
+ lu.clear();
600
+ };
601
+ this.logUpdateDone = () => {
602
+ emitDebug("done");
603
+ lu.done();
604
+ };
605
+ }
606
+ else if (options.stdoutWrite) {
607
+ // #624 Derived AC-D1: replacement-aware test stub. Tracks each frame
608
+ // replacement so tests can assert on frame churn without parsing buf.out.
609
+ // `clearCalls` / `doneCalls` verify the renderer actually invokes the
610
+ // teardown methods (not just resets local state).
611
+ const stub = {
612
+ replacementCount: 0,
613
+ lastFrame: "",
614
+ clearCalls: 0,
615
+ doneCalls: 0,
616
+ };
617
+ this._testStub = stub;
618
+ this.logUpdateImpl = (text) => {
619
+ frameCounter++;
620
+ emitDebug("impl", text);
621
+ if (stub.lastFrame)
622
+ stub.replacementCount++;
623
+ stub.lastFrame = text;
624
+ options.stdoutWrite(text + "\n");
625
+ };
626
+ this.logUpdateClear = () => {
627
+ emitDebug("clear");
628
+ stub.clearCalls++;
629
+ stub.lastFrame = "";
630
+ };
631
+ this.logUpdateDone = () => {
632
+ emitDebug("done");
633
+ stub.doneCalls++;
634
+ stub.lastFrame = "";
635
+ };
636
+ }
637
+ else {
638
+ this._testStub = null;
639
+ this.logUpdateImpl = (text) => {
640
+ frameCounter++;
641
+ emitDebug("impl", text);
642
+ logUpdate(text);
643
+ };
644
+ this.logUpdateClear = () => {
645
+ emitDebug("clear");
646
+ logUpdate.clear();
647
+ };
648
+ this.logUpdateDone = () => {
649
+ emitDebug("done");
650
+ logUpdate.done();
651
+ };
652
+ }
653
+ this.startLiveTimer();
654
+ this.installSignalListeners();
655
+ }
656
+ /**
657
+ * #624 Derived AC-D1: expose the test-only log-update stub. Returns `null`
658
+ * when not in test mode (production renders go through real `log-update`).
659
+ *
660
+ * #647 AC-D3 warning: this stub does NOT model `log-update`'s ANSI cursor
661
+ * or scrollback semantics. Tests that assert on `stub.lastFrame` only see
662
+ * the most recent frame, not whether earlier frames remained stranded in
663
+ * scrollback. Header-count / duplicate-header assertions MUST use
664
+ * `scrollback-harness.ts` (real `createLogUpdate` + VirtualTerminal),
665
+ * otherwise they will pass green even when the production rendering is
666
+ * broken — see #624 for the precedent.
667
+ */
668
+ getTestStub() {
669
+ return this._testStub;
670
+ }
671
+ startLiveTimer() {
672
+ if (this.liveTickMs <= 0)
673
+ return;
674
+ this.liveTimer = setInterval(() => this.tick(), this.liveTickMs);
675
+ if (typeof this.liveTimer.unref === "function") {
676
+ this.liveTimer.unref();
677
+ }
678
+ }
679
+ installSignalListeners() {
680
+ if (this.noSignalListeners)
681
+ return;
682
+ this.resizeListener = () => this.redraw();
683
+ process.on("SIGWINCH", this.resizeListener);
684
+ }
685
+ /** Test hook: drive a tick without waiting on real timers. */
686
+ tickNow() {
687
+ this.tick();
688
+ }
689
+ tick() {
690
+ if (this.disposed || this.paused)
691
+ return;
692
+ this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
693
+ this.redraw();
694
+ }
695
+ afterEvent(event, state) {
696
+ super.afterEvent(event, state);
697
+ if (this.paused || this.disposed)
698
+ return;
699
+ // Append the event line first (above the live zone in append order),
700
+ // then redraw the live zone below it.
701
+ this.appendEventLine(event, state);
702
+ this.redraw();
703
+ }
704
+ afterStateChange() {
705
+ if (this.paused || this.disposed)
706
+ return;
707
+ this.redraw();
708
+ }
709
+ /** Set a banner that renders above the live grid (e.g. worktree-loss). */
710
+ setBanner(text) {
711
+ this.banner = text;
712
+ this.redraw();
713
+ }
714
+ appendEventLine(event, state) {
715
+ // #672 AC-1: drop the `▸ start` journal line. The live zone already shows
716
+ // the phase as running in place, so appending a permanent scrollback line
717
+ // duplicates that information and produces the "two-row" visual reported
718
+ // in #672. `complete` and `failed` still append (they are the durable
719
+ // record of what ran). The redraw in `afterEvent` keeps the live zone
720
+ // fresh so the transition pending → running is still visible.
721
+ if (event.event === "start")
722
+ return;
723
+ // Clear the live zone so the appended event becomes a real `console.log`
724
+ // line above it; the live zone redraws below.
725
+ this.logUpdateClear();
726
+ const c = colorize(this.noColor);
727
+ const phase = state.phases.find((p) => p.name === event.phase);
728
+ // #624 Item 3: shared retry-suffix helper. The `loop` phase has its own
729
+ // running indicator in the live zone, so we don't double-annotate it here.
730
+ const retrySuffix = event.phase === "loop"
731
+ ? ""
732
+ : formatRetrySuffix(phase?.loopIteration, this.maxLoopIterations, "events");
733
+ let line;
734
+ if (event.event === "complete") {
735
+ const durStr = event.durationSeconds !== undefined
736
+ ? ` ${formatElapsedTime(event.durationSeconds)}`
737
+ : "";
738
+ const prSuffix = state.prUrl ? ` → PR #${state.prNumber}` : "";
739
+ line = ` ${c.green("✔")} #${event.issue} ${event.phase}${retrySuffix}${durStr}${prSuffix}`;
740
+ }
741
+ else {
742
+ // #624 Item 4: failure dedup. Abbreviated form only fires when the
743
+ // signature matches a *prior* attempt of THIS phase and we are not at
744
+ // the final allowed iteration (preserves divergence visibility).
745
+ const attempt = phase?.loopIteration ?? 1;
746
+ const dedup = phase
747
+ ? decideDedup(phase, attempt, this.maxLoopIterations)
748
+ : "full";
749
+ if (dedup === "abbreviated" && phase) {
750
+ line = ` ${c.red("✘")} #${event.issue} ${event.phase}${retrySuffix} (same failure as attempt ${phase.firstAttemptForSignature})`;
751
+ }
752
+ else {
753
+ const errStr = event.error ? ` ${c.red(event.error)}` : "";
754
+ line = ` ${c.red("✘")} #${event.issue} ${event.phase}${retrySuffix}${errStr}`;
755
+ }
756
+ }
757
+ this.stdoutWrite(line + "\n");
758
+ }
759
+ setPullRequest(issue, prNumber, prUrl) {
760
+ super.setPullRequest(issue, prNumber, prUrl);
761
+ if (this.paused || this.disposed)
762
+ return;
763
+ this.logUpdateClear();
764
+ const c = colorize(this.noColor);
765
+ this.stdoutWrite(` ${c.green("→")} #${issue} PR #${prNumber} ${c.dim(prUrl)}\n`);
766
+ this.redraw();
767
+ }
768
+ renderSummary(input) {
769
+ if (this.disposed)
770
+ return;
771
+ // #624 Item 2: tear down the live zone *before* writing the summary so any
772
+ // subsequent `console.log` from displaySummary (reflection block, merge
773
+ // tip) cannot overlap with the trailing border of the summary table.
774
+ this.logUpdateClear();
775
+ this.logUpdateDone();
776
+ if (this.liveTimer !== null) {
777
+ clearInterval(this.liveTimer);
778
+ this.liveTimer = null;
779
+ }
780
+ // #624 Item 2 (AC-2.3): clamp summary columns to SUMMARY_COLUMN_CAP so wide
781
+ // terminals don't produce a grid that overflows narrower readers (CI logs,
782
+ // VS Code terminal panes).
783
+ renderSummaryBlock(input, {
784
+ stdoutWrite: this.stdoutWrite,
785
+ noColor: this.noColor,
786
+ columns: Math.min(this.getColumns(), SUMMARY_COLUMN_CAP),
787
+ });
788
+ }
789
+ onPause() {
790
+ // Clear the live zone so verbose streaming has clean stdout.
791
+ this.logUpdateClear();
792
+ }
793
+ onResume() {
794
+ // Live zone redraws on next tick / event automatically.
795
+ this.redraw();
796
+ }
797
+ /**
798
+ * #647 AC-3: TTYRenderer override. Writes the notice above the live zone
799
+ * the same way `appendEventLine` does (clear → write → redraw), so
800
+ * log-update's `previousLineCount` stays consistent with the actual
801
+ * terminal state. If the renderer is already paused (e.g., during
802
+ * verbose subprocess streaming), skip the clear/redraw and just write;
803
+ * the eventual `resume()` will redraw cleanly.
804
+ */
805
+ appendNotice(message) {
806
+ if (this.disposed)
807
+ return;
808
+ if (this.paused) {
809
+ this.stdoutWrite(message + "\n");
810
+ return;
811
+ }
812
+ this.logUpdateClear();
813
+ this.stdoutWrite(message + "\n");
814
+ this.redraw();
815
+ }
816
+ onDispose() {
817
+ if (this.liveTimer !== null) {
818
+ clearInterval(this.liveTimer);
819
+ this.liveTimer = null;
820
+ }
821
+ if (this.resizeListener) {
822
+ process.removeListener("SIGWINCH", this.resizeListener);
823
+ this.resizeListener = null;
824
+ }
825
+ this.logUpdateClear();
826
+ this.logUpdateDone();
827
+ }
828
+ // ---------------- Layout ----------------
829
+ getColumns() {
830
+ return (this.columnsOverride ??
831
+ (process.stdout.columns && process.stdout.columns > 0
832
+ ? process.stdout.columns
833
+ : 100));
834
+ }
835
+ /**
836
+ * #624 Item 1: terminal row count, with a safe default for piped / detached
837
+ * stdout where `process.stdout.rows` is undefined.
838
+ */
839
+ getRows() {
840
+ if (this.rowsOverride !== undefined)
841
+ return this.rowsOverride;
842
+ const r = process.stdout.rows;
843
+ return r && r > 0 ? r : DEFAULT_TERMINAL_ROWS;
844
+ }
845
+ /**
846
+ * #624 Item 1: hard ceiling on live-zone height. The cap is
847
+ * `max(8, rows - 5)`, dropping to `max(8, rows - 7)` when a banner is active
848
+ * so the banner + a few separator rows still fit. The floor of 8 prevents
849
+ * the live zone from collapsing on tiny terminals.
850
+ */
851
+ getMaxLiveRows() {
852
+ const reservation = this.banner ? 7 : 5;
853
+ return Math.max(8, this.getRows() - reservation);
854
+ }
855
+ redraw() {
856
+ if (this.disposed || this.paused)
857
+ return;
858
+ const cols = this.getColumns();
859
+ const text = this.renderLiveFrame(cols);
860
+ if (!text) {
861
+ this.logUpdateClear();
862
+ return;
863
+ }
864
+ this.logUpdateImpl(text);
865
+ }
866
+ /** Public for tests — render the live zone to a string without emitting. */
867
+ renderLiveFrame(columns) {
868
+ const cols = columns ?? this.getColumns();
869
+ if (this.issues.size === 0)
870
+ return "";
871
+ const text = cols < NARROW_TERMINAL_THRESHOLD
872
+ ? this.renderNarrowFrame()
873
+ : this.issues.size === 1
874
+ ? this.renderSingleIssueFrame(cols)
875
+ : this.renderMultiIssueFrame(cols);
876
+ return this.clampFrameHeight(text);
877
+ }
878
+ /**
879
+ * #624 Item 1 (AC-1.1): hard ceiling on rendered frame height. The interior
880
+ * `applyRowCap` already collapses excess issues into the rollup row, but the
881
+ * frame can still drift over the cap if many issues have multi-line status
882
+ * cells (sub-status + phase sequence). This is the belt-and-braces clamp
883
+ * that guarantees `log-update` never sees a frame taller than the terminal.
884
+ *
885
+ * Always engages — when `rows` isn't explicitly provided, `getRows()` returns
886
+ * `DEFAULT_TERMINAL_ROWS` (100) so the cap is generous but never disengaged.
887
+ */
888
+ clampFrameHeight(text) {
889
+ const lines = text.split("\n");
890
+ const maxRows = this.getMaxLiveRows();
891
+ if (lines.length <= maxRows)
892
+ return text;
893
+ const c = colorize(this.noColor);
894
+ // Reserve the last visible row for an overflow indicator so the cap is
895
+ // observable from the rendered output.
896
+ const truncated = lines.slice(0, Math.max(1, maxRows - 1));
897
+ const hidden = lines.length - truncated.length;
898
+ truncated.push(c.dim(` … ${hidden} more line${hidden === 1 ? "" : "s"} (terminal too short)`));
899
+ return truncated.join("\n");
900
+ }
901
+ renderNarrowFrame() {
902
+ // AC-25: <80 columns → indented key:value pairs, no box-drawing.
903
+ const c = colorize(this.noColor);
904
+ const header = `SEQUANT WORKFLOW · ${this.runHeader()}`;
905
+ const lines = [c.bold(header), ""];
906
+ if (this.banner)
907
+ lines.push(c.yellow(this.banner), "");
908
+ const { rolledUpDoneCount, visibleStates, totalCount } = this.applyRowCap();
909
+ if (rolledUpDoneCount > 0) {
910
+ lines.push(` ${c.green(`✔ ${rolledUpDoneCount} done`)}`);
911
+ }
912
+ for (const state of visibleStates) {
913
+ lines.push(` #${state.issueNumber} ${this.statusHeader(state)}`);
914
+ const subLines = this.statusSubLines(state);
915
+ for (const line of subLines)
916
+ lines.push(` ${line}`);
917
+ }
918
+ if (rolledUpDoneCount > 0) {
919
+ lines.push("", c.dim(` (${visibleStates.length} of ${totalCount} shown)`));
920
+ }
921
+ lines.push("", this.rollupLine());
922
+ return lines.join("\n");
923
+ }
924
+ renderSingleIssueFrame(cols) {
925
+ // AC-11: Single-issue runs use a key:value full-grid table.
926
+ const state = [...this.issues.values()][0];
927
+ const c = colorize(this.noColor);
928
+ const header = `SEQUANT WORKFLOW · #${state.issueNumber} · ${formatElapsedTime((this.now() - this.runStartedAt) / 1000)} elapsed`;
929
+ // #647 AC-3: cap at 78 (not 110) so the rendered grid stays narrower than
930
+ // any standard 80-col terminal even when the reported `cols` is wider than
931
+ // the actual terminal (e.g. cached `process.stdout.columns`, TTY emulation
932
+ // layers, `npx` piped stdout). Total drawn width is
933
+ // `labelWidth + valueWidth + 9` (2 leading spaces + 3 box-drawing
934
+ // intersection chars + 4 cell-padding spaces); the prior `- 7` formula
935
+ // additionally produced rows 2 chars wider than `cols`, compounding the
936
+ // wrap. Both errors removed.
937
+ const labelWidth = 10;
938
+ const innerWidth = Math.max(40, Math.min(cols, 78) - labelWidth - 9);
939
+ const valueWidth = innerWidth;
940
+ const rows = [];
941
+ const titleSuffix = state.title ? ` — ${state.title}` : "";
942
+ rows.push([
943
+ "Issue",
944
+ [`#${state.issueNumber}${titleSuffix}`.slice(0, valueWidth)],
945
+ ]);
946
+ if (state.worktreePath) {
947
+ rows.push(["Worktree", [truncate(state.worktreePath, valueWidth)]]);
948
+ }
949
+ if (state.branch) {
950
+ rows.push(["Branch", [truncate(state.branch, valueWidth)]]);
951
+ }
952
+ rows.push(["Status", this.statusCellLines(state)]);
953
+ const lines = [c.bold(header), ""];
954
+ if (this.banner)
955
+ lines.push(c.yellow(this.banner), "");
956
+ lines.push(this.drawKeyValueTable(rows, labelWidth, valueWidth));
957
+ return lines.join("\n");
958
+ }
959
+ renderMultiIssueFrame(cols) {
960
+ // AC-5/6/7: per-issue grid with active expanded, done collapsed.
961
+ // AC-28: when total issues exceed the row cap, oldest done issues collapse
962
+ // into a single `✔ {N} done` row at the top and a `(M of N shown)` indicator
963
+ // is appended.
964
+ const c = colorize(this.noColor);
965
+ const header = `SEQUANT WORKFLOW · ${this.runHeader()}`;
966
+ // #647 AC-3: see note in `renderSingleIssueFrame` — cap at 78 (not 110) so
967
+ // the rendered grid stays narrower than any standard 80-col terminal under
968
+ // width-misreporting conditions. The box-drawing total is
969
+ // `issueColW + statusColW + 9`; the prior `- 7` formula compounded the
970
+ // overflow.
971
+ const issueColW = 8;
972
+ const innerWidth = Math.max(50, Math.min(cols, 78) - issueColW - 9);
973
+ const statusColW = innerWidth;
974
+ const lines = [c.bold(header), ""];
975
+ if (this.banner)
976
+ lines.push(c.yellow(this.banner), "");
977
+ const { rolledUpDoneCount, visibleStates, totalCount } = this.applyRowCap();
978
+ const rows = [];
979
+ if (rolledUpDoneCount > 0) {
980
+ rows.push({
981
+ issueLabel: c.green(`✔ ${rolledUpDoneCount}`),
982
+ statusLines: [c.green(`${rolledUpDoneCount} done · rolled up`)],
983
+ });
984
+ }
985
+ for (const state of visibleStates) {
986
+ rows.push({
987
+ issueLabel: `#${state.issueNumber}`,
988
+ statusLines: this.statusCellLines(state),
989
+ });
990
+ }
991
+ lines.push(this.drawIssueGrid(rows, issueColW, statusColW));
992
+ lines.push("");
993
+ if (rolledUpDoneCount > 0) {
994
+ lines.push(c.dim(` (${visibleStates.length} of ${totalCount} shown)`));
995
+ }
996
+ lines.push(" " + this.rollupLine());
997
+ return lines.join("\n");
998
+ }
999
+ /**
1000
+ * AC-28 + #624 Item 1: enforce the per-frame issue row cap, derived from
1001
+ * the smaller of `multiIssueRowCap` (static config) and a dynamic ceiling
1002
+ * computed from terminal height. The dynamic ceiling reserves ~3 lines per
1003
+ * issue (status header + sub-status + separator) and a fixed overhead for
1004
+ * the frame header, blank lines, grid borders, and the rollup line.
1005
+ *
1006
+ * If the cap is not exceeded, returns all issues unchanged. Otherwise: keep
1007
+ * all non-done rows, then fill remaining slots with the most recently
1008
+ * completed done rows; older done rows roll up into a single summary entry.
1009
+ */
1010
+ applyRowCap() {
1011
+ const all = [...this.issues.values()];
1012
+ const totalCount = all.length;
1013
+ const cap = this.effectiveRowCap();
1014
+ if (totalCount <= cap) {
1015
+ return { rolledUpDoneCount: 0, visibleStates: all, totalCount };
1016
+ }
1017
+ const active = all.filter((s) => s.status !== "done");
1018
+ const done = all
1019
+ .filter((s) => s.status === "done")
1020
+ .sort((a, b) => (b.completedAt ?? 0) - (a.completedAt ?? 0));
1021
+ // Reserve one row for the rollup line, leave the rest for visible issues.
1022
+ const visibleSlots = Math.max(1, cap - 1);
1023
+ const remainingSlotsForDone = Math.max(0, visibleSlots - active.length);
1024
+ const visibleDone = done.slice(0, remainingSlotsForDone);
1025
+ const rolledUpDoneCount = done.length - visibleDone.length;
1026
+ return {
1027
+ rolledUpDoneCount,
1028
+ visibleStates: [...active, ...visibleDone],
1029
+ totalCount,
1030
+ };
1031
+ }
1032
+ /**
1033
+ * #624 Item 1 (AC-1.1): smaller of the configured static cap and the
1034
+ * dynamic terminal-height-derived cap. Each issue row takes roughly 3 grid
1035
+ * lines (header, sub-status, separator); the fixed overhead covers the
1036
+ * frame title, blank lines, grid borders, and the rollup row.
1037
+ *
1038
+ * Always engages — when `rows` isn't explicitly provided, `getRows()` returns
1039
+ * `DEFAULT_TERMINAL_ROWS` (100) which produces a dynamic cap of ~30, well
1040
+ * above the static `multiIssueRowCap` default of 10, so the static cap stays
1041
+ * in charge for normal-height terminals.
1042
+ */
1043
+ effectiveRowCap() {
1044
+ const LINES_PER_ISSUE = 3;
1045
+ const FIXED_OVERHEAD = 8;
1046
+ const maxLiveRows = this.getMaxLiveRows();
1047
+ const dynamicCap = Math.max(2, Math.floor((maxLiveRows - FIXED_OVERHEAD) / LINES_PER_ISSUE));
1048
+ return Math.min(this.multiIssueRowCap, dynamicCap);
1049
+ }
1050
+ // ---------------- Per-issue status content ----------------
1051
+ statusHeader(state) {
1052
+ const c = colorize(this.noColor);
1053
+ if (state.status === "done") {
1054
+ const total = state.startedAt !== undefined && state.completedAt !== undefined
1055
+ ? formatElapsedTime((state.completedAt - state.startedAt) / 1000)
1056
+ : "";
1057
+ const phaseSeq = state.phases
1058
+ .map((p) => p.name)
1059
+ .filter((n) => n !== "loop")
1060
+ .join("→");
1061
+ const prSuffix = state.prNumber ? ` · PR #${state.prNumber}` : "";
1062
+ return c.green(`✔ done · ${total} · ${phaseSeq}${prSuffix}`);
1063
+ }
1064
+ if (state.status === "failed") {
1065
+ const total = state.startedAt !== undefined
1066
+ ? formatElapsedTime(((state.completedAt ?? this.now()) - state.startedAt) / 1000)
1067
+ : "";
1068
+ return c.red(`✘ failed${total ? ` · ${total}` : ""}${state.failureReason ? ` · ${state.failureReason}` : ""}`);
1069
+ }
1070
+ if (state.status === "queued") {
1071
+ return c.gray("· queued");
1072
+ }
1073
+ // running
1074
+ const cur = state.currentPhase ?? "starting";
1075
+ const phase = state.phases.find((p) => p.name === cur);
1076
+ const elapsedMs = phase?.startedAt !== undefined ? this.now() - phase.startedAt : 0;
1077
+ const elapsed = formatElapsedTime(elapsedMs / 1000);
1078
+ const spinner = SPINNER_FRAMES[this.spinnerFrame];
1079
+ // AC-23: in auto-detect mode, render `Phase: detecting…` while spec is
1080
+ // running and no other phase has started yet (no resolved plan known).
1081
+ if (state.autoDetect &&
1082
+ cur === "spec" &&
1083
+ phase?.status === "running" &&
1084
+ state.phases.length === 1) {
1085
+ return c.cyan(`${spinner} Phase: detecting… · ${elapsed}`);
1086
+ }
1087
+ // AC-26: when a phase has been running past the stall threshold, flip the
1088
+ // status header to a yellow stalled marker. The phase keeps ticking; this
1089
+ // is informational only.
1090
+ if (elapsedMs > this.stallThresholdMs) {
1091
+ return c.yellow(`⚠ stalled · ${cur} · ${elapsed}`);
1092
+ }
1093
+ // #624 Item 3 (AC-3.3): while in the `loop` phase, surface both the loop
1094
+ // iteration counter and the last failure reason so the user can see what
1095
+ // the loop is reacting to. The first loop iteration starts at 1/M.
1096
+ if (cur === "loop") {
1097
+ const iter = phase?.loopIteration ?? 1;
1098
+ const failReason = state.failureReason
1099
+ ? ` · last fail: ${truncate(state.failureReason, FAIL_REASON_TRUNCATE_LENGTH)}`
1100
+ : "";
1101
+ return c.cyan(`${spinner} loop ${iter}/${this.maxLoopIterations}${failReason} · ${elapsed}`);
1102
+ }
1103
+ // #624 Derived AC-D2: shared retry-suffix helper. Eliminates the hardcoded
1104
+ // `/3` literal so `maxLoopIterations` from settings flows through.
1105
+ const loopLabel = formatRetrySuffix(phase?.loopIteration, this.maxLoopIterations, "header");
1106
+ const loopPrefix = loopLabel ? ` loop${loopLabel}` : "";
1107
+ return c.cyan(`${spinner} ${cur}${loopPrefix} · ${elapsed}`);
1108
+ }
1109
+ statusSubLines(state) {
1110
+ const c = colorize(this.noColor);
1111
+ const lines = [];
1112
+ // #672 AC-3: include the failed state so the row that just failed still
1113
+ // renders its phase cells (the failing cell shows ✘ in place). Without
1114
+ // this, a failure on an unstarted phase hides the entire pipeline behind
1115
+ // the header summary, making it impossible to see how far the run got.
1116
+ if (state.status === "running" ||
1117
+ state.status === "queued" ||
1118
+ state.status === "failed") {
1119
+ const seq = state.phases
1120
+ .filter((p) => p.name !== "loop")
1121
+ .map((p) => {
1122
+ if (p.status === "done")
1123
+ return c.green(`${p.name} ✔${p.durationMs ? ` ${formatElapsedTime(p.durationMs / 1000)}` : ""}`);
1124
+ if (p.status === "failed")
1125
+ return c.red(`${p.name} ✘`);
1126
+ if (p.status === "running")
1127
+ return c.cyan(`${p.name} running`);
1128
+ // #672 AC-3: pending cells render as `name –` (en dash) so the live
1129
+ // zone reads as a roadmap when a phase plan is set via registration
1130
+ // or `setPhasePlan`. Without a plan, no pending cells are seeded so
1131
+ // this branch is unreachable — preserving prior single-row output.
1132
+ return c.gray(`${p.name} –`);
1133
+ })
1134
+ .join(" → ");
1135
+ if (seq)
1136
+ lines.push(seq);
1137
+ if (state.subStatus)
1138
+ lines.push(c.dim(state.subStatus));
1139
+ }
1140
+ return lines;
1141
+ }
1142
+ statusCellLines(state) {
1143
+ if (state.status === "done")
1144
+ return [this.statusHeader(state)];
1145
+ return [this.statusHeader(state), ...this.statusSubLines(state)];
1146
+ }
1147
+ // ---------------- Rollup / header ----------------
1148
+ runHeader() {
1149
+ const elapsed = formatElapsedTime((this.now() - this.runStartedAt) / 1000);
1150
+ const issues = this.issues.size;
1151
+ return `${issues} issue${issues === 1 ? "" : "s"} · ${elapsed}`;
1152
+ }
1153
+ rollupLine() {
1154
+ const c = colorize(this.noColor);
1155
+ let done = 0, running = 0, queued = 0, failed = 0;
1156
+ for (const s of this.issues.values()) {
1157
+ switch (s.status) {
1158
+ case "done":
1159
+ done++;
1160
+ break;
1161
+ case "running":
1162
+ running++;
1163
+ break;
1164
+ case "queued":
1165
+ queued++;
1166
+ break;
1167
+ case "failed":
1168
+ failed++;
1169
+ break;
1170
+ }
1171
+ }
1172
+ return c.dim(`${done} done · ${running} running · ${queued} queued · ${failed} failed`);
1173
+ }
1174
+ // ---------------- Box drawing ----------------
1175
+ drawKeyValueTable(rows, labelW, valueW) {
1176
+ const c = colorize(this.noColor);
1177
+ const dim = c.dim;
1178
+ const total = labelW + valueW + 3;
1179
+ const top = dim(" ┌" + "─".repeat(labelW + 2) + "┬" + "─".repeat(valueW + 2) + "┐");
1180
+ const sep = dim(" ├" + "─".repeat(labelW + 2) + "┼" + "─".repeat(valueW + 2) + "┤");
1181
+ const bottom = dim(" └" + "─".repeat(labelW + 2) + "┴" + "─".repeat(valueW + 2) + "┘");
1182
+ const out = [top];
1183
+ rows.forEach(([label, lines], i) => {
1184
+ const labelPadded = padEndVisible(label, labelW);
1185
+ lines.forEach((line, idx) => {
1186
+ const labelCell = idx === 0 ? c.cyan(labelPadded) : " ".repeat(labelW);
1187
+ const valuePadded = padEndVisible(line, valueW);
1188
+ out.push(` ${dim("│")} ${labelCell} ${dim("│")} ${valuePadded} ${dim("│")}`);
1189
+ });
1190
+ if (i < rows.length - 1)
1191
+ out.push(sep);
1192
+ });
1193
+ out.push(bottom);
1194
+ void total;
1195
+ return out.join("\n");
1196
+ }
1197
+ drawIssueGrid(rows, issueW, statusW) {
1198
+ const c = colorize(this.noColor);
1199
+ const dim = c.dim;
1200
+ const top = dim(" ┌" + "─".repeat(issueW + 2) + "┬" + "─".repeat(statusW + 2) + "┐");
1201
+ const sep = dim(" ├" + "─".repeat(issueW + 2) + "┼" + "─".repeat(statusW + 2) + "┤");
1202
+ const bottom = dim(" └" + "─".repeat(issueW + 2) + "┴" + "─".repeat(statusW + 2) + "┘");
1203
+ const headerRow = ` ${dim("│")} ${c.cyan(padEndVisible("Issue", issueW))} ${dim("│")} ${c.cyan(padEndVisible("Status", statusW))} ${dim("│")}`;
1204
+ const out = [top, headerRow, sep];
1205
+ rows.forEach((row, i) => {
1206
+ row.statusLines.forEach((line, idx) => {
1207
+ const issueCell = idx === 0
1208
+ ? padEndVisible(row.issueLabel, issueW)
1209
+ : " ".repeat(issueW);
1210
+ const statusPadded = padEndVisible(line, statusW);
1211
+ out.push(` ${dim("│")} ${issueCell} ${dim("│")} ${statusPadded} ${dim("│")}`);
1212
+ });
1213
+ if (i < rows.length - 1)
1214
+ out.push(sep);
1215
+ });
1216
+ out.push(bottom);
1217
+ return out.join("\n");
1218
+ }
1219
+ }
1220
+ function renderSummaryBlock(input, ctx) {
1221
+ const { issues } = input;
1222
+ if (issues.length === 0 && !input.dryRun)
1223
+ return;
1224
+ const c = colorize(ctx.noColor);
1225
+ const passed = issues.filter((i) => i.success).length;
1226
+ const failed = issues.filter((i) => !i.success).length;
1227
+ const totalStr = input.totalDurationSeconds !== undefined
1228
+ ? ` · ${formatElapsedTime(input.totalDurationSeconds)}`
1229
+ : "";
1230
+ const out = [];
1231
+ out.push("");
1232
+ out.push(c.bold(`SUMMARY · ${issues.length} issue${issues.length === 1 ? "" : "s"}${totalStr} · ${passed} passed · ${failed} failed`));
1233
+ out.push("");
1234
+ if (ctx.columns < NARROW_TERMINAL_THRESHOLD) {
1235
+ // AC-25 fallback: indented key:value pairs.
1236
+ for (const r of issues) {
1237
+ const status = r.success ? c.green("✔ passed") : c.red("✘ failed");
1238
+ const detail = renderSummaryDetail(r, ctx);
1239
+ out.push(` ${status} #${r.issueNumber} ${detail.summary}`);
1240
+ for (const extra of detail.extras)
1241
+ out.push(` ${extra}`);
1242
+ if (r.durationSeconds !== undefined) {
1243
+ out.push(` ${c.dim(formatElapsedTime(r.durationSeconds))}`);
1244
+ }
1245
+ }
1246
+ }
1247
+ else {
1248
+ out.push(renderSummaryGrid(issues, ctx));
1249
+ }
1250
+ out.push("");
1251
+ out.push(` ${c.green(`${passed} passed`)} · ${c.red(`${failed} failed`)}`);
1252
+ if (input.logPath) {
1253
+ out.push(` ${c.dim(`Log: ${input.logPath}`)}`);
1254
+ }
1255
+ out.push("");
1256
+ ctx.stdoutWrite(out.join("\n"));
1257
+ }
1258
+ function renderSummaryDetail(r, ctx) {
1259
+ const c = colorize(ctx.noColor);
1260
+ if (r.success) {
1261
+ const phaseSeq = r.phases
1262
+ .map((p) => (p.success ? c.green(p.name) : c.red(p.name)))
1263
+ .join(" → ");
1264
+ const pr = r.prNumber ? ` · PR #${r.prNumber}` : "";
1265
+ return { summary: `${phaseSeq}${pr}`, extras: [] };
1266
+ }
1267
+ // Failed → multi-line detail.
1268
+ const reason = r.failureReason ?? "failure";
1269
+ const extras = [];
1270
+ if (r.qaVerdict) {
1271
+ const unmet = r.unmetCount !== undefined ? ` (${r.unmetCount} unmet)` : "";
1272
+ extras.push(c.red(`${r.qaVerdict}${unmet}`));
1273
+ }
1274
+ return { summary: c.red(reason), extras };
1275
+ }
1276
+ function renderSummaryGrid(issues, ctx) {
1277
+ const c = colorize(ctx.noColor);
1278
+ const dim = c.dim;
1279
+ const issueW = 8;
1280
+ const resultW = 10;
1281
+ const totalW = 10;
1282
+ const detailW = Math.max(28, Math.min(ctx.columns, 100) - issueW - resultW - totalW - 11);
1283
+ const top = dim(" ┌" +
1284
+ "─".repeat(issueW + 2) +
1285
+ "┬" +
1286
+ "─".repeat(resultW + 2) +
1287
+ "┬" +
1288
+ "─".repeat(detailW + 2) +
1289
+ "┬" +
1290
+ "─".repeat(totalW + 2) +
1291
+ "┐");
1292
+ const sep = dim(" ├" +
1293
+ "─".repeat(issueW + 2) +
1294
+ "┼" +
1295
+ "─".repeat(resultW + 2) +
1296
+ "┼" +
1297
+ "─".repeat(detailW + 2) +
1298
+ "┼" +
1299
+ "─".repeat(totalW + 2) +
1300
+ "┤");
1301
+ const bottom = dim(" └" +
1302
+ "─".repeat(issueW + 2) +
1303
+ "┴" +
1304
+ "─".repeat(resultW + 2) +
1305
+ "┴" +
1306
+ "─".repeat(detailW + 2) +
1307
+ "┴" +
1308
+ "─".repeat(totalW + 2) +
1309
+ "┘");
1310
+ 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("│")}`;
1311
+ const out = [top, headerRow, sep];
1312
+ issues.forEach((r, i) => {
1313
+ const result = r.success ? c.green("✔ passed") : c.red("✘ failed");
1314
+ const detail = renderSummaryDetail(r, ctx);
1315
+ const detailLines = [detail.summary, ...detail.extras];
1316
+ const total = r.durationSeconds !== undefined
1317
+ ? formatElapsedTime(r.durationSeconds)
1318
+ : "";
1319
+ detailLines.forEach((line, idx) => {
1320
+ const issueCell = idx === 0
1321
+ ? padEndVisible(`#${r.issueNumber}`, issueW)
1322
+ : " ".repeat(issueW);
1323
+ const resultCell = idx === 0 ? padEndVisible(result, resultW) : " ".repeat(resultW);
1324
+ const totalCell = idx === 0 ? padEndVisible(total, totalW) : " ".repeat(totalW);
1325
+ out.push(` ${dim("│")} ${issueCell} ${dim("│")} ${resultCell} ${dim("│")} ${padEndVisible(line, detailW)} ${dim("│")} ${totalCell} ${dim("│")}`);
1326
+ });
1327
+ if (i < issues.length - 1)
1328
+ out.push(sep);
1329
+ });
1330
+ out.push(bottom);
1331
+ return out.join("\n");
1332
+ }
1333
+ // ============================================================================
1334
+ // Helpers
1335
+ // ============================================================================
1336
+ /**
1337
+ * Pad/truncate a string to a visible-width column, ignoring ANSI escape
1338
+ * sequences. Uses `string-width` (already a dependency) so wide CJK and
1339
+ * emoji characters don't break alignment.
1340
+ */
1341
+ function padEndVisible(s, width) {
1342
+ const visible = stringWidth(s);
1343
+ if (visible >= width)
1344
+ return truncate(s, width);
1345
+ return s + " ".repeat(width - visible);
1346
+ }
1347
+ function truncate(s, max) {
1348
+ // Strip nothing — assumes input is plain text, not ANSI. Visible truncation
1349
+ // is best-effort; box drawing happens after this so ANSI is added later.
1350
+ if (s.length <= max)
1351
+ return s;
1352
+ return s.slice(0, Math.max(1, max - 1)) + "…";
1353
+ }
1354
+ /**
1355
+ * Create a `RunRenderer` matching the current execution context.
1356
+ *
1357
+ * Detection order:
1358
+ * 1. Explicit `mode` override.
1359
+ * 2. `SEQUANT_ORCHESTRATOR` env var → orchestrator (no-op).
1360
+ * 3. `process.stdout.isTTY` (or `options.isTTY`) → TTY renderer.
1361
+ * 4. Otherwise → non-TTY renderer.
1362
+ */
1363
+ export function createRunRenderer(options = {}) {
1364
+ const mode = options.mode ??
1365
+ (process.env.SEQUANT_ORCHESTRATOR
1366
+ ? "orchestrator"
1367
+ : (options.isTTY ?? Boolean(process.stdout.isTTY))
1368
+ ? "tty"
1369
+ : "non-tty");
1370
+ if (mode === "orchestrator")
1371
+ return new OrchestratorRenderer(options);
1372
+ if (mode === "tty")
1373
+ return new TTYRenderer(options);
1374
+ return new NonTTYRenderer(options);
1375
+ }
1376
+ /**
1377
+ * Public summary helper — used by the legacy displaySummary path so callers
1378
+ * that bypass the renderer still get the new grid layout.
1379
+ */
1380
+ export function renderRunSummary(input, options = {}) {
1381
+ // #624 Item 2 (AC-2.3): apply the same SUMMARY_COLUMN_CAP as the TTY and
1382
+ // non-TTY paths so the legacy renderless path can't produce a divergent
1383
+ // wide grid that overflows.
1384
+ const rawColumns = options.columns ?? process.stdout.columns ?? 100;
1385
+ renderSummaryBlock(input, {
1386
+ stdoutWrite: options.stdoutWrite ?? ((s) => void process.stdout.write(s)),
1387
+ noColor: Boolean(options.noColor) || Boolean(process.env.NO_COLOR),
1388
+ columns: Math.min(rawColumns, SUMMARY_COLUMN_CAP),
1389
+ });
1390
+ }