sequant 1.20.3 → 2.0.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 (137) hide show
  1. package/.claude-plugin/marketplace.json +2 -4
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +29 -9
  4. package/dist/bin/cli.js +25 -2
  5. package/dist/src/commands/doctor.js +42 -9
  6. package/dist/src/commands/init.d.ts +1 -0
  7. package/dist/src/commands/init.js +52 -0
  8. package/dist/src/commands/logs.d.ts +1 -0
  9. package/dist/src/commands/logs.js +18 -2
  10. package/dist/src/commands/run.d.ts +7 -0
  11. package/dist/src/commands/run.js +235 -68
  12. package/dist/src/commands/serve.d.ts +13 -0
  13. package/dist/src/commands/serve.js +131 -0
  14. package/dist/src/commands/stats.d.ts +1 -0
  15. package/dist/src/commands/stats.js +185 -26
  16. package/dist/src/commands/status.d.ts +2 -0
  17. package/dist/src/commands/status.js +99 -50
  18. package/dist/src/index.d.ts +2 -2
  19. package/dist/src/index.js +4 -1
  20. package/dist/src/lib/ac-parser.d.ts +2 -0
  21. package/dist/src/lib/ac-parser.js +12 -2
  22. package/dist/src/lib/assess-comment-parser.d.ts +137 -0
  23. package/dist/src/lib/assess-comment-parser.js +344 -0
  24. package/dist/src/lib/ci/config.d.ts +22 -0
  25. package/dist/src/lib/ci/config.js +134 -0
  26. package/dist/src/lib/ci/index.d.ts +12 -0
  27. package/dist/src/lib/ci/index.js +10 -0
  28. package/dist/src/lib/ci/inputs.d.ts +29 -0
  29. package/dist/src/lib/ci/inputs.js +103 -0
  30. package/dist/src/lib/ci/labels.d.ts +34 -0
  31. package/dist/src/lib/ci/labels.js +101 -0
  32. package/dist/src/lib/ci/outputs.d.ts +25 -0
  33. package/dist/src/lib/ci/outputs.js +84 -0
  34. package/dist/src/lib/ci/triggers.d.ts +9 -0
  35. package/dist/src/lib/ci/triggers.js +86 -0
  36. package/dist/src/lib/ci/types.d.ts +131 -0
  37. package/dist/src/lib/ci/types.js +47 -0
  38. package/dist/src/lib/mcp-config.d.ts +54 -0
  39. package/dist/src/lib/mcp-config.js +172 -0
  40. package/dist/src/lib/merge-check/index.js +6 -12
  41. package/dist/src/lib/merge-check/types.d.ts +20 -7
  42. package/dist/src/lib/merge-check/types.js +11 -0
  43. package/dist/src/lib/phase-signal.d.ts +3 -3
  44. package/dist/src/lib/phase-signal.js +5 -3
  45. package/dist/src/lib/settings.d.ts +52 -0
  46. package/dist/src/lib/settings.js +41 -0
  47. package/dist/src/lib/shutdown.d.ts +16 -5
  48. package/dist/src/lib/shutdown.js +32 -12
  49. package/dist/src/lib/solve-comment-parser.d.ts +9 -102
  50. package/dist/src/lib/solve-comment-parser.js +13 -248
  51. package/dist/src/lib/stacks.d.ts +8 -0
  52. package/dist/src/lib/stacks.js +34 -0
  53. package/dist/src/lib/system.js +3 -7
  54. package/dist/src/lib/test-tautology-detector.d.ts +10 -0
  55. package/dist/src/lib/test-tautology-detector.js +43 -4
  56. package/dist/src/lib/upstream/assessment.js +9 -59
  57. package/dist/src/lib/upstream/issues.js +12 -75
  58. package/dist/src/lib/version-check.d.ts +2 -2
  59. package/dist/src/lib/version-check.js +6 -3
  60. package/dist/src/lib/version.d.ts +4 -0
  61. package/dist/src/lib/version.js +25 -0
  62. package/dist/src/lib/workflow/batch-executor.d.ts +18 -86
  63. package/dist/src/lib/workflow/batch-executor.js +232 -55
  64. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +56 -0
  65. package/dist/src/lib/workflow/drivers/agent-driver.js +8 -0
  66. package/dist/src/lib/workflow/drivers/aider.d.ts +18 -0
  67. package/dist/src/lib/workflow/drivers/aider.js +160 -0
  68. package/dist/src/lib/workflow/drivers/claude-code.d.ts +17 -0
  69. package/dist/src/lib/workflow/drivers/claude-code.js +165 -0
  70. package/dist/src/lib/workflow/drivers/index.d.ts +20 -0
  71. package/dist/src/lib/workflow/drivers/index.js +27 -0
  72. package/dist/src/lib/workflow/error-classifier.d.ts +16 -0
  73. package/dist/src/lib/workflow/error-classifier.js +90 -0
  74. package/dist/src/lib/workflow/log-writer.d.ts +6 -3
  75. package/dist/src/lib/workflow/log-writer.js +57 -27
  76. package/dist/src/lib/workflow/metrics-schema.d.ts +9 -9
  77. package/dist/src/lib/workflow/phase-detection.d.ts +23 -0
  78. package/dist/src/lib/workflow/phase-detection.js +45 -29
  79. package/dist/src/lib/workflow/phase-executor.d.ts +42 -3
  80. package/dist/src/lib/workflow/phase-executor.js +340 -220
  81. package/dist/src/lib/workflow/phase-mapper.d.ts +1 -1
  82. package/dist/src/lib/workflow/phase-mapper.js +7 -7
  83. package/dist/src/lib/workflow/platforms/github.d.ts +157 -0
  84. package/dist/src/lib/workflow/platforms/github.js +466 -0
  85. package/dist/src/lib/workflow/platforms/index.d.ts +17 -0
  86. package/dist/src/lib/workflow/platforms/index.js +25 -0
  87. package/dist/src/lib/workflow/platforms/platform-provider.d.ts +67 -0
  88. package/dist/src/lib/workflow/platforms/platform-provider.js +8 -0
  89. package/dist/src/lib/workflow/pr-status.d.ts +2 -4
  90. package/dist/src/lib/workflow/pr-status.js +3 -16
  91. package/dist/src/lib/workflow/qa-cache.d.ts +58 -0
  92. package/dist/src/lib/workflow/qa-cache.js +88 -0
  93. package/dist/src/lib/workflow/reconcile.d.ts +69 -0
  94. package/dist/src/lib/workflow/reconcile.js +290 -0
  95. package/dist/src/lib/workflow/ring-buffer.d.ts +17 -0
  96. package/dist/src/lib/workflow/ring-buffer.js +37 -0
  97. package/dist/src/lib/workflow/run-log-schema.d.ts +115 -24
  98. package/dist/src/lib/workflow/run-log-schema.js +47 -12
  99. package/dist/src/lib/workflow/run-reflect.js +1 -1
  100. package/dist/src/lib/workflow/state-cleanup.js +21 -0
  101. package/dist/src/lib/workflow/state-manager.d.ts +34 -3
  102. package/dist/src/lib/workflow/state-manager.js +278 -126
  103. package/dist/src/lib/workflow/state-schema.d.ts +34 -30
  104. package/dist/src/lib/workflow/state-schema.js +35 -25
  105. package/dist/src/lib/workflow/state-utils.d.ts +3 -1
  106. package/dist/src/lib/workflow/state-utils.js +1 -0
  107. package/dist/src/lib/workflow/types.d.ts +208 -6
  108. package/dist/src/lib/workflow/types.js +20 -1
  109. package/dist/src/lib/workflow/worktree-discovery.d.ts +1 -1
  110. package/dist/src/lib/workflow/worktree-discovery.js +6 -14
  111. package/dist/src/lib/workflow/worktree-manager.js +33 -51
  112. package/dist/src/mcp/index.d.ts +4 -0
  113. package/dist/src/mcp/index.js +4 -0
  114. package/dist/src/mcp/resources.d.ts +7 -0
  115. package/dist/src/mcp/resources.js +111 -0
  116. package/dist/src/mcp/run-registry.d.ts +34 -0
  117. package/dist/src/mcp/run-registry.js +42 -0
  118. package/dist/src/mcp/server.d.ts +12 -0
  119. package/dist/src/mcp/server.js +50 -0
  120. package/dist/src/mcp/tools/logs.d.ts +7 -0
  121. package/dist/src/mcp/tools/logs.js +149 -0
  122. package/dist/src/mcp/tools/run.d.ts +121 -0
  123. package/dist/src/mcp/tools/run.js +591 -0
  124. package/dist/src/mcp/tools/status.d.ts +7 -0
  125. package/dist/src/mcp/tools/status.js +127 -0
  126. package/package.json +10 -1
  127. package/templates/hooks/post-tool.sh +19 -8
  128. package/templates/hooks/pre-tool.sh +36 -49
  129. package/templates/mcp.json +6 -0
  130. package/templates/skills/assess/SKILL.md +354 -352
  131. package/templates/skills/exec/SKILL.md +64 -1
  132. package/templates/skills/fullsolve/SKILL.md +35 -4
  133. package/templates/skills/qa/SKILL.md +486 -9
  134. package/templates/skills/qa/scripts/quality-checks.sh +1 -1
  135. package/templates/skills/setup/SKILL.md +386 -0
  136. package/templates/skills/solve/SKILL.md +38 -664
  137. package/templates/skills/spec/SKILL.md +90 -31
