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
|
@@ -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.
|
|
11
|
+
"version": "2.4.0",
|
|
12
12
|
"author": {
|
|
13
13
|
"name": "sequant-io",
|
|
14
14
|
"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
|
[](https://www.npmjs.com/package/sequant)
|
|
10
10
|
[](https://opensource.org/licenses/MIT)
|
|
11
11
|
|
|
12
|
-
### What's new in 2.
|
|
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
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
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
|
|
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("-
|
|
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
|
-
//
|
|
154
|
-
|
|
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 (
|
|
157
|
-
const ms = Date.now() - new Date(
|
|
172
|
+
if (freshStartedAt) {
|
|
173
|
+
const ms = Date.now() - new Date(freshStartedAt).getTime();
|
|
158
174
|
elapsedSegment = `, ${formatElapsed(ms)} elapsed`;
|
|
159
175
|
}
|
|
160
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
}
|
package/dist/src/commands/run.js
CHANGED
|
@@ -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({
|
|
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[];
|