sequant 2.3.0 → 2.5.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 (101) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.md +125 -160
  4. package/dist/bin/cli.js +59 -4
  5. package/dist/dashboard/server.js +1 -0
  6. package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +2 -2
  7. package/dist/marketplace/external_plugins/sequant/README.md +6 -3
  8. package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +92 -0
  9. package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +18 -9
  10. package/dist/marketplace/external_plugins/sequant/hooks/relay-check.sh +107 -0
  11. package/dist/marketplace/external_plugins/sequant/skills/_shared/references/behavior-rule-detection.md +205 -0
  12. package/dist/marketplace/external_plugins/sequant/skills/_shared/references/subagent-types.md +21 -8
  13. package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +302 -86
  14. package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +109 -0
  15. package/dist/marketplace/external_plugins/sequant/skills/docs/SKILL.md +141 -22
  16. package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +83 -78
  17. package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +377 -137
  18. package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +28 -0
  19. package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +621 -0
  20. package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +741 -232
  21. package/dist/marketplace/external_plugins/sequant/skills/qa/scripts/quality-checks.sh +47 -1
  22. package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +12 -6
  23. package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +217 -964
  24. package/dist/marketplace/external_plugins/sequant/skills/spec/references/parallel-groups.md +7 -0
  25. package/dist/marketplace/external_plugins/sequant/skills/spec/references/quality-checklist.md +75 -0
  26. package/dist/marketplace/external_plugins/sequant/skills/spec/references/recommended-workflow.md +4 -2
  27. package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +0 -27
  28. package/dist/marketplace/external_plugins/sequant/skills/testgen/SKILL.md +24 -44
  29. package/dist/src/commands/abort.d.ts +36 -0
  30. package/dist/src/commands/abort.js +138 -0
  31. package/dist/src/commands/prompt.d.ts +7 -0
  32. package/dist/src/commands/prompt.js +101 -7
  33. package/dist/src/commands/ready-tui-adapter.d.ts +59 -0
  34. package/dist/src/commands/ready-tui-adapter.js +130 -0
  35. package/dist/src/commands/ready.d.ts +49 -0
  36. package/dist/src/commands/ready.js +243 -0
  37. package/dist/src/commands/run-progress.d.ts +11 -1
  38. package/dist/src/commands/run-progress.js +20 -3
  39. package/dist/src/commands/run.js +12 -2
  40. package/dist/src/commands/status.js +4 -0
  41. package/dist/src/commands/watch.d.ts +2 -0
  42. package/dist/src/commands/watch.js +67 -3
  43. package/dist/src/lib/assess-collision-detect.js +1 -1
  44. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +39 -0
  45. package/dist/src/lib/cli-ui/run-renderer.d.ts +34 -2
  46. package/dist/src/lib/cli-ui/run-renderer.js +250 -33
  47. package/dist/src/lib/cli-ui/scrollback-harness.d.ts +112 -0
  48. package/dist/src/lib/cli-ui/scrollback-harness.js +294 -0
  49. package/dist/src/lib/merge-check/types.js +1 -1
  50. package/dist/src/lib/relay/archive.js +6 -0
  51. package/dist/src/lib/relay/types.d.ts +2 -0
  52. package/dist/src/lib/relay/types.js +9 -0
  53. package/dist/src/lib/settings.d.ts +34 -0
  54. package/dist/src/lib/settings.js +23 -1
  55. package/dist/src/lib/workflow/batch-executor.js +34 -18
  56. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +48 -1
  57. package/dist/src/lib/workflow/drivers/aider.d.ts +7 -1
  58. package/dist/src/lib/workflow/drivers/aider.js +9 -0
  59. package/dist/src/lib/workflow/drivers/claude-code.d.ts +17 -1
  60. package/dist/src/lib/workflow/drivers/claude-code.js +51 -2
  61. package/dist/src/lib/workflow/drivers/index.d.ts +1 -1
  62. package/dist/src/lib/workflow/event-emitter.d.ts +157 -0
  63. package/dist/src/lib/workflow/event-emitter.js +102 -0
  64. package/dist/src/lib/workflow/notice.d.ts +32 -0
  65. package/dist/src/lib/workflow/notice.js +38 -0
  66. package/dist/src/lib/workflow/phase-executor.d.ts +9 -21
  67. package/dist/src/lib/workflow/phase-executor.js +105 -117
  68. package/dist/src/lib/workflow/phase-mapper.d.ts +26 -13
  69. package/dist/src/lib/workflow/phase-mapper.js +55 -33
  70. package/dist/src/lib/workflow/phase-registry.d.ts +127 -0
  71. package/dist/src/lib/workflow/phase-registry.js +233 -0
  72. package/dist/src/lib/workflow/platforms/github.d.ts +6 -0
  73. package/dist/src/lib/workflow/platforms/github.js +17 -0
  74. package/dist/src/lib/workflow/ready-gate.d.ts +155 -0
  75. package/dist/src/lib/workflow/ready-gate.js +374 -0
  76. package/dist/src/lib/workflow/reconcile.js +6 -0
  77. package/dist/src/lib/workflow/run-log-schema.d.ts +5 -55
  78. package/dist/src/lib/workflow/run-orchestrator.d.ts +32 -2
  79. package/dist/src/lib/workflow/run-orchestrator.js +125 -11
  80. package/dist/src/lib/workflow/state-manager.d.ts +19 -1
  81. package/dist/src/lib/workflow/state-manager.js +27 -1
  82. package/dist/src/lib/workflow/state-schema.d.ts +23 -35
  83. package/dist/src/lib/workflow/state-schema.js +29 -3
  84. package/dist/src/lib/workflow/types.d.ts +74 -15
  85. package/dist/src/lib/workflow/types.js +18 -13
  86. package/dist/src/ui/tui/App.js +8 -2
  87. package/dist/src/ui/tui/IssueBox.js +3 -4
  88. package/dist/src/ui/tui/index.d.ts +13 -4
  89. package/dist/src/ui/tui/index.js +19 -5
  90. package/dist/src/ui/tui/row-cap.d.ts +51 -0
  91. package/dist/src/ui/tui/row-cap.js +76 -0
  92. package/dist/src/ui/tui/teardown.d.ts +20 -0
  93. package/dist/src/ui/tui/teardown.js +29 -0
  94. package/dist/src/ui/tui/theme.d.ts +3 -0
  95. package/dist/src/ui/tui/theme.js +3 -0
  96. package/package.json +23 -11
  97. package/templates/hooks/post-tool.sh +81 -0
  98. package/templates/skills/assess/SKILL.md +28 -28
  99. package/templates/skills/assess/references/predicted-collision-detection.md +1 -1
  100. package/templates/skills/qa/SKILL.md +5 -2
  101. package/templates/skills/setup/SKILL.md +6 -6