@@ -0,0 +1,591 @@
1
+ /**
2
+ * sequant_run MCP tool
3
+ *
4
+ * Execute workflow phases for GitHub issues.
5
+ * Returns structured JSON with per-issue summaries parsed from run logs.
6
+ * Uses async spawn to keep the MCP server responsive during execution.
7
+ */
8
+ import { z } from "zod";
9
+ import { spawn } from "child_process";
10
+ import { resolve, dirname, join } from "path";
11
+ import { existsSync } from "fs";
12
+ import { readdir, readFile } from "fs/promises";
13
+ import { homedir } from "os";
14
+ import { LOG_PATHS, RunLogSchema } from "../../lib/workflow/run-log-schema.js";
15
+ import { registerRun, unregisterRun } from "../run-registry.js";
16
+ /** Maximum total response size in bytes (64 KB) */
17
+ const MAX_RESPONSE_SIZE = 64 * 1024;
18
+ /** Maximum raw output size before truncation */
19
+ const MAX_RAW_OUTPUT = 2000;
20
+ /** Maximum age of a log file to be considered for the current run (ms) */
21
+ const MAX_LOG_AGE_MS = 5 * 60 * 1000; // 5 minutes
22
+ /**
23
+ * Resolve the CLI binary path to avoid nested npx version mismatches (#389).
24
+ *
25
+ * Priority:
26
+ * 1. process.argv[1] — the script currently running (works for npx, global, local node)
27
+ * 2. __dirname-relative resolution — fallback for bundled/compiled entry points
28
+ * 3. "npx" + "sequant" — last resort if nothing else resolves
29
+ *
30
+ * Returns [command, prefixArgs] where the full invocation is:
31
+ * spawnAsync(command, [...prefixArgs, "run", ...userArgs])
32
+ */
33
+ export function resolveCliBinary() {
34
+ // Try process.argv — most reliable across npx, global install, and local node
35
+ const nodeExe = process.argv[0];
36
+ const scriptPath = process.argv[1];
37
+ if (scriptPath && existsSync(scriptPath)) {
38
+ // If the entry point is a .ts file (e.g. running via `npx tsx bin/cli.ts serve`),
39
+ // the child process won't have tsx's loader hooks. Prefer the compiled dist output,
40
+ // or fall through to use tsx explicitly.
41
+ if (!scriptPath.endsWith(".ts")) {
42
+ return [nodeExe, [scriptPath]];
43
+ }
44
+ // Try compiled dist equivalent: bin/cli.ts → dist/bin/cli.js
45
+ const distPath = resolve(dirname(scriptPath), "..", "dist", "bin", "cli.js");
46
+ if (existsSync(distPath)) {
47
+ return [process.execPath, [distPath]];
48
+ }
49
+ // Use tsx to run the .ts file directly
50
+ return ["npx", ["tsx", scriptPath]];
51
+ }
52
+ // Fallback: resolve relative to this file's location (dist/src/mcp/tools/run.js → dist/bin/cli.js)
53
+ const cliPath = resolve(dirname(__dirname), "..", "..", "bin", "cli.js");
54
+ if (existsSync(cliPath)) {
55
+ return [process.execPath, [cliPath]];
56
+ }
57
+ // Last resort: fall back to npx (original behavior)
58
+ return ["npx", ["sequant"]];
59
+ }
60
+ /**
61
+ * Resolve the log directory path (project-level or user-level)
62
+ */
63
+ function resolveLogDir() {
64
+ const projectPath = LOG_PATHS.project;
65
+ if (existsSync(projectPath)) {
66
+ return projectPath;
67
+ }
68
+ const userPath = LOG_PATHS.user.replace("~", homedir());
69
+ if (existsSync(userPath)) {
70
+ return userPath;
71
+ }
72
+ return projectPath;
73
+ }
74
+ /**
75
+ * Find and parse the most recent run log file.
76
+ *
77
+ * When runStartTime is provided, only log files created within
78
+ * MAX_LOG_AGE_MS of that timestamp are considered, preventing
79
+ * stale logs from a previous run being returned.
80
+ */
81
+ export async function readLatestRunLog(runStartTime) {
82
+ try {
83
+ const logDir = resolveLogDir();
84
+ const entries = await readdir(logDir);
85
+ let logFiles = entries
86
+ .filter((f) => f.startsWith("run-") && f.endsWith(".json"))
87
+ .sort()
88
+ .reverse();
89
+ if (logFiles.length === 0)
90
+ return null;
91
+ // Filter by recency if a run start time is provided
92
+ if (runStartTime) {
93
+ logFiles = logFiles.filter((f) => {
94
+ // Filename format: run-YYYY-MM-DDTHH-MM-SS-<uuid>.json
95
+ const match = f.match(/^run-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-/);
96
+ if (!match)
97
+ return false;
98
+ const fileTime = new Date(`${match[1]}T${match[2]}:${match[3]}:${match[4]}Z`);
99
+ // Accept files created around or after the run started
100
+ return fileTime.getTime() >= runStartTime.getTime() - MAX_LOG_AGE_MS;
101
+ });
102
+ if (logFiles.length === 0)
103
+ return null;
104
+ }
105
+ const content = await readFile(join(logDir, logFiles[0]), "utf-8");
106
+ return RunLogSchema.parse(JSON.parse(content));
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ /**
113
+ * Build a structured response from a parsed RunLog
114
+ */
115
+ export function buildStructuredResponse(runLog, rawOutput, overallStatus, exitCode, errorOutput) {
116
+ const issues = runLog.issues.map((issue) => {
117
+ // Find QA verdict from phase logs
118
+ const qaPhase = issue.phases.find((p) => p.phase === "qa");
119
+ const verdict = qaPhase?.verdict;
120
+ return {
121
+ issueNumber: issue.issueNumber,
122
+ status: issue.status,
123
+ phases: issue.phases.map((p) => ({
124
+ phase: p.phase,
125
+ status: p.status,
126
+ durationSeconds: p.durationSeconds,
127
+ })),
128
+ ...(verdict ? { verdict } : {}),
129
+ durationSeconds: issue.totalDurationSeconds,
130
+ };
131
+ });
132
+ const phasesRan = [
133
+ ...new Set(runLog.issues.flatMap((i) => i.phases.map((p) => p.phase))),
134
+ ].join(",");
135
+ const response = {
136
+ status: overallStatus,
137
+ ...(exitCode != null && exitCode !== 0 ? { exitCode } : {}),
138
+ issues,
139
+ summary: {
140
+ total: runLog.summary.totalIssues,
141
+ passed: runLog.summary.passed,
142
+ failed: runLog.summary.failed,
143
+ durationSeconds: runLog.summary.totalDurationSeconds,
144
+ },
145
+ phases: phasesRan || runLog.config.phases.join(","),
146
+ rawOutput: rawOutput.slice(-MAX_RAW_OUTPUT),
147
+ ...(errorOutput ? { error: errorOutput.slice(-1000) } : {}),
148
+ };
149
+ return enforceResponseSizeLimit(response);
150
+ }
151
+ /**
152
+ * Enforce response size limit by progressively truncating rawOutput.
153
+ * Uses Buffer.byteLength for accurate UTF-8 byte measurement.
154
+ */
155
+ function enforceResponseSizeLimit(response) {
156
+ let json = JSON.stringify(response);
157
+ let byteLength = Buffer.byteLength(json, "utf-8");
158
+ if (byteLength <= MAX_RESPONSE_SIZE) {
159
+ return response;
160
+ }
161
+ // Progressively truncate rawOutput to fit
162
+ const rawOutput = response.rawOutput || "";
163
+ if (rawOutput.length > 0) {
164
+ const excess = byteLength - MAX_RESPONSE_SIZE;
165
+ // Over-trim slightly: multi-byte chars mean char count < byte count
166
+ const newLength = Math.max(0, rawOutput.length - excess - 200);
167
+ response.rawOutput =
168
+ newLength > 0 ? rawOutput.slice(-newLength) : undefined;
169
+ json = JSON.stringify(response);
170
+ byteLength = Buffer.byteLength(json, "utf-8");
171
+ }
172
+ // If still too large (structured data itself is huge), truncate error field
173
+ if (byteLength > MAX_RESPONSE_SIZE && response.error) {
174
+ const excess = byteLength - MAX_RESPONSE_SIZE;
175
+ const newLength = Math.max(0, response.error.length - excess - 200);
176
+ response.error =
177
+ newLength > 0 ? response.error.slice(-newLength) : undefined;
178
+ }
179
+ return response;
180
+ }
181
+ /**
182
+ * Build a fallback response when no log file is available
183
+ */
184
+ function buildFallbackResponse(stdout, issueNumbers, overallStatus, phases, exitCode, stderr) {
185
+ return {
186
+ status: overallStatus,
187
+ ...(exitCode != null && exitCode !== 0 ? { exitCode } : {}),
188
+ issues: [],
189
+ summary: {
190
+ total: issueNumbers.length,
191
+ passed: overallStatus === "success" ? issueNumbers.length : 0,
192
+ failed: overallStatus === "failure" ? issueNumbers.length : 0,
193
+ durationSeconds: 0,
194
+ },
195
+ phases,
196
+ rawOutput: stdout.slice(-MAX_RAW_OUTPUT),
197
+ ...(stderr ? { error: stderr.slice(-1000) } : {}),
198
+ };
199
+ }
200
+ /** Prefix used by the batch executor to emit structured progress lines. */
201
+ const PROGRESS_LINE_PREFIX = "SEQUANT_PROGRESS:";
202
+ /**
203
+ * Parse a SEQUANT_PROGRESS line emitted by the batch executor.
204
+ * Returns the parsed event or null if the line isn't a progress line.
205
+ */
206
+ export function parseProgressLine(line) {
207
+ if (!line.startsWith(PROGRESS_LINE_PREFIX))
208
+ return null;
209
+ try {
210
+ const json = JSON.parse(line.slice(PROGRESS_LINE_PREFIX.length));
211
+ if (typeof json.issue === "number" &&
212
+ typeof json.phase === "string" &&
213
+ typeof json.event === "string" &&
214
+ (json.event === "start" ||
215
+ json.event === "complete" ||
216
+ json.event === "failed")) {
217
+ const result = {
218
+ issue: json.issue,
219
+ phase: json.phase,
220
+ event: json.event,
221
+ };
222
+ if (typeof json.durationSeconds === "number") {
223
+ result.durationSeconds = json.durationSeconds;
224
+ }
225
+ if (typeof json.error === "string") {
226
+ result.error = json.error;
227
+ }
228
+ return result;
229
+ }
230
+ return null;
231
+ }
232
+ catch {
233
+ return null;
234
+ }
235
+ }
236
+ /**
237
+ * Build a human-readable message for a progress notification (AC-3).
238
+ * @internal Exported for testing only.
239
+ */
240
+ export function formatProgressMessage(event) {
241
+ const prefix = `#${event.issue}`;
242
+ switch (event.event) {
243
+ case "start":
244
+ return `${prefix}: ${event.phase} started`;
245
+ case "complete": {
246
+ const dur = event.durationSeconds ? ` (${event.durationSeconds}s)` : "";
247
+ return `${prefix}: ${event.phase} \u2713${dur}`;
248
+ }
249
+ case "failed": {
250
+ const reason = event.error ? ` \u2014 ${event.error}` : "";
251
+ return `${prefix}: ${event.phase} \u2717${reason}`;
252
+ }
253
+ }
254
+ }
255
+ /**
256
+ * Create a line buffer that accumulates stream chunks and yields complete lines.
257
+ * Handles the case where a single `data` event spans partial lines.
258
+ */
259
+ export function createLineBuffer(onLine) {
260
+ let buffer = "";
261
+ return (chunk) => {
262
+ buffer += chunk;
263
+ const lines = buffer.split("\n");
264
+ buffer = lines.pop(); // keep incomplete tail
265
+ for (const line of lines) {
266
+ if (line.length > 0)
267
+ onLine(line);
268
+ }
269
+ };
270
+ }
271
+ const runToolInputSchema = {
272
+ issues: z.array(z.number()).describe("GitHub issue numbers to process"),
273
+ phases: z
274
+ .string()
275
+ .optional()
276
+ .describe("Comma-separated workflow phases to execute. " +
277
+ "Valid values: 'spec' (plan and review AC), 'exec' (implement in worktree), 'qa' (code review and verification). " +
278
+ "Default: 'spec,exec,qa'. Example: 'spec,exec' to skip QA."),
279
+ qualityLoop: z
280
+ .boolean()
281
+ .optional()
282
+ .describe("Enable auto-retry on QA failure"),
283
+ agent: z
284
+ .string()
285
+ .optional()
286
+ .describe("Agent driver for phase execution (default: configured default)"),
287
+ };
288
+ export function registerRunTool(server) {
289
+ server.registerTool("sequant_run", {
290
+ title: "Sequant Run",
291
+ description: "Execute structured AI workflow phases for GitHub issues. " +
292
+ "Runs spec (plan) → exec (implement) → qa (review) in sequence, creating worktrees and PRs. " +
293
+ "Long-running: up to 30 minutes per issue. Returns structured JSON with per-issue phase results. " +
294
+ "Check sequant_status first to avoid re-running completed issues. " +
295
+ "Example: {issues: [123], phases: 'spec,exec'}",
296
+ annotations: {
297
+ readOnlyHint: false,
298
+ destructiveHint: false,
299
+ idempotentHint: false,
300
+ openWorldHint: true,
301
+ },
302
+ inputSchema: runToolInputSchema,
303
+ }, (async ({ issues, phases, qualityLoop, agent, }, extra) => {
304
+ if (!issues || issues.length === 0) {
305
+ return {
306
+ content: [
307
+ {
308
+ type: "text",
309
+ text: JSON.stringify({
310
+ error: "INVALID_INPUT",
311
+ message: "At least one issue number is required",
312
+ }),
313
+ },
314
+ ],
315
+ isError: true,
316
+ };
317
+ }
318
+ // Resolve CLI binary to avoid nested npx version mismatch (#389)
319
+ const [command, prefixArgs] = resolveCliBinary();
320
+ // Build command arguments
321
+ const args = [...prefixArgs, "run", ...issues.map(String)];
322
+ if (phases) {
323
+ args.push("--phases", phases);
324
+ }
325
+ if (qualityLoop) {
326
+ args.push("--quality-loop");
327
+ }
328
+ if (agent) {
329
+ args.push("--agent", agent);
330
+ }
331
+ args.push("--log-json");
332
+ const phasesStr = phases || "spec,exec,qa";
333
+ const phaseList = phasesStr.split(",");
334
+ const totalSteps = issues.length * phaseList.length;
335
+ const runStartTime = new Date();
336
+ // Extract progress token for MCP progress notifications (AC-1)
337
+ const progressToken = extra._meta?.progressToken;
338
+ // Track progress: only complete/failed events increment the counter
339
+ let completedSteps = 0;
340
+ /**
341
+ * Emit a progress notification if the client provided a progressToken.
342
+ * Only complete/failed events increment the progress counter.
343
+ * Failures are caught to avoid aborting the run (AC-6).
344
+ */
345
+ const emitProgress = (event) => {
346
+ if (progressToken === undefined)
347
+ return;
348
+ if (event.event === "complete" || event.event === "failed") {
349
+ completedSteps++;
350
+ }
351
+ try {
352
+ // Fire-and-forget: don't await to avoid blocking the output stream
353
+ void extra
354
+ .sendNotification({
355
+ method: "notifications/progress",
356
+ params: {
357
+ progressToken,
358
+ progress: completedSteps,
359
+ total: totalSteps,
360
+ message: formatProgressMessage(event),
361
+ },
362
+ })
363
+ .catch(() => {
364
+ // Swallow notification delivery errors (AC-6)
365
+ });
366
+ }
367
+ catch {
368
+ // Swallow synchronous errors (AC-6)
369
+ }
370
+ };
371
+ /**
372
+ * Handle a complete line of subprocess stderr, checking for progress events.
373
+ * The batch executor emits SEQUANT_PROGRESS:{json} lines at phase boundaries.
374
+ */
375
+ const handleLine = (line) => {
376
+ const event = parseProgressLine(line);
377
+ if (event)
378
+ emitProgress(event);
379
+ };
380
+ // Line-buffer stderr to handle chunk boundaries correctly.
381
+ // When a progressToken is present, we also enable spawnAsync's
382
+ // internal progress detection for timeout reset (AC-4).
383
+ const hasProgressToken = progressToken !== undefined;
384
+ const stderrLineBuffer = hasProgressToken
385
+ ? createLineBuffer(handleLine)
386
+ : undefined;
387
+ // Register all issues as active runs for real-time status polling
388
+ for (const issue of issues) {
389
+ registerRun(issue);
390
+ }
391
+ try {
392
+ const result = await spawnAsync(command, args, {
393
+ timeout: PHASE_TIMEOUT,
394
+ env: {
395
+ ...process.env,
396
+ SEQUANT_ORCHESTRATOR: "mcp-server",
397
+ },
398
+ signal: extra.signal,
399
+ onStderr: stderrLineBuffer,
400
+ // Enable timeout reset on progress events when client
401
+ // provided a progressToken (AC-4). spawnAsync detects
402
+ // SEQUANT_PROGRESS lines from stderr internally.
403
+ onProgress: hasProgressToken ? () => { } : undefined,
404
+ });
405
+ const stdout = result.stdout || "";
406
+ const stderr = result.stderr || "";
407
+ const overallStatus = result.exitCode === 0 ? "success" : "failure";
408
+ // Try to read structured log file for rich per-issue data
409
+ const runLog = await readLatestRunLog(runStartTime);
410
+ let response;
411
+ if (runLog) {
412
+ response = buildStructuredResponse(runLog, stdout, overallStatus, result.exitCode, stderr || undefined);
413
+ }
414
+ else {
415
+ // Fallback: no log file available
416
+ response = buildFallbackResponse(stdout, issues, overallStatus, phasesStr, result.exitCode, stderr || undefined);
417
+ }
418
+ return {
419
+ content: [
420
+ {
421
+ type: "text",
422
+ text: JSON.stringify(response),
423
+ },
424
+ ],
425
+ ...(result.exitCode !== 0 ? { isError: true } : {}),
426
+ };
427
+ }
428
+ catch (error) {
429
+ return {
430
+ content: [
431
+ {
432
+ type: "text",
433
+ text: JSON.stringify({
434
+ error: "EXECUTION_ERROR",
435
+ message: error instanceof Error ? error.message : String(error),
436
+ }),
437
+ },
438
+ ],
439
+ isError: true,
440
+ };
441
+ }
442
+ finally {
443
+ for (const issue of issues) {
444
+ unregisterRun(issue);
445
+ }
446
+ }
447
+ }));
448
+ }
449
+ /** Per-phase timeout ceiling (30 minutes) */
450
+ export const PHASE_TIMEOUT = 1_800_000;
451
+ /** Absolute maximum run duration (2 hours), even with progress resets */
452
+ export const MAX_TOTAL_TIMEOUT = 7_200_000;
453
+ /** @internal Exported for testing only */
454
+ export function spawnAsync(command, args, options) {
455
+ return new Promise((resolve, reject) => {
456
+ let stdout = "";
457
+ let stderr = "";
458
+ let settled = false;
459
+ const proc = spawn(command, args, {
460
+ stdio: ["pipe", "pipe", "pipe"],
461
+ env: options.env,
462
+ detached: true,
463
+ });
464
+ const settle = (outcome) => {
465
+ if (settled)
466
+ return;
467
+ settled = true;
468
+ clearTimeout(timeoutId);
469
+ options.signal?.removeEventListener("abort", onAbort);
470
+ if (outcome.ok) {
471
+ resolve(outcome.result);
472
+ }
473
+ else {
474
+ reject(outcome.error);
475
+ }
476
+ };
477
+ // Resettable timeout: resets on progress events (AC-4).
478
+ // Uses options.timeout as the per-phase ceiling and MAX_TOTAL_TIMEOUT
479
+ // as the absolute ceiling to prevent infinite runs.
480
+ const runStart = Date.now();
481
+ const maxTotal = options.onProgress
482
+ ? (options.maxTotalTimeout ?? MAX_TOTAL_TIMEOUT)
483
+ : options.timeout;
484
+ const scheduleTimeout = () => {
485
+ const elapsed = Date.now() - runStart;
486
+ const remaining = Math.min(options.timeout, maxTotal - elapsed);
487
+ if (remaining <= 0) {
488
+ // Already exceeded max total — kill immediately
489
+ killProcessGroup(proc);
490
+ const msg = options.onProgress
491
+ ? `Process timed out: total elapsed ${elapsed}ms exceeded ceiling of ${maxTotal}ms`
492
+ : `Process timed out after ${maxTotal}ms`;
493
+ settle({ ok: false, error: new Error(msg) });
494
+ return setTimeout(() => { }, 0); // dummy handle
495
+ }
496
+ return setTimeout(() => {
497
+ killProcessGroup(proc);
498
+ const msg = options.onProgress
499
+ ? `Process timed out: no progress for ${options.timeout}ms (total elapsed: ${Date.now() - runStart}ms)`
500
+ : `Process timed out after ${options.timeout}ms`;
501
+ settle({ ok: false, error: new Error(msg) });
502
+ }, remaining);
503
+ };
504
+ let timeoutId;
505
+ timeoutId = scheduleTimeout();
506
+ // When progress monitoring is enabled, detect SEQUANT_PROGRESS lines
507
+ // from stderr and reset the timeout on each one. This keeps the timeout
508
+ // reset logic co-located with the timer inside spawnAsync (AC-4).
509
+ const resetTimeout = () => {
510
+ if (!settled) {
511
+ clearTimeout(timeoutId);
512
+ timeoutId = scheduleTimeout();
513
+ }
514
+ };
515
+ const progressLineBuffer = options.onProgress
516
+ ? createLineBuffer((line) => {
517
+ if (line.startsWith(PROGRESS_LINE_PREFIX)) {
518
+ resetTimeout();
519
+ options.onProgress();
520
+ }
521
+ })
522
+ : undefined;
523
+ const onAbort = () => {
524
+ killProcessGroup(proc);
525
+ settle({ ok: false, error: new Error("Cancelled by client") });
526
+ };
527
+ if (options.signal) {
528
+ if (options.signal.aborted) {
529
+ killProcessGroup(proc);
530
+ clearTimeout(timeoutId);
531
+ reject(new Error("Cancelled by client"));
532
+ return;
533
+ }
534
+ options.signal.addEventListener("abort", onAbort, { once: true });
535
+ }
536
+ proc.stdout.on("data", (data) => {
537
+ const chunk = data.toString();
538
+ stdout += chunk;
539
+ options.onStdout?.(chunk);
540
+ });
541
+ proc.stderr.on("data", (data) => {
542
+ const chunk = data.toString();
543
+ stderr += chunk;
544
+ options.onStderr?.(chunk);
545
+ progressLineBuffer?.(chunk);
546
+ });
547
+ proc.on("error", (err) => {
548
+ if (err.code === "ENOENT") {
549
+ settle({
550
+ ok: false,
551
+ error: new Error(`Command not found: ${command}. Ensure it is installed and in PATH.`),
552
+ });
553
+ }
554
+ else {
555
+ settle({
556
+ ok: false,
557
+ error: new Error(`Failed to spawn process: ${err.message}`),
558
+ });
559
+ }
560
+ });
561
+ proc.on("close", (code) => {
562
+ settle({ ok: true, result: { exitCode: code, stdout, stderr } });
563
+ });
564
+ });
565
+ }
566
+ const SIGKILL_GRACE_MS = 5000;
567
+ function killProcessGroup(proc) {
568
+ let exited = false;
569
+ proc.on("close", () => {
570
+ exited = true;
571
+ });
572
+ sendSignal(proc, "SIGTERM");
573
+ setTimeout(() => {
574
+ if (!exited) {
575
+ sendSignal(proc, "SIGKILL");
576
+ }
577
+ }, SIGKILL_GRACE_MS).unref();
578
+ }
579
+ function sendSignal(proc, signal) {
580
+ try {
581
+ if (proc.pid) {
582
+ process.kill(-proc.pid, signal);
583
+ }
584
+ }
585
+ catch {
586
+ // Process group may already be gone — fall back to direct kill
587
+ if (!proc.killed) {
588
+ proc.kill(signal);
589
+ }
590
+ }
591
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * sequant_status MCP tool
3
+ *
4
+ * Get current workflow state for an issue.
5
+ */
6
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ export declare function registerStatusTool(server: McpServer): void;