sequant 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +8 -5
  4. package/dist/bin/cli.js +46 -4
  5. package/dist/src/commands/abort.d.ts +36 -0
  6. package/dist/src/commands/abort.js +138 -0
  7. package/dist/src/commands/prompt.d.ts +7 -0
  8. package/dist/src/commands/prompt.js +101 -7
  9. package/dist/src/commands/run-progress.d.ts +11 -1
  10. package/dist/src/commands/run-progress.js +20 -3
  11. package/dist/src/commands/run.js +12 -2
  12. package/dist/src/commands/watch.d.ts +2 -0
  13. package/dist/src/commands/watch.js +67 -3
  14. package/dist/src/lib/assess-collision-detect.js +1 -1
  15. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +39 -0
  16. package/dist/src/lib/cli-ui/run-renderer.d.ts +27 -1
  17. package/dist/src/lib/cli-ui/run-renderer.js +231 -14
  18. package/dist/src/lib/cli-ui/scrollback-harness.d.ts +112 -0
  19. package/dist/src/lib/cli-ui/scrollback-harness.js +294 -0
  20. package/dist/src/lib/merge-check/types.js +1 -1
  21. package/dist/src/lib/relay/archive.js +6 -0
  22. package/dist/src/lib/relay/types.d.ts +2 -0
  23. package/dist/src/lib/relay/types.js +9 -0
  24. package/dist/src/lib/workflow/batch-executor.js +34 -18
  25. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +48 -1
  26. package/dist/src/lib/workflow/drivers/aider.d.ts +7 -1
  27. package/dist/src/lib/workflow/drivers/aider.js +9 -0
  28. package/dist/src/lib/workflow/drivers/claude-code.d.ts +17 -1
  29. package/dist/src/lib/workflow/drivers/claude-code.js +51 -2
  30. package/dist/src/lib/workflow/drivers/index.d.ts +1 -1
  31. package/dist/src/lib/workflow/event-emitter.d.ts +157 -0
  32. package/dist/src/lib/workflow/event-emitter.js +102 -0
  33. package/dist/src/lib/workflow/notice.d.ts +32 -0
  34. package/dist/src/lib/workflow/notice.js +38 -0
  35. package/dist/src/lib/workflow/phase-executor.d.ts +9 -21
  36. package/dist/src/lib/workflow/phase-executor.js +88 -115
  37. package/dist/src/lib/workflow/phase-mapper.d.ts +26 -13
  38. package/dist/src/lib/workflow/phase-mapper.js +55 -33
  39. package/dist/src/lib/workflow/phase-registry.d.ts +127 -0
  40. package/dist/src/lib/workflow/phase-registry.js +233 -0
  41. package/dist/src/lib/workflow/run-log-schema.d.ts +5 -55
  42. package/dist/src/lib/workflow/run-orchestrator.d.ts +32 -2
  43. package/dist/src/lib/workflow/run-orchestrator.js +125 -11
  44. package/dist/src/lib/workflow/state-manager.d.ts +19 -1
  45. package/dist/src/lib/workflow/state-manager.js +27 -1
  46. package/dist/src/lib/workflow/state-schema.d.ts +20 -35
  47. package/dist/src/lib/workflow/state-schema.js +28 -3
  48. package/dist/src/lib/workflow/types.d.ts +65 -15
  49. package/dist/src/lib/workflow/types.js +18 -13
  50. package/package.json +5 -4
  51. package/templates/hooks/post-tool.sh +81 -0
  52. package/templates/skills/assess/SKILL.md +28 -28
  53. package/templates/skills/assess/references/predicted-collision-detection.md +1 -1
  54. package/templates/skills/setup/SKILL.md +6 -6
@@ -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
- let sessionId;
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), sessionId, worktreePath, // Will be ignored for spec (non-isolated phase)
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.sessionId) {
406
- sessionId = specResult.sessionId;
407
- // Update session ID in state for resume capability
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.updateSessionId(issueNumber, specResult.sessionId);
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), sessionId, worktreePath, shutdownManager);
630
+ const result = await executePhaseWithRetry(issueNumber, phase, withActivityHook(issueConfig, issueNumber, phase, onProgress), resumeHandle, worktreePath, shutdownManager, phasePauseHandle);
614
631
  const phaseEndTime = new Date();
615
- // Capture session ID for subsequent phases
616
- if (result.sessionId) {
617
- sessionId = result.sessionId;
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.updateSessionId(issueNumber, result.sessionId);
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), sessionId, worktreePath, shutdownManager);
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.sessionId) {
761
- sessionId = loopResult.sessionId;
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
- /** Resume a previous session (driver-specific; ignored if unsupported) */
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. */