sequant 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +8 -5
- package/dist/bin/cli.js +46 -4
- package/dist/src/commands/abort.d.ts +36 -0
- package/dist/src/commands/abort.js +138 -0
- package/dist/src/commands/prompt.d.ts +7 -0
- package/dist/src/commands/prompt.js +101 -7
- package/dist/src/commands/run-progress.d.ts +11 -1
- package/dist/src/commands/run-progress.js +20 -3
- package/dist/src/commands/run.js +12 -2
- package/dist/src/commands/watch.d.ts +2 -0
- package/dist/src/commands/watch.js +67 -3
- package/dist/src/lib/assess-collision-detect.js +1 -1
- package/dist/src/lib/cli-ui/run-renderer-types.d.ts +39 -0
- package/dist/src/lib/cli-ui/run-renderer.d.ts +27 -1
- package/dist/src/lib/cli-ui/run-renderer.js +231 -14
- package/dist/src/lib/cli-ui/scrollback-harness.d.ts +112 -0
- package/dist/src/lib/cli-ui/scrollback-harness.js +294 -0
- package/dist/src/lib/merge-check/types.js +1 -1
- package/dist/src/lib/relay/archive.js +6 -0
- package/dist/src/lib/relay/types.d.ts +2 -0
- package/dist/src/lib/relay/types.js +9 -0
- package/dist/src/lib/workflow/batch-executor.js +34 -18
- package/dist/src/lib/workflow/drivers/agent-driver.d.ts +48 -1
- package/dist/src/lib/workflow/drivers/aider.d.ts +7 -1
- package/dist/src/lib/workflow/drivers/aider.js +9 -0
- package/dist/src/lib/workflow/drivers/claude-code.d.ts +17 -1
- package/dist/src/lib/workflow/drivers/claude-code.js +51 -2
- package/dist/src/lib/workflow/drivers/index.d.ts +1 -1
- package/dist/src/lib/workflow/event-emitter.d.ts +157 -0
- package/dist/src/lib/workflow/event-emitter.js +102 -0
- package/dist/src/lib/workflow/notice.d.ts +32 -0
- package/dist/src/lib/workflow/notice.js +38 -0
- package/dist/src/lib/workflow/phase-executor.d.ts +9 -21
- package/dist/src/lib/workflow/phase-executor.js +88 -115
- package/dist/src/lib/workflow/phase-mapper.d.ts +26 -13
- package/dist/src/lib/workflow/phase-mapper.js +55 -33
- package/dist/src/lib/workflow/phase-registry.d.ts +127 -0
- package/dist/src/lib/workflow/phase-registry.js +233 -0
- package/dist/src/lib/workflow/run-log-schema.d.ts +5 -55
- package/dist/src/lib/workflow/run-orchestrator.d.ts +32 -2
- package/dist/src/lib/workflow/run-orchestrator.js +125 -11
- package/dist/src/lib/workflow/state-manager.d.ts +19 -1
- package/dist/src/lib/workflow/state-manager.js +27 -1
- package/dist/src/lib/workflow/state-schema.d.ts +20 -35
- package/dist/src/lib/workflow/state-schema.js +28 -3
- package/dist/src/lib/workflow/types.d.ts +65 -15
- package/dist/src/lib/workflow/types.js +18 -13
- package/package.json +5 -4
- package/templates/hooks/post-tool.sh +81 -0
- package/templates/skills/assess/SKILL.md +28 -28
- package/templates/skills/assess/references/predicted-collision-detection.md +1 -1
- package/templates/skills/setup/SKILL.md +6 -6
|
@@ -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;
|
|
@@ -0,0 +1,294 @@
|
|
|
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
|
+
const ESC = "";
|
|
24
|
+
/**
|
|
25
|
+
* Minimal vt100 model: visible grid + scrollback + cursor. Strips SGR colour
|
|
26
|
+
* codes (they're styling, not content) and ignores private-mode toggles
|
|
27
|
+
* (cursor hide/show). Implements the cursor and erase escapes that
|
|
28
|
+
* `log-update@7` actually emits.
|
|
29
|
+
*/
|
|
30
|
+
export class VirtualTerminal {
|
|
31
|
+
rows;
|
|
32
|
+
cols;
|
|
33
|
+
onlcr;
|
|
34
|
+
/** visible[row][col] = char (always single-codepoint slot). */
|
|
35
|
+
visible;
|
|
36
|
+
/** Scrollback grows oldest-first as rows shift off the top. */
|
|
37
|
+
scrollback = [];
|
|
38
|
+
cursorRow = 0;
|
|
39
|
+
cursorCol = 0;
|
|
40
|
+
constructor(opts) {
|
|
41
|
+
this.rows = opts.rows;
|
|
42
|
+
this.cols = opts.cols;
|
|
43
|
+
this.onlcr = opts.onlcr ?? true;
|
|
44
|
+
this.visible = Array.from({ length: this.rows }, () => Array(this.cols).fill(" "));
|
|
45
|
+
}
|
|
46
|
+
// ---------------------------------------------------------------- input
|
|
47
|
+
write(text) {
|
|
48
|
+
let i = 0;
|
|
49
|
+
while (i < text.length) {
|
|
50
|
+
const ch = text[i];
|
|
51
|
+
if (ch === ESC) {
|
|
52
|
+
i = this.handleEscape(text, i);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (ch === "\n") {
|
|
56
|
+
this.linefeed();
|
|
57
|
+
i++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (ch === "\r") {
|
|
61
|
+
this.cursorCol = 0;
|
|
62
|
+
i++;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (ch === "\b") {
|
|
66
|
+
if (this.cursorCol > 0)
|
|
67
|
+
this.cursorCol--;
|
|
68
|
+
i++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
this.putChar(ch);
|
|
72
|
+
i++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// ---------------------------------------------------------------- output
|
|
76
|
+
/** Visible viewport as a list of trimmed-right rows. */
|
|
77
|
+
getVisibleLines() {
|
|
78
|
+
return this.visible.map((row) => row.join("").replace(/\s+$/, ""));
|
|
79
|
+
}
|
|
80
|
+
/** Single multi-line string of (scrollback + visible). */
|
|
81
|
+
getAllText() {
|
|
82
|
+
const visibleText = this.getVisibleLines().join("\n");
|
|
83
|
+
if (this.scrollback.length === 0)
|
|
84
|
+
return visibleText;
|
|
85
|
+
return this.scrollback.join("\n") + "\n" + visibleText;
|
|
86
|
+
}
|
|
87
|
+
/** Match count of the regex against (scrollback + visible). */
|
|
88
|
+
countOccurrences(pattern) {
|
|
89
|
+
const text = this.getAllText();
|
|
90
|
+
const flags = pattern.flags.includes("g")
|
|
91
|
+
? pattern.flags
|
|
92
|
+
: pattern.flags + "g";
|
|
93
|
+
const globalPattern = new RegExp(pattern.source, flags);
|
|
94
|
+
const matches = text.match(globalPattern);
|
|
95
|
+
return matches?.length ?? 0;
|
|
96
|
+
}
|
|
97
|
+
// ------------------------------------------------------- internal: text
|
|
98
|
+
putChar(ch) {
|
|
99
|
+
if (this.cursorCol >= this.cols) {
|
|
100
|
+
// Auto-wrap into the next row. Most terminals do this; log-update wraps
|
|
101
|
+
// upstream so this rarely triggers in practice.
|
|
102
|
+
this.cursorCol = 0;
|
|
103
|
+
this.linefeed();
|
|
104
|
+
}
|
|
105
|
+
this.visible[this.cursorRow][this.cursorCol] = ch;
|
|
106
|
+
this.cursorCol++;
|
|
107
|
+
}
|
|
108
|
+
linefeed() {
|
|
109
|
+
if (this.cursorRow + 1 < this.rows) {
|
|
110
|
+
this.cursorRow++;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
// Bottom of viewport: scroll the top row into scrollback.
|
|
114
|
+
const top = this.visible.shift();
|
|
115
|
+
this.scrollback.push(top.join("").replace(/\s+$/, ""));
|
|
116
|
+
this.visible.push(Array(this.cols).fill(" "));
|
|
117
|
+
// Cursor stays clamped at last visible row.
|
|
118
|
+
}
|
|
119
|
+
if (this.onlcr)
|
|
120
|
+
this.cursorCol = 0;
|
|
121
|
+
}
|
|
122
|
+
// --------------------------------------------------- internal: escapes
|
|
123
|
+
/** Returns the index AFTER the consumed escape sequence. */
|
|
124
|
+
handleEscape(text, start) {
|
|
125
|
+
// Bare ESC at end → consume.
|
|
126
|
+
if (start + 1 >= text.length)
|
|
127
|
+
return text.length;
|
|
128
|
+
const next = text[start + 1];
|
|
129
|
+
// CSI: ESC [ ... <final>
|
|
130
|
+
if (next === "[") {
|
|
131
|
+
return this.handleCSI(text, start + 2);
|
|
132
|
+
}
|
|
133
|
+
// OSC: ESC ] ... BEL or ESC \
|
|
134
|
+
if (next === "]") {
|
|
135
|
+
let i = start + 2;
|
|
136
|
+
while (i < text.length) {
|
|
137
|
+
if (text[i] === "")
|
|
138
|
+
return i + 1;
|
|
139
|
+
if (text[i] === ESC && text[i + 1] === "\\")
|
|
140
|
+
return i + 2;
|
|
141
|
+
i++;
|
|
142
|
+
}
|
|
143
|
+
return text.length;
|
|
144
|
+
}
|
|
145
|
+
// 2-byte non-CSI escapes: ESC 7 / ESC 8 (save/restore — cursor only,
|
|
146
|
+
// safe to ignore for our uses).
|
|
147
|
+
return start + 2;
|
|
148
|
+
}
|
|
149
|
+
handleCSI(text, start) {
|
|
150
|
+
let i = start;
|
|
151
|
+
let isPrivate = false;
|
|
152
|
+
if (text[i] === "?" || text[i] === ">" || text[i] === "<") {
|
|
153
|
+
isPrivate = true;
|
|
154
|
+
i++;
|
|
155
|
+
}
|
|
156
|
+
let params = "";
|
|
157
|
+
while (i < text.length && /[0-9;]/.test(text[i])) {
|
|
158
|
+
params += text[i];
|
|
159
|
+
i++;
|
|
160
|
+
}
|
|
161
|
+
if (i >= text.length)
|
|
162
|
+
return text.length;
|
|
163
|
+
const final = text[i];
|
|
164
|
+
i++;
|
|
165
|
+
this.executeCSI(params, final, isPrivate);
|
|
166
|
+
return i;
|
|
167
|
+
}
|
|
168
|
+
executeCSI(params, final, isPrivate) {
|
|
169
|
+
const parts = params.length === 0 ? [] : params.split(";").map((p) => parseInt(p, 10));
|
|
170
|
+
const n = (idx, def) => {
|
|
171
|
+
const v = parts[idx];
|
|
172
|
+
return v === undefined || isNaN(v) ? def : v;
|
|
173
|
+
};
|
|
174
|
+
// Private modes (e.g. ?25l/?25h cursor hide/show) — ignore.
|
|
175
|
+
if (isPrivate)
|
|
176
|
+
return;
|
|
177
|
+
switch (final) {
|
|
178
|
+
case "A": // cursor up
|
|
179
|
+
this.cursorRow = Math.max(0, this.cursorRow - n(0, 1));
|
|
180
|
+
return;
|
|
181
|
+
case "B": // cursor down (no scroll)
|
|
182
|
+
this.cursorRow = Math.min(this.rows - 1, this.cursorRow + n(0, 1));
|
|
183
|
+
return;
|
|
184
|
+
case "C": // cursor forward
|
|
185
|
+
this.cursorCol = Math.min(this.cols - 1, this.cursorCol + n(0, 1));
|
|
186
|
+
return;
|
|
187
|
+
case "D": // cursor back
|
|
188
|
+
this.cursorCol = Math.max(0, this.cursorCol - n(0, 1));
|
|
189
|
+
return;
|
|
190
|
+
case "E": // cursor next line
|
|
191
|
+
this.cursorRow = Math.min(this.rows - 1, this.cursorRow + n(0, 1));
|
|
192
|
+
this.cursorCol = 0;
|
|
193
|
+
return;
|
|
194
|
+
case "F": // cursor prev line
|
|
195
|
+
this.cursorRow = Math.max(0, this.cursorRow - n(0, 1));
|
|
196
|
+
this.cursorCol = 0;
|
|
197
|
+
return;
|
|
198
|
+
case "G": // cursor absolute column (1-based)
|
|
199
|
+
this.cursorCol = Math.min(this.cols - 1, Math.max(0, n(0, 1) - 1));
|
|
200
|
+
return;
|
|
201
|
+
case "H": // cursor position (1-based row;col)
|
|
202
|
+
case "f":
|
|
203
|
+
this.cursorRow = Math.min(this.rows - 1, Math.max(0, n(0, 1) - 1));
|
|
204
|
+
this.cursorCol = Math.min(this.cols - 1, Math.max(0, n(1, 1) - 1));
|
|
205
|
+
return;
|
|
206
|
+
case "J": {
|
|
207
|
+
// erase in display
|
|
208
|
+
const mode = n(0, 0);
|
|
209
|
+
if (mode === 0)
|
|
210
|
+
this.eraseFromCursorToEndOfScreen();
|
|
211
|
+
else if (mode === 1)
|
|
212
|
+
this.eraseFromStartOfScreenToCursor();
|
|
213
|
+
else if (mode === 2 || mode === 3)
|
|
214
|
+
this.eraseScreen();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
case "K": {
|
|
218
|
+
// erase in line
|
|
219
|
+
const mode = n(0, 0);
|
|
220
|
+
if (mode === 0)
|
|
221
|
+
this.eraseFromCursorToEndOfLine();
|
|
222
|
+
else if (mode === 1)
|
|
223
|
+
this.eraseFromStartOfLineToCursor();
|
|
224
|
+
else if (mode === 2)
|
|
225
|
+
this.eraseLine();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
case "S": // scroll up
|
|
229
|
+
case "T": // scroll down
|
|
230
|
+
case "m": // SGR colour — ignore (we don't model styling)
|
|
231
|
+
case "s": // save cursor
|
|
232
|
+
case "u": // restore cursor
|
|
233
|
+
case "n": // device status report — ignore
|
|
234
|
+
case "h": // set mode — ignore
|
|
235
|
+
case "l": // reset mode — ignore
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
eraseLine() {
|
|
240
|
+
for (let c = 0; c < this.cols; c++)
|
|
241
|
+
this.visible[this.cursorRow][c] = " ";
|
|
242
|
+
}
|
|
243
|
+
eraseFromCursorToEndOfLine() {
|
|
244
|
+
for (let c = this.cursorCol; c < this.cols; c++)
|
|
245
|
+
this.visible[this.cursorRow][c] = " ";
|
|
246
|
+
}
|
|
247
|
+
eraseFromStartOfLineToCursor() {
|
|
248
|
+
for (let c = 0; c <= this.cursorCol; c++)
|
|
249
|
+
this.visible[this.cursorRow][c] = " ";
|
|
250
|
+
}
|
|
251
|
+
eraseFromCursorToEndOfScreen() {
|
|
252
|
+
this.eraseFromCursorToEndOfLine();
|
|
253
|
+
for (let r = this.cursorRow + 1; r < this.rows; r++) {
|
|
254
|
+
for (let c = 0; c < this.cols; c++)
|
|
255
|
+
this.visible[r][c] = " ";
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
eraseFromStartOfScreenToCursor() {
|
|
259
|
+
for (let r = 0; r < this.cursorRow; r++) {
|
|
260
|
+
for (let c = 0; c < this.cols; c++)
|
|
261
|
+
this.visible[r][c] = " ";
|
|
262
|
+
}
|
|
263
|
+
this.eraseFromStartOfLineToCursor();
|
|
264
|
+
}
|
|
265
|
+
eraseScreen() {
|
|
266
|
+
for (let r = 0; r < this.rows; r++) {
|
|
267
|
+
for (let c = 0; c < this.cols; c++)
|
|
268
|
+
this.visible[r][c] = " ";
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
export function createTerminalHarness(opts) {
|
|
273
|
+
const vt = new VirtualTerminal(opts);
|
|
274
|
+
const stream = {
|
|
275
|
+
write: (chunk) => {
|
|
276
|
+
vt.write(chunk);
|
|
277
|
+
return true;
|
|
278
|
+
},
|
|
279
|
+
columns: opts.streamColumns ?? opts.cols,
|
|
280
|
+
rows: opts.streamRows ?? opts.rows,
|
|
281
|
+
isTTY: true,
|
|
282
|
+
};
|
|
283
|
+
// log-update reads `stream.columns` / `stream.rows` defensively; the cast is
|
|
284
|
+
// safe because we exercise only those fields plus `write`.
|
|
285
|
+
const lu = createLogUpdate(stream, {
|
|
286
|
+
showCursor: true,
|
|
287
|
+
});
|
|
288
|
+
return {
|
|
289
|
+
vt,
|
|
290
|
+
logUpdate: lu,
|
|
291
|
+
stdoutWrite: (s) => vt.write(s),
|
|
292
|
+
stderrWrite: (s) => vt.write(s),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
@@ -19,7 +19,7 @@ export const BatchVerdictSchema = z.enum(BATCH_VERDICTS);
|
|
|
19
19
|
*/
|
|
20
20
|
export const DEFAULT_MIRROR_PAIRS = [
|
|
21
21
|
{ source: ".claude/skills", target: "templates/skills" },
|
|
22
|
-
{ source: "hooks", target: "templates/hooks" },
|
|
22
|
+
{ source: ".claude/hooks", target: "templates/hooks" },
|
|
23
23
|
];
|
|
24
24
|
/**
|
|
25
25
|
* Get the git ref to use for diff/merge operations on a branch.
|
|
@@ -57,6 +57,10 @@ export function archiveRelayDir(issue, options) {
|
|
|
57
57
|
copyFileSync(src, join(destDir, name));
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
+
// Split inbox/outbox counts (#645, Gap 5). Surfaces unanswered queries
|
|
61
|
+
// (inboxCount > outboxCount) when inspecting archives post-hoc.
|
|
62
|
+
const inboxCount = countLines(join(srcDir, RELAY_INBOX));
|
|
63
|
+
const outboxCount = countLines(join(srcDir, RELAY_OUTBOX));
|
|
60
64
|
// Write meta.json.
|
|
61
65
|
const meta = RelayArchiveMetaSchema.parse({
|
|
62
66
|
issue,
|
|
@@ -64,6 +68,8 @@ export function archiveRelayDir(issue, options) {
|
|
|
64
68
|
startedAt: options.startedAt,
|
|
65
69
|
endedAt,
|
|
66
70
|
messageCount: options.messageCount,
|
|
71
|
+
inboxCount,
|
|
72
|
+
outboxCount,
|
|
67
73
|
});
|
|
68
74
|
writeFileSync(join(destDir, "meta.json"), JSON.stringify(meta, null, 2), "utf-8");
|
|
69
75
|
// Clear the working relay dir (inbox/outbox/cursor).
|
|
@@ -64,5 +64,7 @@ export declare const RelayArchiveMetaSchema: z.ZodObject<{
|
|
|
64
64
|
startedAt: z.ZodString;
|
|
65
65
|
endedAt: z.ZodString;
|
|
66
66
|
messageCount: z.ZodNumber;
|
|
67
|
+
inboxCount: z.ZodOptional<z.ZodNumber>;
|
|
68
|
+
outboxCount: z.ZodOptional<z.ZodNumber>;
|
|
67
69
|
}, z.core.$strip>;
|
|
68
70
|
export type RelayArchiveMeta = z.infer<typeof RelayArchiveMetaSchema>;
|
|
@@ -72,5 +72,14 @@ export const RelayArchiveMetaSchema = z.object({
|
|
|
72
72
|
phase: z.string(),
|
|
73
73
|
startedAt: z.string().datetime(),
|
|
74
74
|
endedAt: z.string().datetime(),
|
|
75
|
+
/** Total inbox + outbox messages exchanged during the run. */
|
|
75
76
|
messageCount: z.number().int().nonnegative(),
|
|
77
|
+
/**
|
|
78
|
+
* Inbox messages (user → agent). Split out from `messageCount` (#645, Gap 5)
|
|
79
|
+
* so post-hoc inspection can spot unanswered queries (inboxCount > outboxCount).
|
|
80
|
+
* Optional for backward compatibility with archives written before this split.
|
|
81
|
+
*/
|
|
82
|
+
inboxCount: z.number().int().nonnegative().optional(),
|
|
83
|
+
/** Outbox replies (agent → user). See `inboxCount` for context. */
|
|
84
|
+
outboxCount: z.number().int().nonnegative().optional(),
|
|
76
85
|
});
|
|
@@ -260,7 +260,7 @@ export function getEnvConfig() {
|
|
|
260
260
|
return config;
|
|
261
261
|
}
|
|
262
262
|
export async function executeBatch(issueNumbers, batchCtx) {
|
|
263
|
-
const { config, options, issueInfoMap, worktreeMap, logWriter, stateManager, shutdownManager, packageManager, baseBranch, onProgress, } = batchCtx;
|
|
263
|
+
const { config, options, issueInfoMap, worktreeMap, logWriter, stateManager, shutdownManager, packageManager, baseBranch, onProgress, onPhasePlan, phasePauseHandle, } = batchCtx;
|
|
264
264
|
const results = [];
|
|
265
265
|
for (const issueNumber of issueNumbers) {
|
|
266
266
|
// Check if shutdown was triggered
|
|
@@ -289,6 +289,8 @@ export async function executeBatch(issueNumbers, batchCtx) {
|
|
|
289
289
|
packageManager,
|
|
290
290
|
baseBranch,
|
|
291
291
|
onProgress,
|
|
292
|
+
onPhasePlan,
|
|
293
|
+
phasePauseHandle,
|
|
292
294
|
};
|
|
293
295
|
const result = await runIssueWithLogging(ctx);
|
|
294
296
|
results.push(result);
|
|
@@ -305,7 +307,7 @@ export async function executeBatch(issueNumbers, batchCtx) {
|
|
|
305
307
|
}
|
|
306
308
|
export async function runIssueWithLogging(ctx) {
|
|
307
309
|
// Destructure context for use throughout the function
|
|
308
|
-
const { issueNumber, config, options, title: issueTitle, labels, services: { logWriter, stateManager, shutdownManager }, worktree, chain, packageManager, baseBranch, onProgress, } = ctx;
|
|
310
|
+
const { issueNumber, config, options, title: issueTitle, labels, services: { logWriter, stateManager, shutdownManager }, worktree, chain, packageManager, baseBranch, onProgress, onPhasePlan, phasePauseHandle, } = ctx;
|
|
309
311
|
const worktreePath = worktree?.path;
|
|
310
312
|
const branch = worktree?.branch;
|
|
311
313
|
const chainMode = chain?.enabled;
|
|
@@ -313,7 +315,8 @@ export async function runIssueWithLogging(ctx) {
|
|
|
313
315
|
const startTime = Date.now();
|
|
314
316
|
const phaseResults = [];
|
|
315
317
|
let loopTriggered = false;
|
|
316
|
-
|
|
318
|
+
// Cross-phase resume token, driver-tagged and cwd-bound (#674).
|
|
319
|
+
let resumeHandle;
|
|
317
320
|
// In parallel mode, suppress per-issue terminal output to prevent interleaving.
|
|
318
321
|
// The caller (run.ts) handles progress display via updateProgress().
|
|
319
322
|
const log = config.parallel ? () => { } : console.log.bind(console);
|
|
@@ -399,15 +402,15 @@ export async function runIssueWithLogging(ctx) {
|
|
|
399
402
|
}
|
|
400
403
|
const specStartTime = new Date();
|
|
401
404
|
// Note: spec runs in main repo (not worktree) for planning
|
|
402
|
-
const specResult = await executePhaseWithRetry(issueNumber, "spec", withActivityHook(config, issueNumber, "spec", onProgress),
|
|
403
|
-
shutdownManager);
|
|
405
|
+
const specResult = await executePhaseWithRetry(issueNumber, "spec", withActivityHook(config, issueNumber, "spec", onProgress), resumeHandle, worktreePath, // Will be ignored for spec (non-isolated phase)
|
|
406
|
+
shutdownManager, phasePauseHandle);
|
|
404
407
|
const specEndTime = new Date();
|
|
405
|
-
if (specResult.
|
|
406
|
-
|
|
407
|
-
//
|
|
408
|
+
if (specResult.resumeHandle) {
|
|
409
|
+
resumeHandle = specResult.resumeHandle;
|
|
410
|
+
// Persist resume token + originCwd for cross-process resume (#674).
|
|
408
411
|
if (stateManager) {
|
|
409
412
|
try {
|
|
410
|
-
await stateManager.
|
|
413
|
+
await stateManager.updateResumeHandle(issueNumber, specResult.resumeHandle);
|
|
411
414
|
}
|
|
412
415
|
catch {
|
|
413
416
|
// State tracking errors shouldn't stop execution
|
|
@@ -568,6 +571,20 @@ export async function runIssueWithLogging(ctx) {
|
|
|
568
571
|
}
|
|
569
572
|
}
|
|
570
573
|
}
|
|
574
|
+
// #672 AC-2: surface the resolved phase pipeline to the renderer so it can
|
|
575
|
+
// seed pending cells for every phase before any one of them fires. This
|
|
576
|
+
// runs once per issue after all phase-list mutations (auto-detect, resume
|
|
577
|
+
// filter, testgen/security-review insertion). The full pipeline for the row
|
|
578
|
+
// is `spec` (if it already ran) plus the remaining `phases` array.
|
|
579
|
+
if (onPhasePlan) {
|
|
580
|
+
const fullPlan = specAlreadyRan ? ["spec", ...phases] : [...phases];
|
|
581
|
+
try {
|
|
582
|
+
onPhasePlan(issueNumber, fullPlan);
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
/* renderer wiring errors must not halt execution */
|
|
586
|
+
}
|
|
587
|
+
}
|
|
571
588
|
// Build per-issue config with issue type metadata for skill env propagation
|
|
572
589
|
const lowerLabelsForType = labels.map((l) => l.toLowerCase());
|
|
573
590
|
const issueIsDocs = lowerLabelsForType.some((label) => DOCS_LABELS.some((docsLabel) => label === docsLabel));
|
|
@@ -610,15 +627,14 @@ export async function runIssueWithLogging(ctx) {
|
|
|
610
627
|
}
|
|
611
628
|
}
|
|
612
629
|
const phaseStartTime = new Date();
|
|
613
|
-
const result = await executePhaseWithRetry(issueNumber, phase, withActivityHook(issueConfig, issueNumber, phase, onProgress),
|
|
630
|
+
const result = await executePhaseWithRetry(issueNumber, phase, withActivityHook(issueConfig, issueNumber, phase, onProgress), resumeHandle, worktreePath, shutdownManager, phasePauseHandle);
|
|
614
631
|
const phaseEndTime = new Date();
|
|
615
|
-
// Capture
|
|
616
|
-
if (result.
|
|
617
|
-
|
|
618
|
-
// Update session ID in state for resume capability
|
|
632
|
+
// Capture resume handle for subsequent phases (#674).
|
|
633
|
+
if (result.resumeHandle) {
|
|
634
|
+
resumeHandle = result.resumeHandle;
|
|
619
635
|
if (stateManager) {
|
|
620
636
|
try {
|
|
621
|
-
await stateManager.
|
|
637
|
+
await stateManager.updateResumeHandle(issueNumber, result.resumeHandle);
|
|
622
638
|
}
|
|
623
639
|
catch {
|
|
624
640
|
// State tracking errors shouldn't stop execution
|
|
@@ -732,7 +748,7 @@ export async function runIssueWithLogging(ctx) {
|
|
|
732
748
|
promptContext: buildLoopContext(result),
|
|
733
749
|
};
|
|
734
750
|
const loopStartTime = new Date();
|
|
735
|
-
const loopResult = await executePhaseWithRetry(issueNumber, "loop", withActivityHook(loopConfig, issueNumber, "loop", onProgress),
|
|
751
|
+
const loopResult = await executePhaseWithRetry(issueNumber, "loop", withActivityHook(loopConfig, issueNumber, "loop", onProgress), resumeHandle, worktreePath, shutdownManager, phasePauseHandle);
|
|
736
752
|
const loopEndTime = new Date();
|
|
737
753
|
phaseResults.push(loopResult);
|
|
738
754
|
// Emit loop completion/failure progress event (AC-8)
|
|
@@ -757,8 +773,8 @@ export async function runIssueWithLogging(ctx) {
|
|
|
757
773
|
/* progress errors must not halt */
|
|
758
774
|
}
|
|
759
775
|
}
|
|
760
|
-
if (loopResult.
|
|
761
|
-
|
|
776
|
+
if (loopResult.resumeHandle) {
|
|
777
|
+
resumeHandle = loopResult.resumeHandle;
|
|
762
778
|
}
|
|
763
779
|
if (loopResult.success) {
|
|
764
780
|
// Continue to next iteration
|
|
@@ -5,6 +5,22 @@
|
|
|
5
5
|
* Continue.dev, Copilot SDK, Cursor API) can be added by implementing this
|
|
6
6
|
* interface without touching orchestration logic.
|
|
7
7
|
*/
|
|
8
|
+
/**
|
|
9
|
+
* Resume handle for a previous agent session.
|
|
10
|
+
*
|
|
11
|
+
* Replaces the opaque `sessionId` string with a driver-tagged value that
|
|
12
|
+
* records the cwd the session was created in. Drivers use this to enforce
|
|
13
|
+
* cwd-safe resume (Claude Code: session storage is cwd-namespaced; Codex:
|
|
14
|
+
* cwd-independent SDK requires driver-side gating). See #674.
|
|
15
|
+
*/
|
|
16
|
+
export interface ResumeHandle {
|
|
17
|
+
/** Driver name that created this handle (e.g. "claude-code", "codex"). */
|
|
18
|
+
driver: string;
|
|
19
|
+
/** Driver-specific resume token (session id, thread id, etc.). */
|
|
20
|
+
token: string;
|
|
21
|
+
/** Absolute cwd the session was created in. */
|
|
22
|
+
originCwd: string;
|
|
23
|
+
}
|
|
8
24
|
/**
|
|
9
25
|
* Configuration passed to an agent for phase execution.
|
|
10
26
|
*/
|
|
@@ -15,8 +31,17 @@ export interface AgentExecutionConfig {
|
|
|
15
31
|
phaseTimeout: number;
|
|
16
32
|
verbose: boolean;
|
|
17
33
|
mcp: boolean;
|
|
18
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Resume a previous session (driver-specific; ignored if unsupported).
|
|
36
|
+
*
|
|
37
|
+
* @deprecated Use {@link resumeHandle}. The opaque `sessionId` field is
|
|
38
|
+
* retained for one release to keep in-flight `.sequant/state.json` records
|
|
39
|
+
* resumable across upgrade. Drivers MUST prefer `resumeHandle` when both
|
|
40
|
+
* are set. See #674.
|
|
41
|
+
*/
|
|
19
42
|
sessionId?: string;
|
|
43
|
+
/** Driver-tagged resume handle with originCwd for cwd-safe resume (#674). */
|
|
44
|
+
resumeHandle?: ResumeHandle;
|
|
20
45
|
/** Callback for streaming output */
|
|
21
46
|
onOutput?: (text: string) => void;
|
|
22
47
|
/** Callback for stderr */
|
|
@@ -30,7 +55,14 @@ export interface AgentExecutionConfig {
|
|
|
30
55
|
export interface AgentPhaseResult {
|
|
31
56
|
success: boolean;
|
|
32
57
|
output: string;
|
|
58
|
+
/**
|
|
59
|
+
* @deprecated Use {@link resumeHandle}. Retained as a mirror of
|
|
60
|
+
* `resumeHandle.token` for one release to ease state-file migration. See
|
|
61
|
+
* #674.
|
|
62
|
+
*/
|
|
33
63
|
sessionId?: string;
|
|
64
|
+
/** Driver-tagged resume handle for cwd-safe cross-phase resume (#674). */
|
|
65
|
+
resumeHandle?: ResumeHandle;
|
|
34
66
|
error?: string;
|
|
35
67
|
/** Last N lines of stderr captured via RingBuffer (#447) */
|
|
36
68
|
stderrTail?: string[];
|
|
@@ -53,4 +85,19 @@ export interface AgentDriver {
|
|
|
53
85
|
executePhase(prompt: string, config: AgentExecutionConfig): Promise<AgentPhaseResult>;
|
|
54
86
|
/** Check if this driver is available/configured */
|
|
55
87
|
isAvailable(): Promise<boolean>;
|
|
88
|
+
/**
|
|
89
|
+
* Decide whether a resume handle can be safely used for a target cwd.
|
|
90
|
+
*
|
|
91
|
+
* Implementations enforce the asymmetric resume contract (#674):
|
|
92
|
+
* - Claude Code: session storage is cwd-namespaced; resume only if cwds
|
|
93
|
+
* match byte-equal.
|
|
94
|
+
* - Codex (when added in #497): runtime is cwd-independent; the driver
|
|
95
|
+
* enforces cwd match (and AGENTS.md parity) to prevent silent
|
|
96
|
+
* misexecution.
|
|
97
|
+
* - Drivers without a session-resume concept return `false`.
|
|
98
|
+
*
|
|
99
|
+
* Drivers MUST also verify `handle.driver === this.name` and reject
|
|
100
|
+
* cross-driver handles.
|
|
101
|
+
*/
|
|
102
|
+
canResume(handle: ResumeHandle, targetCwd: string): boolean;
|
|
56
103
|
}
|
|
@@ -5,12 +5,18 @@
|
|
|
5
5
|
* for fully non-interactive phase execution. Sequant manages git,
|
|
6
6
|
* not Aider.
|
|
7
7
|
*/
|
|
8
|
-
import type { AgentDriver, AgentExecutionConfig, AgentPhaseResult } from "./agent-driver.js";
|
|
8
|
+
import type { AgentDriver, AgentExecutionConfig, AgentPhaseResult, ResumeHandle } from "./agent-driver.js";
|
|
9
9
|
import type { AiderSettings } from "../../settings.js";
|
|
10
10
|
export declare class AiderDriver implements AgentDriver {
|
|
11
11
|
name: string;
|
|
12
12
|
private settings?;
|
|
13
13
|
constructor(settings?: AiderSettings);
|
|
14
|
+
/**
|
|
15
|
+
* Aider has no session-resume concept: each invocation is a one-shot
|
|
16
|
+
* `aider --message <prompt>` against a fresh chat. There is no token to
|
|
17
|
+
* reattach to, so resume is always declined (#674).
|
|
18
|
+
*/
|
|
19
|
+
canResume(handle: ResumeHandle, targetCwd: string): boolean;
|
|
14
20
|
executePhase(prompt: string, config: AgentExecutionConfig): Promise<AgentPhaseResult>;
|
|
15
21
|
isAvailable(): Promise<boolean>;
|
|
16
22
|
/** Build the CLI argument list for aider. */
|