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.
Files changed (54) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +8 -5
  4. package/dist/bin/cli.js +46 -4
  5. package/dist/src/commands/abort.d.ts +36 -0
  6. package/dist/src/commands/abort.js +138 -0
  7. package/dist/src/commands/prompt.d.ts +7 -0
  8. package/dist/src/commands/prompt.js +101 -7
  9. package/dist/src/commands/run-progress.d.ts +11 -1
  10. package/dist/src/commands/run-progress.js +20 -3
  11. package/dist/src/commands/run.js +12 -2
  12. package/dist/src/commands/watch.d.ts +2 -0
  13. package/dist/src/commands/watch.js +67 -3
  14. package/dist/src/lib/assess-collision-detect.js +1 -1
  15. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +39 -0
  16. package/dist/src/lib/cli-ui/run-renderer.d.ts +27 -1
  17. package/dist/src/lib/cli-ui/run-renderer.js +231 -14
  18. package/dist/src/lib/cli-ui/scrollback-harness.d.ts +112 -0
  19. package/dist/src/lib/cli-ui/scrollback-harness.js +294 -0
  20. package/dist/src/lib/merge-check/types.js +1 -1
  21. package/dist/src/lib/relay/archive.js +6 -0
  22. package/dist/src/lib/relay/types.d.ts +2 -0
  23. package/dist/src/lib/relay/types.js +9 -0
  24. package/dist/src/lib/workflow/batch-executor.js +34 -18
  25. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +48 -1
  26. package/dist/src/lib/workflow/drivers/aider.d.ts +7 -1
  27. package/dist/src/lib/workflow/drivers/aider.js +9 -0
  28. package/dist/src/lib/workflow/drivers/claude-code.d.ts +17 -1
  29. package/dist/src/lib/workflow/drivers/claude-code.js +51 -2
  30. package/dist/src/lib/workflow/drivers/index.d.ts +1 -1
  31. package/dist/src/lib/workflow/event-emitter.d.ts +157 -0
  32. package/dist/src/lib/workflow/event-emitter.js +102 -0
  33. package/dist/src/lib/workflow/notice.d.ts +32 -0
  34. package/dist/src/lib/workflow/notice.js +38 -0
  35. package/dist/src/lib/workflow/phase-executor.d.ts +9 -21
  36. package/dist/src/lib/workflow/phase-executor.js +88 -115
  37. package/dist/src/lib/workflow/phase-mapper.d.ts +26 -13
  38. package/dist/src/lib/workflow/phase-mapper.js +55 -33
  39. package/dist/src/lib/workflow/phase-registry.d.ts +127 -0
  40. package/dist/src/lib/workflow/phase-registry.js +233 -0
  41. package/dist/src/lib/workflow/run-log-schema.d.ts +5 -55
  42. package/dist/src/lib/workflow/run-orchestrator.d.ts +32 -2
  43. package/dist/src/lib/workflow/run-orchestrator.js +125 -11
  44. package/dist/src/lib/workflow/state-manager.d.ts +19 -1
  45. package/dist/src/lib/workflow/state-manager.js +27 -1
  46. package/dist/src/lib/workflow/state-schema.d.ts +20 -35
  47. package/dist/src/lib/workflow/state-schema.js +28 -3
  48. package/dist/src/lib/workflow/types.d.ts +65 -15
  49. package/dist/src/lib/workflow/types.js +18 -13
  50. package/package.json +5 -4
  51. package/templates/hooks/post-tool.sh +81 -0
  52. package/templates/skills/assess/SKILL.md +28 -28
  53. package/templates/skills/assess/references/predicted-collision-detection.md +1 -1
  54. 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
- const stop = () => {
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 -q ` +
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
- if (options.stdoutWrite) {
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) => logUpdate(text);
488
- this.logUpdateClear = () => logUpdate.clear();
489
- this.logUpdateDone = () => logUpdate.done();
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 === "start") {
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, 110) - labelWidth - 7);
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, 110) - issueColW - 7);
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
- if (state.status === "running" || state.status === "queued") {
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
- return c.gray(`${p.name} queued`);
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)