sequant 2.3.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 +8 -5
- package/dist/bin/cli.js +46 -4
- package/dist/src/commands/abort.d.ts +36 -0
- package/dist/src/commands/abort.js +138 -0
- package/dist/src/commands/prompt.d.ts +7 -0
- package/dist/src/commands/prompt.js +101 -7
- package/dist/src/commands/run-progress.d.ts +11 -1
- package/dist/src/commands/run-progress.js +20 -3
- package/dist/src/commands/run.js +12 -2
- package/dist/src/commands/watch.d.ts +2 -0
- package/dist/src/commands/watch.js +67 -3
- package/dist/src/lib/assess-collision-detect.js +1 -1
- package/dist/src/lib/cli-ui/run-renderer-types.d.ts +39 -0
- package/dist/src/lib/cli-ui/run-renderer.d.ts +27 -1
- package/dist/src/lib/cli-ui/run-renderer.js +231 -14
- 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/merge-check/types.js +1 -1
- package/dist/src/lib/relay/archive.js +6 -0
- package/dist/src/lib/relay/types.d.ts +2 -0
- package/dist/src/lib/relay/types.js +9 -0
- package/dist/src/lib/workflow/batch-executor.js +34 -18
- 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/notice.d.ts +32 -0
- package/dist/src/lib/workflow/notice.js +38 -0
- package/dist/src/lib/workflow/phase-executor.d.ts +9 -21
- package/dist/src/lib/workflow/phase-executor.js +88 -115
- package/dist/src/lib/workflow/phase-mapper.d.ts +26 -13
- package/dist/src/lib/workflow/phase-mapper.js +55 -33
- 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/run-log-schema.d.ts +5 -55
- package/dist/src/lib/workflow/run-orchestrator.d.ts +32 -2
- package/dist/src/lib/workflow/run-orchestrator.js +125 -11
- package/dist/src/lib/workflow/state-manager.d.ts +19 -1
- package/dist/src/lib/workflow/state-manager.js +27 -1
- package/dist/src/lib/workflow/state-schema.d.ts +20 -35
- package/dist/src/lib/workflow/state-schema.js +28 -3
- package/dist/src/lib/workflow/types.d.ts +65 -15
- package/dist/src/lib/workflow/types.js +18 -13
- package/package.json +5 -4
- package/templates/hooks/post-tool.sh +81 -0
- package/templates/skills/assess/SKILL.md +28 -28
- package/templates/skills/assess/references/predicted-collision-detection.md +1 -1
- package/templates/skills/setup/SKILL.md +6 -6
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { existsSync, statSync, createReadStream, watch } from "fs";
|
|
7
7
|
import chalk from "chalk";
|
|
8
8
|
import { outboxPathFor } from "../lib/relay/paths.js";
|
|
9
|
+
import { listArchives } from "../lib/relay/archive.js";
|
|
10
|
+
import { readPidFile } from "../lib/relay/pid.js";
|
|
9
11
|
import { StateManager } from "../lib/workflow/state-manager.js";
|
|
10
12
|
import { RelayResponseSchema } from "../lib/relay/types.js";
|
|
11
13
|
function formatTimestamp(iso) {
|
|
@@ -82,8 +84,10 @@ export async function watchCommand(argsAndOptions) {
|
|
|
82
84
|
}
|
|
83
85
|
const stateManager = new StateManager();
|
|
84
86
|
const issueState = await stateManager.getIssueState(issueNumber);
|
|
87
|
+
const cwd = options.cwd ?? process.cwd();
|
|
85
88
|
const outboxPath = outboxPathFor(issueNumber, {
|
|
86
89
|
worktreePath: issueState?.worktree,
|
|
90
|
+
cwd,
|
|
87
91
|
});
|
|
88
92
|
const pollIntervalMs = options.pollIntervalMs ?? 200;
|
|
89
93
|
const tail = { offset: 0, partial: "" };
|
|
@@ -91,16 +95,43 @@ export async function watchCommand(argsAndOptions) {
|
|
|
91
95
|
if (existsSync(outboxPath)) {
|
|
92
96
|
tail.offset = statSync(outboxPath).size;
|
|
93
97
|
}
|
|
98
|
+
// Dead-relay detection (#645, Gap 3). The pidfile is written by activateRelay
|
|
99
|
+
// and removed by deactivateRelay. If it's absent and the outbox is absent at
|
|
100
|
+
// startup, there is nothing alive to watch — print a useful pointer and exit.
|
|
101
|
+
const initialPidPresent = readPidFile(issueNumber, cwd) !== null;
|
|
102
|
+
const initialOutboxPresent = existsSync(outboxPath);
|
|
103
|
+
if (!initialPidPresent && !initialOutboxPresent) {
|
|
104
|
+
const archives = listArchives(issueNumber, cwd);
|
|
105
|
+
const summary = `No active relay for #${issueNumber}.`;
|
|
106
|
+
const hint = archives[0]
|
|
107
|
+
? ` Most recent archive: ${archives[0]}`
|
|
108
|
+
: " (no archived runs found)";
|
|
109
|
+
if (options.json) {
|
|
110
|
+
console.log(JSON.stringify({
|
|
111
|
+
ok: false,
|
|
112
|
+
issue: issueNumber,
|
|
113
|
+
reason: "no-active-relay",
|
|
114
|
+
archive: archives[0] ?? null,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
console.log(chalk.yellow(summary + hint));
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
94
122
|
if (!options.json) {
|
|
95
123
|
console.log(chalk.gray(`Watching #${issueNumber} outbox — Ctrl+C to stop`));
|
|
96
124
|
}
|
|
97
125
|
let stopped = false;
|
|
98
|
-
|
|
126
|
+
let endReason = null;
|
|
127
|
+
const stop = (reason = "signal") => {
|
|
99
128
|
stopped = true;
|
|
129
|
+
if (!endReason)
|
|
130
|
+
endReason = reason;
|
|
100
131
|
};
|
|
101
|
-
options.signal?.addEventListener("abort", stop);
|
|
132
|
+
options.signal?.addEventListener("abort", () => stop("signal"));
|
|
102
133
|
process.on("SIGINT", () => {
|
|
103
|
-
stop();
|
|
134
|
+
stop("signal");
|
|
104
135
|
if (!options.json)
|
|
105
136
|
console.log(chalk.gray("\nStopped watching."));
|
|
106
137
|
process.exit(0);
|
|
@@ -125,6 +156,7 @@ export async function watchCommand(argsAndOptions) {
|
|
|
125
156
|
};
|
|
126
157
|
// Polling loop — also used as a heartbeat when fs.watch is active so we
|
|
127
158
|
// don't miss events on filesystems where watch is unreliable.
|
|
159
|
+
let sawLivePid = initialPidPresent;
|
|
128
160
|
while (!stopped) {
|
|
129
161
|
try {
|
|
130
162
|
const replies = await readNewLines(outboxPath, tail);
|
|
@@ -133,6 +165,23 @@ export async function watchCommand(argsAndOptions) {
|
|
|
133
165
|
catch {
|
|
134
166
|
/* transient — try again next tick */
|
|
135
167
|
}
|
|
168
|
+
// Dead-relay detection (#645, Gap 3). Once we've seen a live pidfile, its
|
|
169
|
+
// absence means the run has deactivated relay (archive complete). Drain
|
|
170
|
+
// one more poll for late writes, then exit cleanly.
|
|
171
|
+
const pidAlive = readPidFile(issueNumber, cwd) !== null;
|
|
172
|
+
if (sawLivePid && !pidAlive) {
|
|
173
|
+
try {
|
|
174
|
+
const finalReplies = await readNewLines(outboxPath, tail);
|
|
175
|
+
emit(finalReplies);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* swallow */
|
|
179
|
+
}
|
|
180
|
+
stop("relay-ended");
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
if (pidAlive)
|
|
184
|
+
sawLivePid = true;
|
|
136
185
|
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
137
186
|
}
|
|
138
187
|
if (watcher) {
|
|
@@ -144,4 +193,19 @@ export async function watchCommand(argsAndOptions) {
|
|
|
144
193
|
}
|
|
145
194
|
}
|
|
146
195
|
void useWatcher; // currently unused beyond best-effort init
|
|
196
|
+
if (endReason === "relay-ended") {
|
|
197
|
+
const archives = listArchives(issueNumber, cwd);
|
|
198
|
+
if (options.json) {
|
|
199
|
+
console.log(JSON.stringify({
|
|
200
|
+
ok: true,
|
|
201
|
+
issue: issueNumber,
|
|
202
|
+
reason: "relay-ended",
|
|
203
|
+
archive: archives[0] ?? null,
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
const hint = archives[0] ? ` Archive: ${archives[0]}` : "";
|
|
208
|
+
console.log(chalk.gray(`Run for #${issueNumber} ended.${hint}`));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
147
211
|
}
|
|
@@ -208,7 +208,7 @@ export function formatCollisionAnnotations(results) {
|
|
|
208
208
|
if (r.issues.length >= 3 && !chainSuggestion) {
|
|
209
209
|
const ids = r.issues.join(" ");
|
|
210
210
|
chainSuggestion =
|
|
211
|
-
`Chain: npx sequant run ${ids} --chain --qa-gate -
|
|
211
|
+
`Chain: npx sequant run ${ids} --chain --qa-gate -Q ` +
|
|
212
212
|
`# alternative — ${r.issues.length} issues modify ${r.file} ` +
|
|
213
213
|
`(chain length≥3 historically 1/6 = 17%; see docs/reference/chain-mode-analysis-2026-05.md)`;
|
|
214
214
|
}
|
|
@@ -76,6 +76,15 @@ export interface IssueRegistration {
|
|
|
76
76
|
* is known) and switches to the normal phase header once spec completes.
|
|
77
77
|
*/
|
|
78
78
|
autoDetect?: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* #672 AC-2: the resolved phase pipeline for this issue. When set, the live
|
|
81
|
+
* zone seeds one pending cell per planned phase so users see the full
|
|
82
|
+
* roadmap before any phase fires. Cells transition pending → running → ✔/✘
|
|
83
|
+
* in place via subsequent `onEvent` calls (#672 AC-3). When the plan isn't
|
|
84
|
+
* known at registration time (auto-detect mode), call `setPhasePlan` once
|
|
85
|
+
* spec resolves it.
|
|
86
|
+
*/
|
|
87
|
+
plannedPhases?: string[];
|
|
79
88
|
}
|
|
80
89
|
/** Per-issue summary fields used by the final summary table. */
|
|
81
90
|
export interface IssueSummary {
|
|
@@ -106,12 +115,26 @@ export interface RunRenderer {
|
|
|
106
115
|
registerIssue(reg: IssueRegistration): void;
|
|
107
116
|
/** Feed a progress event from batch-executor. */
|
|
108
117
|
onEvent(event: ProgressEvent): void;
|
|
118
|
+
/**
|
|
119
|
+
* #672 AC-2: set or replace the planned phase pipeline for an already-
|
|
120
|
+
* registered issue. Used by auto-detect mode after spec resolves the plan.
|
|
121
|
+
* No-op for unregistered issues (defensive — same as `setPullRequest`).
|
|
122
|
+
* An empty `phases` array clears the plan back to streaming-only behaviour.
|
|
123
|
+
*/
|
|
124
|
+
setPhasePlan(issue: number, phases: string[]): void;
|
|
109
125
|
/** Mark an issue as completed with PR info. Called by orchestrator. */
|
|
110
126
|
setPullRequest(issue: number, prNumber: number, prUrl: string): void;
|
|
111
127
|
/** Pause live updates so verbose streaming can write through. */
|
|
112
128
|
pause(): void;
|
|
113
129
|
/** Resume live updates after streaming ends. */
|
|
114
130
|
resume(): void;
|
|
131
|
+
/**
|
|
132
|
+
* #647 AC-3: print a notice line above the live zone without breaking
|
|
133
|
+
* log-update's cursor model. Use for retry/fallback messages emitted
|
|
134
|
+
* from outside the renderer's event flow (e.g., phase-executor retry
|
|
135
|
+
* paths).
|
|
136
|
+
*/
|
|
137
|
+
appendNotice(message: string): void;
|
|
115
138
|
/** Render the final summary block. */
|
|
116
139
|
renderSummary(input: SummaryRenderInput): void;
|
|
117
140
|
/** Tear down timers, cursor state, signal listeners. */
|
|
@@ -173,6 +196,22 @@ export interface RenderOptions {
|
|
|
173
196
|
* Default: false (matches existing displaySummary behaviour).
|
|
174
197
|
*/
|
|
175
198
|
alwaysRenderSummary?: boolean;
|
|
199
|
+
/**
|
|
200
|
+
* #647: inject a `log-update` instance (typically built via
|
|
201
|
+
* `createLogUpdate(stream)` against a custom stream). Used by the
|
|
202
|
+
* scrollback-harness regression test to drive the real `log-update`
|
|
203
|
+
* through a virtual terminal that tracks scrollback. When set, this takes
|
|
204
|
+
* precedence over both `stdoutWrite` (for the log-update path) and the
|
|
205
|
+
* default `process.stdout`-bound `logUpdate` import.
|
|
206
|
+
*
|
|
207
|
+
* Production code never sets this. Tests that need to assert on
|
|
208
|
+
* `log-update`'s actual erase semantics use it to replace the test stub.
|
|
209
|
+
*/
|
|
210
|
+
logUpdateInstance?: {
|
|
211
|
+
(text: string): void;
|
|
212
|
+
clear(): void;
|
|
213
|
+
done(): void;
|
|
214
|
+
};
|
|
176
215
|
}
|
|
177
216
|
/**
|
|
178
217
|
* Mode the renderer should run in. Auto-detected by `createRunRenderer` from
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* See issue #618.
|
|
15
15
|
*/
|
|
16
|
+
import type { PhasePauseHandle } from "../workflow/types.js";
|
|
16
17
|
import type { IssueRegistration, IssueState, ProgressEvent, RenderOptions, RendererMode, RunRenderer, SummaryRenderInput } from "./run-renderer-types.js";
|
|
17
18
|
/**
|
|
18
19
|
* #624 Item 4: normalized failure signature for dedup decisions.
|
|
@@ -35,7 +36,7 @@ export declare function failureSignature(error: string | undefined): string;
|
|
|
35
36
|
* (no iteration, iteration === 1, or non-positive).
|
|
36
37
|
*/
|
|
37
38
|
export declare function formatRetrySuffix(iteration: number | undefined, maxIterations: number, kind: "events" | "header"): string;
|
|
38
|
-
declare abstract class BaseRenderer implements RunRenderer {
|
|
39
|
+
declare abstract class BaseRenderer implements RunRenderer, PhasePauseHandle {
|
|
39
40
|
protected readonly issues: Map<number, IssueState>;
|
|
40
41
|
protected readonly stdoutWrite: (s: string) => void;
|
|
41
42
|
protected readonly stderrWrite: (s: string) => void;
|
|
@@ -47,10 +48,18 @@ declare abstract class BaseRenderer implements RunRenderer {
|
|
|
47
48
|
protected disposed: boolean;
|
|
48
49
|
constructor(options: RenderOptions);
|
|
49
50
|
registerIssue(reg: IssueRegistration): void;
|
|
51
|
+
setPhasePlan(issue: number, phases: string[]): void;
|
|
50
52
|
onEvent(event: ProgressEvent): void;
|
|
51
53
|
setPullRequest(issue: number, prNumber: number, prUrl: string): void;
|
|
52
54
|
pause(): void;
|
|
53
55
|
resume(): void;
|
|
56
|
+
/**
|
|
57
|
+
* #647 AC-3: default notice path — just write to the renderer's stdout
|
|
58
|
+
* channel. NonTTYRenderer keeps this default (no live zone to manage).
|
|
59
|
+
* TTYRenderer overrides to clear the live zone before writing so
|
|
60
|
+
* log-update's cursor model stays consistent with the actual terminal.
|
|
61
|
+
*/
|
|
62
|
+
appendNotice(message: string): void;
|
|
54
63
|
abstract renderSummary(input: SummaryRenderInput): void;
|
|
55
64
|
dispose(): void;
|
|
56
65
|
protected applyEvent(state: IssueState, event: ProgressEvent): void;
|
|
@@ -134,6 +143,14 @@ export declare class TTYRenderer extends BaseRenderer {
|
|
|
134
143
|
/**
|
|
135
144
|
* #624 Derived AC-D1: expose the test-only log-update stub. Returns `null`
|
|
136
145
|
* when not in test mode (production renders go through real `log-update`).
|
|
146
|
+
*
|
|
147
|
+
* #647 AC-D3 warning: this stub does NOT model `log-update`'s ANSI cursor
|
|
148
|
+
* or scrollback semantics. Tests that assert on `stub.lastFrame` only see
|
|
149
|
+
* the most recent frame, not whether earlier frames remained stranded in
|
|
150
|
+
* scrollback. Header-count / duplicate-header assertions MUST use
|
|
151
|
+
* `scrollback-harness.ts` (real `createLogUpdate` + VirtualTerminal),
|
|
152
|
+
* otherwise they will pass green even when the production rendering is
|
|
153
|
+
* broken — see #624 for the precedent.
|
|
137
154
|
*/
|
|
138
155
|
getTestStub(): TTYTestStub | null;
|
|
139
156
|
private startLiveTimer;
|
|
@@ -150,6 +167,15 @@ export declare class TTYRenderer extends BaseRenderer {
|
|
|
150
167
|
renderSummary(input: SummaryRenderInput): void;
|
|
151
168
|
protected onPause(): void;
|
|
152
169
|
protected onResume(): void;
|
|
170
|
+
/**
|
|
171
|
+
* #647 AC-3: TTYRenderer override. Writes the notice above the live zone
|
|
172
|
+
* the same way `appendEventLine` does (clear → write → redraw), so
|
|
173
|
+
* log-update's `previousLineCount` stays consistent with the actual
|
|
174
|
+
* terminal state. If the renderer is already paused (e.g., during
|
|
175
|
+
* verbose subprocess streaming), skip the clear/redraw and just write;
|
|
176
|
+
* the eventual `resume()` will redraw cleanly.
|
|
177
|
+
*/
|
|
178
|
+
appendNotice(message: string): void;
|
|
153
179
|
protected onDispose(): void;
|
|
154
180
|
private getColumns;
|
|
155
181
|
/**
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
*
|
|
14
14
|
* See issue #618.
|
|
15
15
|
*/
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
16
18
|
import chalk from "chalk";
|
|
17
19
|
import logUpdate from "log-update";
|
|
18
20
|
import stringWidth from "string-width";
|
|
@@ -118,6 +120,11 @@ class BaseRenderer {
|
|
|
118
120
|
registerIssue(reg) {
|
|
119
121
|
if (this.issues.has(reg.issueNumber))
|
|
120
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
|
+
: [];
|
|
121
128
|
this.issues.set(reg.issueNumber, {
|
|
122
129
|
issueNumber: reg.issueNumber,
|
|
123
130
|
title: reg.title,
|
|
@@ -125,10 +132,30 @@ class BaseRenderer {
|
|
|
125
132
|
branch: reg.branch,
|
|
126
133
|
autoDetect: reg.autoDetect,
|
|
127
134
|
status: "queued",
|
|
128
|
-
phases
|
|
135
|
+
phases,
|
|
129
136
|
});
|
|
130
137
|
this.afterStateChange();
|
|
131
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
|
+
}
|
|
132
159
|
onEvent(event) {
|
|
133
160
|
if (this.disposed)
|
|
134
161
|
return;
|
|
@@ -162,6 +189,17 @@ class BaseRenderer {
|
|
|
162
189
|
this.paused = false;
|
|
163
190
|
this.onResume();
|
|
164
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
|
+
}
|
|
165
203
|
dispose() {
|
|
166
204
|
if (this.disposed)
|
|
167
205
|
return;
|
|
@@ -452,10 +490,120 @@ export class TTYRenderer extends BaseRenderer {
|
|
|
452
490
|
options.multiIssueRowCap ?? DEFAULT_MULTI_ISSUE_ROW_CAP;
|
|
453
491
|
this.maxLoopIterations =
|
|
454
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
|
+
};
|
|
455
580
|
// log-update writes to process.stdout via a mutable global instance. When
|
|
456
581
|
// tests inject `stdoutWrite`, route renders through it instead so capture
|
|
457
|
-
// works deterministically.
|
|
458
|
-
|
|
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) {
|
|
459
607
|
// #624 Derived AC-D1: replacement-aware test stub. Tracks each frame
|
|
460
608
|
// replacement so tests can assert on frame churn without parsing buf.out.
|
|
461
609
|
// `clearCalls` / `doneCalls` verify the renderer actually invokes the
|
|
@@ -468,25 +616,39 @@ export class TTYRenderer extends BaseRenderer {
|
|
|
468
616
|
};
|
|
469
617
|
this._testStub = stub;
|
|
470
618
|
this.logUpdateImpl = (text) => {
|
|
619
|
+
frameCounter++;
|
|
620
|
+
emitDebug("impl", text);
|
|
471
621
|
if (stub.lastFrame)
|
|
472
622
|
stub.replacementCount++;
|
|
473
623
|
stub.lastFrame = text;
|
|
474
624
|
options.stdoutWrite(text + "\n");
|
|
475
625
|
};
|
|
476
626
|
this.logUpdateClear = () => {
|
|
627
|
+
emitDebug("clear");
|
|
477
628
|
stub.clearCalls++;
|
|
478
629
|
stub.lastFrame = "";
|
|
479
630
|
};
|
|
480
631
|
this.logUpdateDone = () => {
|
|
632
|
+
emitDebug("done");
|
|
481
633
|
stub.doneCalls++;
|
|
482
634
|
stub.lastFrame = "";
|
|
483
635
|
};
|
|
484
636
|
}
|
|
485
637
|
else {
|
|
486
638
|
this._testStub = null;
|
|
487
|
-
this.logUpdateImpl = (text) =>
|
|
488
|
-
|
|
489
|
-
|
|
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
|
+
};
|
|
490
652
|
}
|
|
491
653
|
this.startLiveTimer();
|
|
492
654
|
this.installSignalListeners();
|
|
@@ -494,6 +656,14 @@ export class TTYRenderer extends BaseRenderer {
|
|
|
494
656
|
/**
|
|
495
657
|
* #624 Derived AC-D1: expose the test-only log-update stub. Returns `null`
|
|
496
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.
|
|
497
667
|
*/
|
|
498
668
|
getTestStub() {
|
|
499
669
|
return this._testStub;
|
|
@@ -542,6 +712,14 @@ export class TTYRenderer extends BaseRenderer {
|
|
|
542
712
|
this.redraw();
|
|
543
713
|
}
|
|
544
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;
|
|
545
723
|
// Clear the live zone so the appended event becomes a real `console.log`
|
|
546
724
|
// line above it; the live zone redraws below.
|
|
547
725
|
this.logUpdateClear();
|
|
@@ -553,10 +731,7 @@ export class TTYRenderer extends BaseRenderer {
|
|
|
553
731
|
? ""
|
|
554
732
|
: formatRetrySuffix(phase?.loopIteration, this.maxLoopIterations, "events");
|
|
555
733
|
let line;
|
|
556
|
-
if (event.event === "
|
|
557
|
-
line = ` ${c.cyan("▸")} #${event.issue} ${event.phase}${retrySuffix}`;
|
|
558
|
-
}
|
|
559
|
-
else if (event.event === "complete") {
|
|
734
|
+
if (event.event === "complete") {
|
|
560
735
|
const durStr = event.durationSeconds !== undefined
|
|
561
736
|
? ` ${formatElapsedTime(event.durationSeconds)}`
|
|
562
737
|
: "";
|
|
@@ -619,6 +794,25 @@ export class TTYRenderer extends BaseRenderer {
|
|
|
619
794
|
// Live zone redraws on next tick / event automatically.
|
|
620
795
|
this.redraw();
|
|
621
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
|
+
}
|
|
622
816
|
onDispose() {
|
|
623
817
|
if (this.liveTimer !== null) {
|
|
624
818
|
clearInterval(this.liveTimer);
|
|
@@ -732,8 +926,16 @@ export class TTYRenderer extends BaseRenderer {
|
|
|
732
926
|
const state = [...this.issues.values()][0];
|
|
733
927
|
const c = colorize(this.noColor);
|
|
734
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.
|
|
735
937
|
const labelWidth = 10;
|
|
736
|
-
const innerWidth = Math.max(40, Math.min(cols,
|
|
938
|
+
const innerWidth = Math.max(40, Math.min(cols, 78) - labelWidth - 9);
|
|
737
939
|
const valueWidth = innerWidth;
|
|
738
940
|
const rows = [];
|
|
739
941
|
const titleSuffix = state.title ? ` — ${state.title}` : "";
|
|
@@ -761,8 +963,13 @@ export class TTYRenderer extends BaseRenderer {
|
|
|
761
963
|
// is appended.
|
|
762
964
|
const c = colorize(this.noColor);
|
|
763
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.
|
|
764
971
|
const issueColW = 8;
|
|
765
|
-
const innerWidth = Math.max(50, Math.min(cols,
|
|
972
|
+
const innerWidth = Math.max(50, Math.min(cols, 78) - issueColW - 9);
|
|
766
973
|
const statusColW = innerWidth;
|
|
767
974
|
const lines = [c.bold(header), ""];
|
|
768
975
|
if (this.banner)
|
|
@@ -902,7 +1109,13 @@ export class TTYRenderer extends BaseRenderer {
|
|
|
902
1109
|
statusSubLines(state) {
|
|
903
1110
|
const c = colorize(this.noColor);
|
|
904
1111
|
const lines = [];
|
|
905
|
-
|
|
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") {
|
|
906
1119
|
const seq = state.phases
|
|
907
1120
|
.filter((p) => p.name !== "loop")
|
|
908
1121
|
.map((p) => {
|
|
@@ -912,7 +1125,11 @@ export class TTYRenderer extends BaseRenderer {
|
|
|
912
1125
|
return c.red(`${p.name} ✘`);
|
|
913
1126
|
if (p.status === "running")
|
|
914
1127
|
return c.cyan(`${p.name} running`);
|
|
915
|
-
|
|
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} –`);
|
|
916
1133
|
})
|
|
917
1134
|
.join(" → ");
|
|
918
1135
|
if (seq)
|