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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +125 -160
- package/dist/bin/cli.js +59 -4
- package/dist/dashboard/server.js +1 -0
- package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +2 -2
- package/dist/marketplace/external_plugins/sequant/README.md +6 -3
- package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +92 -0
- package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +18 -9
- package/dist/marketplace/external_plugins/sequant/hooks/relay-check.sh +107 -0
- package/dist/marketplace/external_plugins/sequant/skills/_shared/references/behavior-rule-detection.md +205 -0
- package/dist/marketplace/external_plugins/sequant/skills/_shared/references/subagent-types.md +21 -8
- package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +302 -86
- package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +109 -0
- package/dist/marketplace/external_plugins/sequant/skills/docs/SKILL.md +141 -22
- package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +83 -78
- package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +377 -137
- package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +28 -0
- package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +621 -0
- package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +741 -232
- package/dist/marketplace/external_plugins/sequant/skills/qa/scripts/quality-checks.sh +47 -1
- package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +12 -6
- package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +217 -964
- package/dist/marketplace/external_plugins/sequant/skills/spec/references/parallel-groups.md +7 -0
- package/dist/marketplace/external_plugins/sequant/skills/spec/references/quality-checklist.md +75 -0
- package/dist/marketplace/external_plugins/sequant/skills/spec/references/recommended-workflow.md +4 -2
- package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +0 -27
- package/dist/marketplace/external_plugins/sequant/skills/testgen/SKILL.md +24 -44
- 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/ready-tui-adapter.d.ts +59 -0
- package/dist/src/commands/ready-tui-adapter.js +130 -0
- package/dist/src/commands/ready.d.ts +49 -0
- package/dist/src/commands/ready.js +243 -0
- 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/status.js +4 -0
- 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 +34 -2
- package/dist/src/lib/cli-ui/run-renderer.js +250 -33
- 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/settings.d.ts +34 -0
- package/dist/src/lib/settings.js +23 -1
- 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 +105 -117
- 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/platforms/github.d.ts +6 -0
- package/dist/src/lib/workflow/platforms/github.js +17 -0
- package/dist/src/lib/workflow/ready-gate.d.ts +155 -0
- package/dist/src/lib/workflow/ready-gate.js +374 -0
- package/dist/src/lib/workflow/reconcile.js +6 -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 +23 -35
- package/dist/src/lib/workflow/state-schema.js +29 -3
- package/dist/src/lib/workflow/types.d.ts +74 -15
- package/dist/src/lib/workflow/types.js +18 -13
- package/dist/src/ui/tui/App.js +8 -2
- package/dist/src/ui/tui/IssueBox.js +3 -4
- package/dist/src/ui/tui/index.d.ts +13 -4
- package/dist/src/ui/tui/index.js +19 -5
- package/dist/src/ui/tui/row-cap.d.ts +51 -0
- package/dist/src/ui/tui/row-cap.js +76 -0
- package/dist/src/ui/tui/teardown.d.ts +20 -0
- package/dist/src/ui/tui/teardown.js +29 -0
- package/dist/src/ui/tui/theme.d.ts +3 -0
- package/dist/src/ui/tui/theme.js +3 -0
- package/package.json +23 -11
- 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/qa/SKILL.md +5 -2
- 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
|
-
|
|
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);
|
|
@@ -728,13 +922,25 @@ export class TTYRenderer extends BaseRenderer {
|
|
|
728
922
|
return lines.join("\n");
|
|
729
923
|
}
|
|
730
924
|
renderSingleIssueFrame(cols) {
|
|
731
|
-
// AC-11:
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
961
|
-
const
|
|
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
|
-
|
|
971
|
-
out.push(` ${dim("│")} ${labelCell} ${dim("│")} ${valuePadded} ${dim("│")}`);
|
|
1192
|
+
out.push(` ${labelCell} ${line}`);
|
|
972
1193
|
});
|
|
973
|
-
|
|
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;
|