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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +81 -5
- package/dist/bin/cli.js +140 -13
- package/dist/src/commands/abort.d.ts +36 -0
- package/dist/src/commands/abort.js +138 -0
- package/dist/src/commands/doctor.d.ts +25 -0
- package/dist/src/commands/doctor.js +36 -1
- package/dist/src/commands/locks.d.ts +67 -0
- package/dist/src/commands/locks.js +290 -0
- package/dist/src/commands/merge.js +11 -0
- package/dist/src/commands/prompt.d.ts +46 -0
- package/dist/src/commands/prompt.js +273 -0
- package/dist/src/commands/run-display.d.ts +11 -2
- package/dist/src/commands/run-display.js +62 -28
- package/dist/src/commands/run-progress.d.ts +42 -0
- package/dist/src/commands/run-progress.js +93 -0
- package/dist/src/commands/run.js +90 -18
- package/dist/src/commands/stats.d.ts +2 -0
- package/dist/src/commands/stats.js +94 -8
- package/dist/src/commands/status.js +12 -0
- package/dist/src/commands/watch.d.ts +18 -0
- package/dist/src/commands/watch.js +211 -0
- package/dist/src/lib/ac-linter.d.ts +1 -1
- package/dist/src/lib/ac-linter.js +81 -0
- package/dist/src/lib/assess-collision-detect.d.ts +91 -0
- package/dist/src/lib/assess-collision-detect.js +217 -0
- package/dist/src/lib/assess-comment-parser.d.ts +59 -1
- package/dist/src/lib/assess-comment-parser.js +124 -2
- package/dist/src/lib/cli-ui/format.d.ts +19 -0
- package/dist/src/lib/cli-ui/format.js +34 -0
- package/dist/src/lib/cli-ui/run-renderer-types.d.ts +220 -0
- package/dist/src/lib/cli-ui/run-renderer-types.js +7 -0
- package/dist/src/lib/cli-ui/run-renderer.d.ts +265 -0
- package/dist/src/lib/cli-ui/run-renderer.js +1390 -0
- package/dist/src/lib/cli-ui/scrollback-harness.d.ts +112 -0
- package/dist/src/lib/cli-ui/scrollback-harness.js +294 -0
- package/dist/src/lib/heuristics/behavior-rule-detector.d.ts +94 -0
- package/dist/src/lib/heuristics/behavior-rule-detector.js +467 -0
- package/dist/src/lib/locks/index.d.ts +7 -0
- package/dist/src/lib/locks/index.js +5 -0
- package/dist/src/lib/locks/lock-manager.d.ts +168 -0
- package/dist/src/lib/locks/lock-manager.js +433 -0
- package/dist/src/lib/locks/types.d.ts +59 -0
- package/dist/src/lib/locks/types.js +31 -0
- package/dist/src/lib/merge-check/types.js +1 -1
- package/dist/src/lib/qa/markdown-only-ci.d.ts +46 -0
- package/dist/src/lib/qa/markdown-only-ci.js +74 -0
- package/dist/src/lib/relay/activation.d.ts +60 -0
- package/dist/src/lib/relay/activation.js +122 -0
- package/dist/src/lib/relay/archive.d.ts +34 -0
- package/dist/src/lib/relay/archive.js +112 -0
- package/dist/src/lib/relay/frame.d.ts +20 -0
- package/dist/src/lib/relay/frame.js +76 -0
- package/dist/src/lib/relay/index.d.ts +13 -0
- package/dist/src/lib/relay/index.js +13 -0
- package/dist/src/lib/relay/paths.d.ts +43 -0
- package/dist/src/lib/relay/paths.js +59 -0
- package/dist/src/lib/relay/pid.d.ts +34 -0
- package/dist/src/lib/relay/pid.js +72 -0
- package/dist/src/lib/relay/reader.d.ts +35 -0
- package/dist/src/lib/relay/reader.js +115 -0
- package/dist/src/lib/relay/types.d.ts +70 -0
- package/dist/src/lib/relay/types.js +85 -0
- package/dist/src/lib/relay/writer.d.ts +48 -0
- package/dist/src/lib/relay/writer.js +113 -0
- package/dist/src/lib/settings.d.ts +31 -1
- package/dist/src/lib/settings.js +18 -3
- package/dist/src/lib/version-check.d.ts +60 -5
- package/dist/src/lib/version-check.js +97 -9
- package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
- package/dist/src/lib/workflow/batch-executor.js +274 -185
- package/dist/src/lib/workflow/config-resolver.js +4 -0
- package/dist/src/lib/workflow/drivers/agent-driver.d.ts +48 -1
- package/dist/src/lib/workflow/drivers/aider.d.ts +7 -1
- package/dist/src/lib/workflow/drivers/aider.js +9 -0
- package/dist/src/lib/workflow/drivers/claude-code.d.ts +17 -1
- package/dist/src/lib/workflow/drivers/claude-code.js +51 -2
- package/dist/src/lib/workflow/drivers/index.d.ts +1 -1
- package/dist/src/lib/workflow/event-emitter.d.ts +157 -0
- package/dist/src/lib/workflow/event-emitter.js +102 -0
- package/dist/src/lib/workflow/heartbeat.d.ts +71 -0
- package/dist/src/lib/workflow/heartbeat.js +194 -0
- package/dist/src/lib/workflow/notice.d.ts +32 -0
- package/dist/src/lib/workflow/notice.js +38 -0
- package/dist/src/lib/workflow/phase-executor.d.ts +58 -16
- package/dist/src/lib/workflow/phase-executor.js +244 -130
- package/dist/src/lib/workflow/phase-mapper.d.ts +27 -13
- package/dist/src/lib/workflow/phase-mapper.js +70 -51
- package/dist/src/lib/workflow/phase-registry.d.ts +127 -0
- package/dist/src/lib/workflow/phase-registry.js +233 -0
- package/dist/src/lib/workflow/platforms/github.d.ts +1 -1
- package/dist/src/lib/workflow/platforms/github.js +20 -3
- package/dist/src/lib/workflow/pr-status.d.ts +18 -2
- package/dist/src/lib/workflow/pr-status.js +41 -9
- package/dist/src/lib/workflow/qa-stagnation.d.ts +117 -0
- package/dist/src/lib/workflow/qa-stagnation.js +179 -0
- package/dist/src/lib/workflow/run-log-schema.d.ts +5 -55
- package/dist/src/lib/workflow/run-orchestrator.d.ts +70 -1
- package/dist/src/lib/workflow/run-orchestrator.js +464 -25
- package/dist/src/lib/workflow/run-reflect.js +1 -1
- package/dist/src/lib/workflow/run-state.d.ts +71 -0
- package/dist/src/lib/workflow/run-state.js +14 -0
- package/dist/src/lib/workflow/state-cleanup.d.ts +13 -5
- package/dist/src/lib/workflow/state-cleanup.js +17 -5
- package/dist/src/lib/workflow/state-manager.d.ts +31 -2
- package/dist/src/lib/workflow/state-manager.js +64 -1
- package/dist/src/lib/workflow/state-schema.d.ts +82 -35
- package/dist/src/lib/workflow/state-schema.js +63 -4
- package/dist/src/lib/workflow/types.d.ts +139 -16
- package/dist/src/lib/workflow/types.js +18 -13
- package/dist/src/lib/workflow/worktree-manager.d.ts +8 -1
- package/dist/src/lib/workflow/worktree-manager.js +15 -6
- package/dist/src/mcp/tools/run.d.ts +44 -0
- package/dist/src/mcp/tools/run.js +104 -13
- package/dist/src/ui/tui/App.d.ts +14 -0
- package/dist/src/ui/tui/App.js +41 -0
- package/dist/src/ui/tui/ElapsedTimer.d.ts +10 -0
- package/dist/src/ui/tui/ElapsedTimer.js +31 -0
- package/dist/src/ui/tui/Header.d.ts +6 -0
- package/dist/src/ui/tui/Header.js +15 -0
- package/dist/src/ui/tui/IssueBox.d.ts +16 -0
- package/dist/src/ui/tui/IssueBox.js +68 -0
- package/dist/src/ui/tui/Spinner.d.ts +9 -0
- package/dist/src/ui/tui/Spinner.js +18 -0
- package/dist/src/ui/tui/index.d.ts +15 -0
- package/dist/src/ui/tui/index.js +29 -0
- package/dist/src/ui/tui/theme.d.ts +29 -0
- package/dist/src/ui/tui/theme.js +52 -0
- package/dist/src/ui/tui/truncate.d.ts +11 -0
- package/dist/src/ui/tui/truncate.js +31 -0
- package/package.json +14 -6
- package/templates/agents/sequant-explorer.md +1 -0
- package/templates/agents/sequant-qa-checker.md +2 -1
- package/templates/agents/sequant-testgen.md +1 -0
- package/templates/hooks/post-tool.sh +92 -0
- package/templates/hooks/pre-tool.sh +18 -9
- package/templates/hooks/relay-check.sh +107 -0
- package/templates/relay/frame.txt +11 -0
- package/templates/scripts/cleanup-worktree.sh +25 -3
- package/templates/scripts/new-feature.sh +6 -0
- package/templates/skills/_shared/references/behavior-rule-detection.md +205 -0
- package/templates/skills/_shared/references/subagent-types.md +21 -8
- package/templates/skills/assess/SKILL.md +122 -68
- package/templates/skills/assess/references/predicted-collision-detection.md +109 -0
- package/templates/skills/docs/SKILL.md +141 -22
- package/templates/skills/exec/SKILL.md +10 -8
- package/templates/skills/fullsolve/SKILL.md +79 -5
- package/templates/skills/loop/SKILL.md +28 -0
- package/templates/skills/merger/SKILL.md +621 -0
- package/templates/skills/qa/SKILL.md +727 -8
- package/templates/skills/setup/SKILL.md +12 -6
- package/templates/skills/spec/SKILL.md +52 -0
- package/templates/skills/spec/references/parallel-groups.md +7 -0
- package/templates/skills/spec/references/recommended-workflow.md +4 -2
- 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
|
+
}
|