@@ -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);
@@ -728,13 +922,25 @@ export class TTYRenderer extends BaseRenderer {
728
922
  return lines.join("\n");
729
923
  }
730
924
  renderSingleIssueFrame(cols) {
731
- // AC-11: Single-issue runs use a key:value full-grid table.
925
+ // AC-11: single-issue runs render as indented `label value` lines — not a
926
+ // box-drawing grid.
927
+ //
928
+ // The grid was the dominant source of `log-update` `eraseLines` stranding
929
+ // (#647 / #655): a multi-line bordered frame whose top survives in
930
+ // scrollback when the erase undershoots, leaving a frozen first paint (the
931
+ // classic "0s elapsed" ghost with no bottom border). Indented labels keep
932
+ // the same information at a shorter, border-free height that clears
933
+ // cleanly, and match the repo's move away from box-drawing in human output
934
+ // (see feedback_llm_hostile_formatting). Multi-issue still uses the grid.
732
935
  const state = [...this.issues.values()][0];
733
936
  const c = colorize(this.noColor);
734
937
  const header = `SEQUANT WORKFLOW · #${state.issueNumber} · ${formatElapsedTime((this.now() - this.runStartedAt) / 1000)} elapsed`;
735
- const labelWidth = 10;
736
- const innerWidth = Math.max(40, Math.min(cols, 110) - labelWidth - 7);
737
- const valueWidth = innerWidth;
938
+ // Label column fits the widest label ("Worktree"); value column is the
939
+ // remaining width after the 2-space indent + 2-space gap. Capped the same
940
+ // way the grid was so wide / misreported terminals can't push values past a
941
+ // standard 80-col reader.
942
+ const labelWidth = 8;
943
+ const valueWidth = Math.max(40, Math.min(cols, 100) - labelWidth - 4);
738
944
  const rows = [];
739
945
  const titleSuffix = state.title ? ` — ${state.title}` : "";
740
946
  rows.push([
@@ -751,7 +957,7 @@ export class TTYRenderer extends BaseRenderer {
751
957
  const lines = [c.bold(header), ""];
752
958
  if (this.banner)
753
959
  lines.push(c.yellow(this.banner), "");
754
- lines.push(this.drawKeyValueTable(rows, labelWidth, valueWidth));
960
+ lines.push(this.drawKeyValueLines(rows, labelWidth));
755
961
  return lines.join("\n");
756
962
  }
757
963
  renderMultiIssueFrame(cols) {
@@ -761,8 +967,13 @@ export class TTYRenderer extends BaseRenderer {
761
967
  // is appended.
762
968
  const c = colorize(this.noColor);
763
969
  const header = `SEQUANT WORKFLOW · ${this.runHeader()}`;
970
+ // #647 AC-3: see note in `renderSingleIssueFrame` — cap at 78 (not 110) so
971
+ // the rendered grid stays narrower than any standard 80-col terminal under
972
+ // width-misreporting conditions. The box-drawing total is
973
+ // `issueColW + statusColW + 9`; the prior `- 7` formula compounded the
974
+ // overflow.
764
975
  const issueColW = 8;
765
- const innerWidth = Math.max(50, Math.min(cols, 110) - issueColW - 7);
976
+ const innerWidth = Math.max(50, Math.min(cols, 78) - issueColW - 9);
766
977
  const statusColW = innerWidth;
767
978
  const lines = [c.bold(header), ""];
768
979
  if (this.banner)
@@ -902,7 +1113,13 @@ export class TTYRenderer extends BaseRenderer {
902
1113
  statusSubLines(state) {
903
1114
  const c = colorize(this.noColor);
904
1115
  const lines = [];
905
- if (state.status === "running" || state.status === "queued") {
1116
+ // #672 AC-3: include the failed state so the row that just failed still
1117
+ // renders its phase cells (the failing cell shows ✘ in place). Without
1118
+ // this, a failure on an unstarted phase hides the entire pipeline behind
1119
+ // the header summary, making it impossible to see how far the run got.
1120
+ if (state.status === "running" ||
1121
+ state.status === "queued" ||
1122
+ state.status === "failed") {
906
1123
  const seq = state.phases
907
1124
  .filter((p) => p.name !== "loop")
908
1125
  .map((p) => {
@@ -912,7 +1129,11 @@ export class TTYRenderer extends BaseRenderer {
912
1129
  return c.red(`${p.name} ✘`);
913
1130
  if (p.status === "running")
914
1131
  return c.cyan(`${p.name} running`);
915
- return c.gray(`${p.name} queued`);
1132
+ // #672 AC-3: pending cells render as `name –` (en dash) so the live
1133
+ // zone reads as a roadmap when a phase plan is set via registration
1134
+ // or `setPhasePlan`. Without a plan, no pending cells are seeded so
1135
+ // this branch is unreachable — preserving prior single-row output.
1136
+ return c.gray(`${p.name} –`);
916
1137
  })
917
1138
  .join(" → ");
918
1139
  if (seq)
@@ -955,26 +1176,22 @@ export class TTYRenderer extends BaseRenderer {
955
1176
  return c.dim(`${done} done · ${running} running · ${queued} queued · ${failed} failed`);
956
1177
  }
957
1178
  // ---------------- Box drawing ----------------
958
- drawKeyValueTable(rows, labelW, valueW) {
1179
+ /**
1180
+ * Single-issue layout: indented `label value` lines, no box drawing. The
1181
+ * label is cyan and padded to `labelW`; continuation lines (multi-line
1182
+ * status cells) align under the value column with a blank label. See
1183
+ * `renderSingleIssueFrame` for why the bordered grid was dropped.
1184
+ */
1185
+ drawKeyValueLines(rows, labelW) {
959
1186
  const c = colorize(this.noColor);
960
- const dim = c.dim;
961
- const total = labelW + valueW + 3;
962
- const top = dim(" ┌" + "─".repeat(labelW + 2) + "┬" + "─".repeat(valueW + 2) + "┐");
963
- const sep = dim(" ├" + "─".repeat(labelW + 2) + "┼" + "─".repeat(valueW + 2) + "┤");
964
- const bottom = dim(" └" + "─".repeat(labelW + 2) + "┴" + "─".repeat(valueW + 2) + "┘");
965
- const out = [top];
966
- rows.forEach(([label, lines], i) => {
1187
+ const out = [];
1188
+ for (const [label, lines] of rows) {
967
1189
  const labelPadded = padEndVisible(label, labelW);
968
1190
  lines.forEach((line, idx) => {
969
1191
  const labelCell = idx === 0 ? c.cyan(labelPadded) : " ".repeat(labelW);
970
- const valuePadded = padEndVisible(line, valueW);
971
- out.push(` ${dim("│")} ${labelCell} ${dim("│")} ${valuePadded} ${dim("│")}`);
1192
+ out.push(` ${labelCell} ${line}`);
972
1193
  });
973
- if (i < rows.length - 1)
974
- out.push(sep);
975
- });
976
- out.push(bottom);
977
- void total;
1194
+ }
978
1195
  return out.join("\n");
979
1196
  }
980
1197
  drawIssueGrid(rows, issueW, statusW) {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Virtual-terminal harness for renderer regression tests (#647).
3
+ *
4
+ * The test stub embedded in TTYRenderer (see {@link
5
+ * ./run-renderer.ts#TTYTestStub}) mocks `log-update` itself — it cannot reveal
6
+ * whether the real `log-update` actually erases prior frames once the terminal
7
+ * scrolls. That gap is what allowed #624's fix to ship green while the
8
+ * underlying duplicate-header bug remained.
9
+ *
10
+ * This harness models a real terminal:
11
+ * - bounded visible viewport (rows × cols)
12
+ * - unbounded scrollback that captures every line that scrolls off the top
13
+ * - the ANSI escape vocabulary that `log-update@7` + `ansi-escapes`
14
+ * actually emit (cursor up/down/forward/back, eraseLine variants,
15
+ * SGR colour stripping, private mode set/reset, save/restore)
16
+ *
17
+ * With it, a test can wire the production renderer through a real
18
+ * `createLogUpdate` instance, replay an event sequence, and assert on
19
+ * `(visible + scrollback)` to catch any duplicate-header rendering — the
20
+ * exact regression #647 was opened for.
21
+ */
22
+ import { createLogUpdate } from "log-update";
23
+ export interface VirtualTerminalOptions {
24
+ rows: number;
25
+ cols: number;
26
+ /** Newline mode. POSIX shells default to ONLCR which translates `\n` to
27
+ * `\r\n`, so most apps see "move down + col 0". Default true. */
28
+ onlcr?: boolean;
29
+ }
30
+ /**
31
+ * Minimal vt100 model: visible grid + scrollback + cursor. Strips SGR colour
32
+ * codes (they're styling, not content) and ignores private-mode toggles
33
+ * (cursor hide/show). Implements the cursor and erase escapes that
34
+ * `log-update@7` actually emits.
35
+ */
36
+ export declare class VirtualTerminal {
37
+ readonly rows: number;
38
+ readonly cols: number;
39
+ private readonly onlcr;
40
+ /** visible[row][col] = char (always single-codepoint slot). */
41
+ visible: string[][];
42
+ /** Scrollback grows oldest-first as rows shift off the top. */
43
+ scrollback: string[];
44
+ cursorRow: number;
45
+ cursorCol: number;
46
+ constructor(opts: VirtualTerminalOptions);
47
+ write(text: string): void;
48
+ /** Visible viewport as a list of trimmed-right rows. */
49
+ getVisibleLines(): string[];
50
+ /** Single multi-line string of (scrollback + visible). */
51
+ getAllText(): string;
52
+ /** Match count of the regex against (scrollback + visible). */
53
+ countOccurrences(pattern: RegExp): number;
54
+ private putChar;
55
+ private linefeed;
56
+ /** Returns the index AFTER the consumed escape sequence. */
57
+ private handleEscape;
58
+ private handleCSI;
59
+ private executeCSI;
60
+ private eraseLine;
61
+ private eraseFromCursorToEndOfLine;
62
+ private eraseFromStartOfLineToCursor;
63
+ private eraseFromCursorToEndOfScreen;
64
+ private eraseFromStartOfScreenToCursor;
65
+ private eraseScreen;
66
+ }
67
+ /**
68
+ * Bundle a VirtualTerminal with a real `log-update` instance writing into it
69
+ * and a matching `stdoutWrite` for renderer event-line writes. Both paths hit
70
+ * the same VT, mirroring real-terminal interleaving.
71
+ *
72
+ * Production runs frequently hit a width/height mismatch between what
73
+ * `log-update` reads from `process.stdout` and what the real terminal actually
74
+ * uses (e.g. `process.stdout.columns` is undefined under `npx` so log-update
75
+ * falls back to 80 while the terminal is 200 cols). Those mismatches cause
76
+ * `previousLineCount` to under- or over-count the rows log-update actually
77
+ * wrote, breaking `eraseLines` and leaving stale rows in scrollback. The
78
+ * `streamColumns` / `streamRows` overrides let tests reproduce this without
79
+ * needing a real PTY.
80
+ */
81
+ export interface TerminalHarness {
82
+ vt: VirtualTerminal;
83
+ logUpdate: ReturnType<typeof createLogUpdate>;
84
+ stdoutWrite: (s: string) => void;
85
+ /**
86
+ * Out-of-band write that lands in the same VT as `logUpdate` and
87
+ * `stdoutWrite` — mirrors how a real pty merges stderr writes with stdout
88
+ * when both descriptors point at the same terminal. log-update has no
89
+ * knowledge of these writes, so they advance the cursor in ways
90
+ * `previousLineCount` cannot account for. Use this to reproduce the
91
+ * Mechanism #2-class bug (out-of-band writes break log-update's cursor
92
+ * model) that #647 AC-1 capture diagnosed.
93
+ */
94
+ stderrWrite: (s: string) => void;
95
+ }
96
+ export interface HarnessOptions extends VirtualTerminalOptions {
97
+ /**
98
+ * Width log-update is told about via `stream.columns`. Defaults to
99
+ * `opts.cols` (matched terminal). Override to simulate a mismatch where
100
+ * log-update wraps at one width but the real terminal wraps at another.
101
+ */
102
+ streamColumns?: number;
103
+ /**
104
+ * Height log-update is told about via `stream.rows`. Defaults to
105
+ * `opts.rows`. Override to simulate `process.stdout.rows = undefined`
106
+ * (the `npx` symptom): pass `undefined` explicitly via the harness's stream
107
+ * by setting this to a non-positive number — log-update then falls through
108
+ * to its internal `defaultHeight ?? 24`.
109
+ */
110
+ streamRows?: number;
111
+ }
112
+ export declare function createTerminalHarness(opts: HarnessOptions): TerminalHarness;