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
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "sequant",
10
10
  "description": "Structured workflow system for Claude Code - GitHub issue resolution with spec, exec, test, and QA phases. Includes 17 skills, MCP server with workflow tools, and pre/post-tool hooks.",
11
- "version": "2.3.0",
11
+ "version": "2.4.0",
12
12
  "author": {
13
13
  "name": "sequant-io",
14
14
  "email": "hello@sequant.io"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sequant",
3
3
  "description": "Structured workflow system for Claude Code - GitHub issue resolution with spec, exec, test, and QA phases",
4
- "version": "2.3.0",
4
+ "version": "2.4.0",
5
5
  "author": {
6
6
  "name": "sequant-io",
7
7
  "email": "hello@sequant.io"
package/README.md CHANGED
@@ -9,13 +9,16 @@ Solve GitHub issues with structured phases and quality gates — from issue to m
9
9
  [![npm version](https://img.shields.io/npm/v/sequant.svg)](https://www.npmjs.com/package/sequant)
10
10
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
11
11
 
12
- ### What's new in 2.0
12
+ ### What's new in 2.x
13
13
 
14
14
  - **MCP server** — `sequant serve` exposes workflow orchestration as MCP tools (`sequant_run`, `sequant_status`, `sequant_logs`). Any MCP client can drive Sequant headlessly.
15
15
  - **`/assess` unification** — `/solve` is merged into `/assess` with a 6-action vocabulary (PROCEED, CLOSE, MERGE, REWRITE, CLARIFY, PARK). `/solve` still works as an alias.
16
- - **Parallel execution** — multi-issue runs are concurrent by default with `--concurrency`.
17
- - **Multi-agent** — `--agent aider` as an alternative backend, with a driver interface for future agents.
18
- - **GitHub Actions** — label-triggered and comment-triggered CI workflows out of the box.
16
+ - **Parallel execution with per-issue locks** — multi-issue runs are concurrent by default (`--concurrency`); `.sequant/locks/` prevents two sessions from clobbering the same issue.
17
+ - **Stacked PRs** — `sequant run --stacked` chains issue PRs onto their predecessor branch so reviewers see the incremental diff per issue instead of the cumulative chain diff.
18
+ - **Live run dashboard + cohort analytics** — unified renderer with a two-zone TTY grid (active issues + append-only events log); `sequant stats --label <name> --since YYYY-MM-DD` filters runs for measuring feature- or class-specific success rates.
19
+ - **Interactive relay** — `sequant prompt <issue> "<message>"` sends queries or directives into a running headless `sequant run`, and `sequant watch <issue>` tails the replies. Bidirectional communication with detached and CI-driven sessions.
20
+ - **Worktree isolation for parallel agents** — `sequant run --isolate-parallel` gives each parallel `/exec` agent group its own sub-worktree with merge-back via `git merge --no-ff`, eliminating file conflicts between concurrent agents structurally rather than by convention.
21
+ - **GitHub Actions & multi-agent backends** — label-triggered and comment-triggered CI workflows out of the box; `--agent aider` for non-Claude-Code execution.
19
22
 
20
23
  Upgrading from v1.x? See the [migration guide](CHANGELOG.md#migration-from-v1x).
21
24
 
@@ -71,7 +74,7 @@ Or step-by-step:
71
74
  - Git (for worktree-based isolation)
72
75
 
73
76
  **For npm installation:**
74
- - Node.js 20+
77
+ - Node.js 22.12+
75
78
 
76
79
  **Optional MCP servers (enhanced features):**
77
80
  - `chrome-devtools` — enables `/test` for browser-based UI testing
package/dist/bin/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Sequential AI phases with quality gates for any codebase.
6
6
  */
7
- import { Command } from "commander";
7
+ import { Command, InvalidArgumentError } from "commander";
8
8
  import chalk from "chalk";
9
9
  import { fileURLToPath } from "url";
10
10
  import { dirname, resolve } from "path";
@@ -50,7 +50,29 @@ import { conventionsCommand } from "../src/commands/conventions.js";
50
50
  import { locksListCommand, locksClearCommand, locksAcquireCommand, locksReleaseCommand, locksCheckCommand, locksCheckBatchCommand, } from "../src/commands/locks.js";
51
51
  import { promptCommand } from "../src/commands/prompt.js";
52
52
  import { watchCommand } from "../src/commands/watch.js";
53
+ import { abortCommand } from "../src/commands/abort.js";
53
54
  import { getManifest } from "../src/lib/manifest.js";
55
+ import { phaseRegistry } from "../src/lib/workflow/phase-registry.js";
56
+ /**
57
+ * Validate `--phases` argument against the phase registry.
58
+ *
59
+ * Splits a comma-separated phase list, checks each name against
60
+ * `phaseRegistry`, and exits with a clear error message if any phase
61
+ * is unknown. Returns the original string unchanged so downstream
62
+ * `RunOptions.phases` parsing in `config-resolver.ts` is undisturbed.
63
+ */
64
+ function validatePhasesFlag(value) {
65
+ const names = value
66
+ .split(",")
67
+ .map((p) => p.trim())
68
+ .filter((p) => p.length > 0);
69
+ const unknown = names.filter((n) => !phaseRegistry.has(n));
70
+ if (unknown.length > 0) {
71
+ const available = phaseRegistry.names().join(", ");
72
+ throw new InvalidArgumentError(`Unknown phase '${unknown[0]}'. Available: ${available}`);
73
+ }
74
+ return value;
75
+ }
54
76
  const program = new Command();
55
77
  // Handle --no-color before parsing
56
78
  if (process.argv.includes("--no-color")) {
@@ -141,7 +163,7 @@ program
141
163
  .command("run")
142
164
  .description("Execute workflow for GitHub issues using Claude Agent SDK")
143
165
  .argument("[issues...]", "Issue numbers to process")
144
- .option("--phases <list>", "Phases to run (default: spec,exec,qa)")
166
+ .option("--phases <list>", "Phases to run (default: spec,exec,qa)", validatePhasesFlag)
145
167
  .option("--sequential", "Stop on first issue failure (default: continue)")
146
168
  .option("-d, --dry-run", "Preview without execution")
147
169
  .option("-v, --verbose", "Verbose output with streaming")
@@ -149,14 +171,14 @@ program
149
171
  .option("--log-json", "Enable structured JSON logging (default: true)")
150
172
  .option("--no-log", "Disable JSON logging for this run")
151
173
  .option("--log-path <path>", "Custom log directory path")
152
- .option("-q, --quality-loop", "Enable quality loop with auto-retry")
174
+ .option("-Q, --quality-loop", "Enable quality loop with auto-retry")
153
175
  .option("--max-iterations <n>", "Max iterations for quality loop (default: 3)", parseInt)
154
176
  .option("--batch <issues>", 'Group of issues to run together (e.g., --batch "1 2" --batch "3")', (value, prev) => prev.concat([value]), [])
155
177
  .option("--smart-tests", "Enable smart test detection (default)")
156
178
  .option("--no-smart-tests", "Disable smart test detection")
157
179
  .option("--testgen", "Run testgen phase after spec")
158
180
  .option("--security-review", "Run security-review phase after spec")
159
- .option("--quiet", "Suppress version warnings and non-essential output")
181
+ .option("-q, --quiet", "Suppress version warnings and non-essential output")
160
182
  .option("--chain", "Chain issues: each branches from previous (implies --sequential)")
161
183
  .option("--stacked", "Stack PRs: middle PRs target predecessor branch instead of main; first/last target main (implies --chain)")
162
184
  .option("--qa-gate", "Wait for QA pass before starting next issue in chain (requires --chain)")
@@ -180,12 +202,14 @@ program
180
202
  .description("Send a message into a running headless sequant session (#383)")
181
203
  .argument("[args...]", '[<issue>] "<message>"')
182
204
  .option("--type <type>", "Message type: query (default), directive, abort", "query")
205
+ .option("--wait <seconds>", "Block until a reply arrives or the timeout elapses (#645, Gap 4)", parseInt)
183
206
  .option("--json", "Output as JSON")
184
207
  .action((args, options) => {
185
208
  return promptCommand({
186
209
  args,
187
210
  options: {
188
211
  type: options.type,
212
+ waitSeconds: typeof options.wait === "number" ? options.wait : undefined,
189
213
  json: Boolean(options.json),
190
214
  },
191
215
  });
@@ -201,6 +225,24 @@ program
201
225
  options: { json: Boolean(options.json) },
202
226
  });
203
227
  });
228
+ program
229
+ .command("abort")
230
+ .description("Out-of-band abort: signal a running sequant session directly (#645)")
231
+ .argument("[issue]", "Issue number (auto-resolved when a single run is active)")
232
+ .option("--force", "Skip the SIGINT grace period; SIGTERM immediately")
233
+ .option("--grace <seconds>", "Seconds to wait after SIGINT before escalating (default: 10)", parseInt)
234
+ .option("--json", "Output as JSON")
235
+ .action((issueArg, options) => {
236
+ const args = issueArg === undefined ? [] : [issueArg];
237
+ return abortCommand({
238
+ args,
239
+ options: {
240
+ force: Boolean(options.force),
241
+ graceSeconds: typeof options.grace === "number" ? options.grace : undefined,
242
+ json: Boolean(options.json),
243
+ },
244
+ });
245
+ });
204
246
  program
205
247
  .command("merge")
206
248
  .description("Batch-level integration QA — verify feature branches before merging")
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `sequant abort <issue>` — out-of-band escape hatch for a running headless
3
+ * session (#645, Gap 7).
4
+ *
5
+ * `sequant prompt --type abort` queues an abort message into the inbox, which
6
+ * the agent must read via the PostToolUse hook chain. When that chain is
7
+ * broken (the bug originally reported in #645), no in-band abort can land.
8
+ *
9
+ * This command bypasses the inbox entirely: it locates the orchestrator PID
10
+ * via `state.json.relay.pid` (with the per-issue pidfile as fallback) and
11
+ * sends signals directly. The receiving end is the existing ShutdownManager
12
+ * in `sequant run`, which already performs a clean teardown on SIGINT/SIGTERM.
13
+ */
14
+ export interface AbortCommandOptions {
15
+ /** Skip the SIGINT grace period; SIGTERM immediately. */
16
+ force?: boolean;
17
+ /** Seconds to wait after SIGINT before escalating. Default 10. */
18
+ graceSeconds?: number;
19
+ json?: boolean;
20
+ /** Test seam: override the system kill function. */
21
+ killFn?: (pid: number, signal: NodeJS.Signals) => void;
22
+ /** Test seam: override the liveness check. */
23
+ isAlive?: (pid: number) => boolean;
24
+ /** Test seam: override the poll interval (ms). Default 250. */
25
+ pollIntervalMs?: number;
26
+ /** Test seam: override SIGTERM grace before SIGKILL (ms). Default 3000. */
27
+ sigtermTimeoutMs?: number;
28
+ /** Test seam: override SIGKILL final wait (ms). Default 2000. */
29
+ sigkillTimeoutMs?: number;
30
+ /** Test seam: override cwd for pidfile resolution. */
31
+ cwd?: string;
32
+ }
33
+ export declare function abortCommand(argsAndOptions: {
34
+ args: string[];
35
+ options: AbortCommandOptions;
36
+ }): Promise<void>;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * `sequant abort <issue>` — out-of-band escape hatch for a running headless
3
+ * session (#645, Gap 7).
4
+ *
5
+ * `sequant prompt --type abort` queues an abort message into the inbox, which
6
+ * the agent must read via the PostToolUse hook chain. When that chain is
7
+ * broken (the bug originally reported in #645), no in-band abort can land.
8
+ *
9
+ * This command bypasses the inbox entirely: it locates the orchestrator PID
10
+ * via `state.json.relay.pid` (with the per-issue pidfile as fallback) and
11
+ * sends signals directly. The receiving end is the existing ShutdownManager
12
+ * in `sequant run`, which already performs a clean teardown on SIGINT/SIGTERM.
13
+ */
14
+ import chalk from "chalk";
15
+ import { isPidAlive, readPidFile } from "../lib/relay/pid.js";
16
+ import { StateManager } from "../lib/workflow/state-manager.js";
17
+ import { findActiveIssues, resolveTargetIssue } from "./prompt.js";
18
+ function delay(ms) {
19
+ return new Promise((resolve) => setTimeout(resolve, ms));
20
+ }
21
+ /**
22
+ * Send `signal` and wait up to `timeoutMs` for the PID to exit. Returns true
23
+ * if the process died, false if still alive at the deadline.
24
+ */
25
+ async function signalAndWait(pid, signal, timeoutMs, isAlive, killFn, pollIntervalMs) {
26
+ try {
27
+ killFn(pid, signal);
28
+ }
29
+ catch {
30
+ // ESRCH (process already dead) → success.
31
+ if (!isAlive(pid))
32
+ return true;
33
+ throw new Error(`Failed to send ${signal} to PID ${pid} (signal not delivered)`);
34
+ }
35
+ const deadline = Date.now() + timeoutMs;
36
+ while (Date.now() < deadline) {
37
+ if (!isAlive(pid))
38
+ return true;
39
+ await delay(pollIntervalMs);
40
+ }
41
+ return !isAlive(pid);
42
+ }
43
+ export async function abortCommand(argsAndOptions) {
44
+ const { args, options } = argsAndOptions;
45
+ const json = Boolean(options.json);
46
+ const killFn = options.killFn ??
47
+ ((pid, signal) => {
48
+ process.kill(pid, signal);
49
+ });
50
+ const isAlive = options.isAlive ?? isPidAlive;
51
+ const pollIntervalMs = options.pollIntervalMs ?? 250;
52
+ const cwd = options.cwd ?? process.cwd();
53
+ // Resolve target issue: explicit arg or single-active auto-resolve.
54
+ const stateManager = new StateManager();
55
+ let issueNumber;
56
+ const issueArg = args[0];
57
+ if (issueArg !== undefined) {
58
+ const n = Number.parseInt(issueArg, 10);
59
+ if (!Number.isInteger(n) || n <= 0) {
60
+ throw new Error(`Invalid issue number: '${issueArg}'`);
61
+ }
62
+ issueNumber = n;
63
+ }
64
+ else {
65
+ const all = stateManager.stateExists()
66
+ ? Object.values(await stateManager.getAllIssueStates())
67
+ : [];
68
+ const active = findActiveIssues(all, isAlive, cwd);
69
+ const target = resolveTargetIssue({ explicit: null, activeIssues: active });
70
+ issueNumber = target.issue;
71
+ }
72
+ const issueState = await stateManager.getIssueState(issueNumber);
73
+ const pid = issueState?.relay?.pid ?? readPidFile(issueNumber, cwd) ?? null;
74
+ if (pid === null) {
75
+ const msg = `No relay PID found for #${issueNumber}. Is the run active?`;
76
+ if (json) {
77
+ console.log(JSON.stringify({ ok: false, issue: issueNumber, error: msg }));
78
+ }
79
+ else {
80
+ console.error(chalk.yellow(msg));
81
+ }
82
+ process.exitCode = 1;
83
+ return;
84
+ }
85
+ if (!isAlive(pid)) {
86
+ const msg = `PID ${pid} for #${issueNumber} is already dead.`;
87
+ if (json) {
88
+ console.log(JSON.stringify({ ok: true, issue: issueNumber, pid, signal: null }));
89
+ }
90
+ else {
91
+ console.log(chalk.gray(msg));
92
+ }
93
+ return;
94
+ }
95
+ const graceMs = Math.max(0, (options.graceSeconds ?? 10) * 1000);
96
+ const force = Boolean(options.force);
97
+ let delivered = "SIGINT";
98
+ let died = false;
99
+ if (!force) {
100
+ if (!json) {
101
+ console.log(chalk.gray(`Sending SIGINT to PID ${pid} (#${issueNumber}); waiting up to ${graceMs / 1000}s for graceful exit…`));
102
+ }
103
+ died = await signalAndWait(pid, "SIGINT", graceMs, isAlive, killFn, pollIntervalMs);
104
+ }
105
+ if (!died) {
106
+ delivered = "SIGTERM";
107
+ if (!json) {
108
+ console.log(chalk.yellow(force
109
+ ? `Sending SIGTERM to PID ${pid} (#${issueNumber}) (--force)…`
110
+ : `Grace expired; escalating to SIGTERM…`));
111
+ }
112
+ died = await signalAndWait(pid, "SIGTERM", options.sigtermTimeoutMs ?? 3000, isAlive, killFn, pollIntervalMs);
113
+ }
114
+ if (!died) {
115
+ delivered = "SIGKILL";
116
+ if (!json) {
117
+ console.log(chalk.red(`SIGTERM ignored; escalating to SIGKILL on PID ${pid}…`));
118
+ }
119
+ died = await signalAndWait(pid, "SIGKILL", options.sigkillTimeoutMs ?? 2000, isAlive, killFn, pollIntervalMs);
120
+ }
121
+ if (!died) {
122
+ process.exitCode = 1;
123
+ }
124
+ if (json) {
125
+ console.log(JSON.stringify({
126
+ ok: died,
127
+ issue: issueNumber,
128
+ pid,
129
+ signal: delivered,
130
+ }));
131
+ }
132
+ else if (died) {
133
+ console.log(chalk.green(`Aborted #${issueNumber} (PID ${pid}, ${delivered}).`));
134
+ }
135
+ else {
136
+ console.error(chalk.red(`Failed to abort #${issueNumber}: PID ${pid} still alive after ${delivered}.`));
137
+ }
138
+ }
@@ -7,6 +7,13 @@ import type { IssueState } from "../lib/workflow/state-schema.js";
7
7
  export interface PromptCommandOptions {
8
8
  type?: string;
9
9
  json?: boolean;
10
+ /**
11
+ * If set, poll the outbox for a reply that matches the new message ID and
12
+ * print it inline. Exits 0 on reply, 1 on timeout (#645, Gap 4).
13
+ */
14
+ waitSeconds?: number;
15
+ /** Test seam: override the poll interval (ms). Default 250. */
16
+ waitPollIntervalMs?: number;
10
17
  }
11
18
  export interface ParsedPromptArgs {
12
19
  issue: number | null;
@@ -2,10 +2,12 @@
2
2
  * `sequant prompt <issue> "<message>"` — send a message into a running
3
3
  * headless `sequant run` session via the interactive relay (#383).
4
4
  */
5
+ import { existsSync, statSync, readFileSync } from "fs";
5
6
  import chalk from "chalk";
6
- import { RelayMessageTypeSchema, } from "../lib/relay/types.js";
7
+ import { RelayMessageTypeSchema, RelayResponseSchema, } from "../lib/relay/types.js";
7
8
  import { appendInboxMessage } from "../lib/relay/writer.js";
8
9
  import { cleanupStalePid, readPidFile } from "../lib/relay/pid.js";
10
+ import { outboxPathFor } from "../lib/relay/paths.js";
9
11
  import { StateManager } from "../lib/workflow/state-manager.js";
10
12
  /** Validate raw CLI args. Throws on invalid type or empty message. */
11
13
  export function parseRelayPromptArgs(args, options = {}) {
@@ -150,26 +152,118 @@ export async function promptCommand(argsAndOptions) {
150
152
  catch {
151
153
  /* swallow */
152
154
  }
153
- // Build confirmation with current phase + elapsed time.
154
- const phase = issueState?.currentPhase ?? "unknown";
155
+ // Re-fetch state with a fresh manager to bypass any cached snapshot from
156
+ // the start-of-command read. Without this, `currentPhase` shown in the
157
+ // confirmation can be a phase-old (#645, Gap 6: user reported "exec phase"
158
+ // while state.json had advanced to qa).
159
+ let freshPhase;
160
+ let freshStartedAt;
161
+ try {
162
+ const freshState = await new StateManager().getIssueState(issueNumber);
163
+ freshPhase = freshState?.currentPhase;
164
+ freshStartedAt = freshState?.relay?.startedAt;
165
+ }
166
+ catch {
167
+ // Fall back to the issueState we already have.
168
+ freshPhase = issueState?.currentPhase;
169
+ freshStartedAt = issueState?.relay?.startedAt;
170
+ }
155
171
  let elapsedSegment = "";
156
- if (issueState?.relay?.startedAt) {
157
- const ms = Date.now() - new Date(issueState.relay.startedAt).getTime();
172
+ if (freshStartedAt) {
173
+ const ms = Date.now() - new Date(freshStartedAt).getTime();
158
174
  elapsedSegment = `, ${formatElapsed(ms)} elapsed`;
159
175
  }
160
- const confirmation = `Message sent to #${issueNumber} (${phase} phase${elapsedSegment})`;
176
+ // Omit the phase label when we don't have a fresh reading (#645, Gap 6)
177
+ // a wrong phase is worse than no phase. Callers asking for JSON still get
178
+ // an explicit `phase: null` for that case.
179
+ const phaseSegment = freshPhase
180
+ ? ` (${freshPhase} phase${elapsedSegment})`
181
+ : "";
182
+ const confirmation = `Message sent to #${issueNumber}${phaseSegment}`;
161
183
  if (options.json) {
162
184
  console.log(JSON.stringify({
163
185
  ok: true,
164
186
  issue: issueNumber,
165
187
  messageId: message.id,
166
188
  type: parsed.type,
167
- phase,
189
+ phase: freshPhase ?? null,
168
190
  }));
169
191
  }
170
192
  else {
171
193
  console.log(chalk.green(confirmation));
172
194
  }
195
+ // Optional --wait: poll outbox for a reply that references our message id.
196
+ // Times out with exit 1 if no matching reply lands within the window.
197
+ if (typeof options.waitSeconds === "number" &&
198
+ options.waitSeconds > 0 &&
199
+ parsed.type !== "abort") {
200
+ const reply = await waitForReply({
201
+ issueNumber,
202
+ worktreePath: issueState?.worktree,
203
+ inReplyTo: message.id,
204
+ timeoutMs: options.waitSeconds * 1000,
205
+ pollIntervalMs: options.waitPollIntervalMs ?? 250,
206
+ });
207
+ if (reply) {
208
+ if (options.json) {
209
+ console.log(JSON.stringify({ ok: true, reply }));
210
+ }
211
+ else {
212
+ console.log(chalk.cyan(`Reply: ${reply.message}`));
213
+ }
214
+ }
215
+ else {
216
+ const msg = `No reply received within ${options.waitSeconds}s. Message may be archived without a response.`;
217
+ if (options.json) {
218
+ console.log(JSON.stringify({ ok: false, timeout: true, error: msg }));
219
+ }
220
+ else {
221
+ console.error(chalk.yellow(msg));
222
+ }
223
+ process.exitCode = 1;
224
+ }
225
+ }
226
+ }
227
+ async function waitForReply(options) {
228
+ const outboxPath = outboxPathFor(options.issueNumber, {
229
+ worktreePath: options.worktreePath,
230
+ });
231
+ // Seed offset at current EOF: only NEW replies count for this prompt's wait.
232
+ let offset = existsSync(outboxPath) ? statSync(outboxPath).size : 0;
233
+ let partial = "";
234
+ const deadline = Date.now() + options.timeoutMs;
235
+ while (Date.now() < deadline) {
236
+ if (existsSync(outboxPath)) {
237
+ try {
238
+ const size = statSync(outboxPath).size;
239
+ if (size > offset) {
240
+ const chunk = readFileSync(outboxPath, "utf-8").slice(offset);
241
+ offset = size;
242
+ const lines = (partial + chunk).split("\n");
243
+ partial = lines.pop() ?? "";
244
+ for (const line of lines) {
245
+ if (line.trim() === "")
246
+ continue;
247
+ try {
248
+ const parsed = RelayResponseSchema.safeParse(JSON.parse(line));
249
+ if (parsed.success &&
250
+ parsed.data.inReplyTo === options.inReplyTo) {
251
+ return parsed.data;
252
+ }
253
+ }
254
+ catch {
255
+ /* skip malformed */
256
+ }
257
+ }
258
+ }
259
+ }
260
+ catch {
261
+ /* transient — try again */
262
+ }
263
+ }
264
+ await new Promise((r) => setTimeout(r, options.pollIntervalMs));
265
+ }
266
+ return null;
173
267
  }
174
268
  function formatElapsed(ms) {
175
269
  const totalSec = Math.max(0, Math.floor(ms / 1000));
@@ -8,11 +8,14 @@
8
8
  */
9
9
  import type { RunRenderer } from "../lib/cli-ui/run-renderer-types.js";
10
10
  import { LivenessHeartbeat } from "../lib/workflow/heartbeat.js";
11
- import type { ProgressCallback } from "../lib/workflow/types.js";
11
+ import type { ProgressCallback, PhasePlanCallback } from "../lib/workflow/types.js";
12
12
  export interface ProgressWiring {
13
13
  renderer: RunRenderer | null;
14
14
  heartbeat: LivenessHeartbeat | null;
15
15
  onProgress: ProgressCallback | undefined;
16
+ /** #672 AC-2: forwarded to the orchestrator so batch-executor can hand the
17
+ * resolved phase pipeline back to the renderer once it's known. */
18
+ onPhasePlan: PhasePlanCallback | undefined;
16
19
  }
17
20
  /**
18
21
  * Construct the renderer + heartbeat + onProgress callback for a run.
@@ -27,6 +30,13 @@ export declare function buildProgressWiring(args: {
27
30
  /** AC-23: when auto-detect mode is on, the renderer shows `Phase: detecting…`
28
31
  * while spec runs (before the resolved plan is known). */
29
32
  autoDetectPhases?: boolean;
33
+ /** #672 AC-2: the base configured phase pipeline. In explicit-phase mode
34
+ * (not auto-detect) this is known upfront, so every issue — including those
35
+ * still queued behind the active one — can show its roadmap immediately
36
+ * rather than only once it starts running. `setPhasePlan` later refines it
37
+ * per issue (e.g. testgen/security-review insertion). Ignored in
38
+ * auto-detect mode, where the plan isn't known until spec resolves it. */
39
+ basePhases?: string[];
30
40
  /** #624 Item 3 / D2: total allowed quality-loop iterations (from settings). */
31
41
  maxLoopIterations?: number;
32
42
  }): ProgressWiring;
@@ -14,7 +14,7 @@ import { LivenessHeartbeat } from "../lib/workflow/heartbeat.js";
14
14
  * `tuiEnabled` and `quiet` are mutually exclusive with the renderer path.
15
15
  */
16
16
  export function buildProgressWiring(args) {
17
- const { tuiEnabled, quiet, issueNumbers, phaseTimeoutSeconds, autoDetectPhases, maxLoopIterations, } = args;
17
+ const { tuiEnabled, quiet, issueNumbers, phaseTimeoutSeconds, autoDetectPhases, basePhases, maxLoopIterations, } = args;
18
18
  const heartbeat = quiet && !tuiEnabled
19
19
  ? new LivenessHeartbeat({ phaseTimeoutSeconds })
20
20
  : null;
@@ -36,8 +36,20 @@ export function buildProgressWiring(args) {
36
36
  })
37
37
  : null;
38
38
  if (renderer) {
39
+ // #672 AC-2: seed the planned pipeline at registration when it's known
40
+ // upfront (explicit-phase mode). This makes queued issues render their
41
+ // roadmap before they start, matching the issue's multi-row matrix
42
+ // mock-up. In auto-detect mode the plan isn't known yet, so we leave
43
+ // `plannedPhases` undefined and rely on `setPhasePlan` once spec resolves.
44
+ const seedPlan = !autoDetectPhases && basePhases && basePhases.length > 0
45
+ ? basePhases
46
+ : undefined;
39
47
  for (const issueNumber of issueNumbers) {
40
- renderer.registerIssue({ issueNumber, autoDetect: autoDetectPhases });
48
+ renderer.registerIssue({
49
+ issueNumber,
50
+ autoDetect: autoDetectPhases,
51
+ plannedPhases: seedPlan,
52
+ });
41
53
  }
42
54
  }
43
55
  let onProgress;
@@ -72,5 +84,10 @@ export function buildProgressWiring(args) {
72
84
  heartbeat.stop({ issueNumber: issue, phase });
73
85
  };
74
86
  }
75
- return { renderer, heartbeat, onProgress };
87
+ // #672 AC-2: only the renderer path consumes a phase plan; quiet / TUI
88
+ // modes leave this undefined and the orchestrator no-ops the callback.
89
+ const onPhasePlan = renderer
90
+ ? (issue, phases) => renderer.setPhasePlan(issue, phases)
91
+ : undefined;
92
+ return { renderer, heartbeat, onProgress, onPhasePlan };
76
93
  }
@@ -72,12 +72,15 @@ export async function runCommand(issues, options) {
72
72
  const tuiEnabled = Boolean(options.experimentalTui) && Boolean(process.stdout.isTTY);
73
73
  // RunRenderer (#618) + LivenessHeartbeat (#574) wiring lives in
74
74
  // run-progress.ts to keep this adapter under the 200-LOC cap (#503 AC-2).
75
- const { renderer, heartbeat, onProgress } = buildProgressWiring({
75
+ const { renderer, heartbeat, onProgress, onPhasePlan } = buildProgressWiring({
76
76
  tuiEnabled,
77
77
  quiet: Boolean(options.quiet),
78
78
  issueNumbers: resolved.issueNumbers,
79
79
  phaseTimeoutSeconds: settings.run.timeout,
80
80
  autoDetectPhases: resolved.autoDetectPhases,
81
+ // #672 AC-2: base pipeline so queued issues show their roadmap upfront in
82
+ // explicit-phase mode (ignored when auto-detect resolves the plan later).
83
+ basePhases: resolved.config.phases,
81
84
  // #624 Item 3 / D2: route the resolved maxIterations into the renderer so
82
85
  // `(attempt N/M)` and `loop N/M` reflect actual configured limits.
83
86
  maxLoopIterations: resolved.config.maxIterations,
@@ -97,6 +100,8 @@ export async function runCommand(issues, options) {
97
100
  const result = await RunOrchestrator.run({
98
101
  ...init,
99
102
  onProgress,
103
+ onPhasePlan,
104
+ phasePauseHandle: renderer ?? undefined,
100
105
  onOrchestratorReady: (orch) => {
101
106
  tuiHandle = renderTui(orch);
102
107
  },
@@ -121,7 +126,12 @@ export async function runCommand(issues, options) {
121
126
  if (renderer)
122
127
  process.once("SIGINT", sigintHandler);
123
128
  try {
124
- const result = await RunOrchestrator.run({ ...init, onProgress }, issues, batches);
129
+ const result = await RunOrchestrator.run({
130
+ ...init,
131
+ onProgress,
132
+ onPhasePlan,
133
+ phasePauseHandle: renderer ?? undefined,
134
+ }, issues, batches);
125
135
  // Record PR info in renderer state before summary so done rows show PR #s.
126
136
  if (renderer) {
127
137
  for (const r of result.results) {
@@ -9,6 +9,8 @@ export interface WatchCommandOptions {
9
9
  pollIntervalMs?: number;
10
10
  /** Abort signal for clean shutdown (tests). */
11
11
  signal?: AbortSignal;
12
+ /** Override cwd for resolving the pid file + archive root (test seam). */
13
+ cwd?: string;
12
14
  }
13
15
  export declare function watchCommand(argsAndOptions: {
14
16
  args: string[];