sequant 2.1.0 → 2.1.2

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.
@@ -1,806 +1,133 @@
1
- /**
2
- * sequant run - Execute workflow for GitHub issues
3
- *
4
- * Orchestrator module that composes focused workflow modules:
5
- * - worktree-manager: Worktree lifecycle (ensure, list, cleanup, changed files)
6
- * - phase-executor: Phase execution with retry and failure handling
7
- * - phase-mapper: Label-to-phase detection and workflow parsing
8
- * - batch-executor: Batch execution, dependency sorting, issue logging
9
- */
1
+ /** sequant run — Thin CLI adapter that delegates to RunOrchestrator. */
10
2
  import chalk from "chalk";
11
- import { spawnSync } from "child_process";
12
- import pLimit from "p-limit";
13
3
  import { getManifest } from "../lib/manifest.js";
14
4
  import { formatElapsedTime } from "../lib/phase-spinner.js";
15
5
  import { getSettings } from "../lib/settings.js";
16
- import { LogWriter } from "../lib/workflow/log-writer.js";
17
- import { StateManager } from "../lib/workflow/state-manager.js";
18
- import { DEFAULT_PHASES, DEFAULT_CONFIG, } from "../lib/workflow/types.js";
19
- import { ShutdownManager } from "../lib/shutdown.js";
20
6
  import { checkVersionCached, getVersionWarning } from "../lib/version-check.js";
21
- import { MetricsWriter } from "../lib/workflow/metrics-writer.js";
22
- import { determineOutcome, } from "../lib/workflow/metrics-schema.js";
23
7
  import { ui, colors } from "../lib/cli-ui.js";
24
- import { getCommitHash } from "../lib/workflow/git-diff-utils.js";
25
- import { getTokenUsageForRun } from "../lib/workflow/token-utils.js";
26
- import { reconcileStateAtStartup } from "../lib/workflow/state-utils.js";
27
- import { analyzeRun, formatReflection } from "../lib/workflow/run-reflect.js";
28
- /** @internal Log a non-fatal warning: one-line summary always, detail in verbose. */
29
- export function logNonFatalWarning(message, error, verbose) {
30
- console.log(chalk.yellow(message));
31
- if (verbose) {
32
- console.log(chalk.gray(` ${error}`));
33
- }
34
- }
35
- // Extracted modules
36
- import { detectDefaultBranch, ensureWorktrees, ensureWorktreesChain, getWorktreeDiffStats, } from "../lib/workflow/worktree-manager.js";
37
8
  import { formatDuration } from "../lib/workflow/phase-executor.js";
38
- import { getIssueInfo, sortByDependencies, parseBatches, getEnvConfig, executeBatch, runIssueWithLogging, } from "../lib/workflow/batch-executor.js";
9
+ import { parseBatches } from "../lib/workflow/batch-executor.js";
10
+ import { RunOrchestrator } from "../lib/workflow/run-orchestrator.js";
11
+ import { analyzeRun, formatReflection } from "../lib/workflow/run-reflect.js";
39
12
  // Re-export public API for backwards compatibility
40
- export { parseQaVerdict, formatDuration, executePhaseWithRetry, } from "../lib/workflow/phase-executor.js";
41
- export { detectDefaultBranch, checkWorktreeFreshness, removeStaleWorktree, listWorktrees, getWorktreeChangedFiles, getWorktreeDiffStats, readCacheMetrics, filterResumedPhases, ensureWorktree, createCheckpointCommit, reinstallIfLockfileChanged, rebaseBeforePR, createPR, } from "../lib/workflow/worktree-manager.js";
42
- export { detectPhasesFromLabels, parseRecommendedWorkflow, determinePhasesForIssue, } from "../lib/workflow/phase-mapper.js";
43
- export { getIssueInfo, sortByDependencies, parseBatches, getEnvConfig, executeBatch, runIssueWithLogging, } from "../lib/workflow/batch-executor.js";
44
- /**
45
- * Normalize Commander.js --no-X flags into typed RunOptions fields.
46
- * Replaces the `as any` cast (#402 AC-4).
47
- */
48
- export function normalizeCommanderOptions(options) {
49
- const raw = options;
50
- return {
51
- ...options,
52
- ...(raw.log === false && { noLog: true }),
53
- ...(raw.smartTests === false && { noSmartTests: true }),
54
- ...(raw.mcp === false && { noMcp: true }),
55
- ...(raw.retry === false && { noRetry: true }),
56
- ...(raw.rebase === false && { noRebase: true }),
57
- ...(raw.pr === false && { noPr: true }),
58
- };
59
- }
60
- /**
61
- * Execute a single issue with log bookkeeping (start/complete/PR info).
62
- * Replaces the duplicated per-issue wrapper in sequential and parallel loops (#402 AC-1).
63
- */
64
- async function executeOneIssue(args) {
65
- const { issueNumber, batchCtx, chain, parallelIssueNumber } = args;
66
- const { config, options, issueInfoMap, worktreeMap, logWriter, stateManager, shutdownManager, packageManager, baseBranch, onProgress, } = batchCtx;
67
- const issueInfo = issueInfoMap.get(issueNumber) ?? {
68
- title: `Issue #${issueNumber}`,
69
- labels: [],
70
- };
71
- const worktreeInfo = worktreeMap.get(issueNumber);
72
- // Start issue logging
73
- if (logWriter) {
74
- logWriter.startIssue(issueNumber, issueInfo.title, issueInfo.labels);
75
- }
76
- const ctx = {
77
- issueNumber,
78
- title: issueInfo.title,
79
- labels: issueInfo.labels,
80
- config,
81
- options,
82
- services: { logWriter, stateManager, shutdownManager },
83
- worktree: worktreeInfo
84
- ? { path: worktreeInfo.path, branch: worktreeInfo.branch }
85
- : undefined,
86
- chain,
87
- packageManager,
88
- baseBranch,
89
- onProgress,
90
- };
91
- const result = await runIssueWithLogging(ctx);
92
- // Record PR info in log before completing issue
93
- if (logWriter && result.prNumber && result.prUrl) {
94
- logWriter.setPRInfo(result.prNumber, result.prUrl, parallelIssueNumber);
95
- }
96
- // Complete issue logging
97
- if (logWriter) {
98
- logWriter.completeIssue(parallelIssueNumber);
99
- }
100
- return result;
101
- }
102
- /**
103
- * Main run command
104
- */
13
+ export * from "./run-compat.js";
14
+ /** Parse CLI args validate delegate to RunOrchestrator.run() display summary. */
105
15
  export async function runCommand(issues, options) {
106
16
  console.log(ui.headerBox("SEQUANT WORKFLOW"));
107
- // Version freshness check (cached, non-blocking, respects --quiet)
108
17
  if (!options.quiet) {
109
18
  try {
110
- const versionResult = await checkVersionCached();
111
- if (versionResult.isOutdated && versionResult.latestVersion) {
112
- console.log(chalk.yellow(` ! ${getVersionWarning(versionResult.currentVersion, versionResult.latestVersion, versionResult.isLocalInstall)}`));
19
+ const v = await checkVersionCached();
20
+ if (v.isOutdated && v.latestVersion) {
21
+ console.log(chalk.yellow(` ! ${getVersionWarning(v.currentVersion, v.latestVersion, v.isLocalInstall)}`));
113
22
  console.log("");
114
23
  }
115
24
  }
116
25
  catch {
117
- // Silent failure - version check is non-critical
26
+ /* non-critical */
118
27
  }
119
28
  }
120
- // Check if initialized
121
29
  const manifest = await getManifest();
122
30
  if (!manifest) {
123
31
  console.log(chalk.red("❌ Sequant is not initialized. Run `sequant init` first."));
124
32
  return;
125
33
  }
126
- // Load settings and merge with environment config and CLI options
127
34
  const settings = await getSettings();
128
- const envConfig = getEnvConfig();
129
- // Settings provide defaults, env overrides settings, CLI overrides all
130
- // Note: phases are auto-detected per-issue unless --phases is explicitly set
131
- const normalizedOptions = normalizeCommanderOptions(options);
132
- const mergedOptions = {
133
- // Settings defaults (phases removed - now auto-detected)
134
- sequential: normalizedOptions.sequential ?? settings.run.sequential,
135
- concurrency: normalizedOptions.concurrency ?? settings.run.concurrency,
136
- timeout: normalizedOptions.timeout ?? settings.run.timeout,
137
- logPath: normalizedOptions.logPath ?? settings.run.logPath,
138
- qualityLoop: normalizedOptions.qualityLoop ?? settings.run.qualityLoop,
139
- maxIterations: normalizedOptions.maxIterations ?? settings.run.maxIterations,
140
- noSmartTests: normalizedOptions.noSmartTests ?? !settings.run.smartTests,
141
- // Agent settings (from agents section, not run section)
142
- isolateParallel: normalizedOptions.isolateParallel ?? settings.agents.isolateParallel,
143
- // Env overrides
144
- ...envConfig,
145
- // CLI explicit options override all
146
- ...normalizedOptions,
147
- };
148
- // Determine if we should auto-detect phases from labels
149
- const autoDetectPhases = !options.phases && settings.run.autoDetectPhases;
150
- mergedOptions.autoDetectPhases = autoDetectPhases;
151
- // Resolve base branch: CLI flag → settings.run.defaultBase → auto-detect → 'main'
152
- const resolvedBaseBranch = options.base ??
153
- settings.run.defaultBase ??
154
- detectDefaultBranch(mergedOptions.verbose ?? false);
155
- // Parse issue numbers (or use batch mode)
156
- let issueNumbers;
157
- let batches = null;
158
- if (mergedOptions.batch && mergedOptions.batch.length > 0) {
159
- batches = parseBatches(mergedOptions.batch);
160
- issueNumbers = batches.flat();
161
- console.log(chalk.gray(` Batch mode: ${batches.map((b) => `[${b.join(", ")}]`).join(" → ")}`));
162
- }
163
- else {
164
- issueNumbers = issues.map((i) => parseInt(i, 10)).filter((n) => !isNaN(n));
165
- }
166
- if (issueNumbers.length === 0) {
167
- console.log(chalk.red("❌ No valid issue numbers provided."));
168
- console.log(chalk.gray("\nUsage: npx sequant run <issues...> [options]"));
169
- console.log(chalk.gray("Example: npx sequant run 1 2 3 --sequential"));
170
- console.log(chalk.gray('Batch example: npx sequant run --batch "1 2" --batch "3"'));
171
- console.log(chalk.gray("Chain example: npx sequant run 1 2 3 --chain"));
35
+ // Validate constraints
36
+ if (options.chain && options.batch?.length) {
37
+ console.log(chalk.red("❌ --chain cannot be used with --batch"));
172
38
  return;
173
39
  }
174
- // Validate chain mode requirements
175
- if (mergedOptions.chain) {
176
- // Chain mode is inherently sequential imply --sequential automatically
177
- if (!mergedOptions.sequential) {
178
- mergedOptions.sequential = true;
179
- }
180
- if (batches) {
181
- console.log(chalk.red("❌ --chain cannot be used with --batch"));
182
- console.log(chalk.gray(" Chain mode creates a linear dependency chain between issues."));
183
- return;
184
- }
185
- // Warn about long chains
186
- if (issueNumbers.length > 5) {
187
- console.log(chalk.yellow(` ! Warning: Chain has ${issueNumbers.length} issues (recommended max: 5)`));
188
- console.log(chalk.yellow(" Long chains increase merge complexity and review difficulty."));
189
- console.log(chalk.yellow(" Consider breaking into smaller chains or using batch mode."));
190
- console.log("");
191
- }
192
- }
193
- // Validate concurrency value
194
- if (mergedOptions.concurrency !== undefined &&
195
- (mergedOptions.concurrency < 1 ||
196
- !Number.isInteger(mergedOptions.concurrency))) {
197
- console.log(chalk.red(`❌ Invalid --concurrency value: ${mergedOptions.concurrency}. Must be a positive integer.`));
40
+ if (options.concurrency !== undefined &&
41
+ (options.concurrency < 1 || !Number.isInteger(options.concurrency))) {
42
+ console.log(chalk.red(`❌ Invalid --concurrency value: ${options.concurrency}. Must be a positive integer.`));
198
43
  return;
199
44
  }
200
- // Validate QA gate requirements
201
- if (mergedOptions.qaGate && !mergedOptions.chain) {
45
+ if (options.qaGate && !options.chain) {
202
46
  console.log(chalk.red("❌ --qa-gate requires --chain flag"));
203
- console.log(chalk.gray(" QA gate ensures each issue passes QA before the next issue starts."));
204
- console.log(chalk.gray(" Usage: npx sequant run 1 2 3 --sequential --chain --qa-gate"));
205
47
  return;
206
48
  }
207
- // Sort issues by dependencies (if more than one issue)
208
- if (issueNumbers.length > 1 && !batches) {
209
- const originalOrder = [...issueNumbers];
210
- issueNumbers = sortByDependencies(issueNumbers);
211
- const orderChanged = !originalOrder.every((n, i) => n === issueNumbers[i]);
212
- if (orderChanged) {
213
- console.log(chalk.gray(` Dependency order: ${issueNumbers.map((n) => `#${n}`).join(" → ")}`));
214
- }
215
- }
216
- // Build config
217
- // Note: config.phases is only used when --phases is explicitly set or autoDetect fails
218
- const explicitPhases = mergedOptions.phases
219
- ? mergedOptions.phases.split(",").map((p) => p.trim())
220
- : null;
221
- // Determine MCP enablement: CLI flag (--no-mcp) → settings.run.mcp → default (true)
222
- const mcpEnabled = mergedOptions.noMcp
223
- ? false
224
- : (settings.run.mcp ?? DEFAULT_CONFIG.mcp);
225
- // Resolve retry setting: CLI flag → settings.run.retry → default (true)
226
- const retryEnabled = mergedOptions.noRetry
227
- ? false
228
- : (settings.run.retry ?? true);
229
- const isSequential = mergedOptions.sequential ?? false;
230
- const isParallel = !isSequential && issueNumbers.length > 1;
231
- const config = {
232
- ...DEFAULT_CONFIG,
233
- phases: explicitPhases ?? DEFAULT_PHASES,
234
- sequential: isSequential,
235
- concurrency: mergedOptions.concurrency ?? DEFAULT_CONFIG.concurrency,
236
- parallel: isParallel,
237
- dryRun: mergedOptions.dryRun ?? false,
238
- verbose: mergedOptions.verbose ?? false,
239
- phaseTimeout: mergedOptions.timeout ?? DEFAULT_CONFIG.phaseTimeout,
240
- qualityLoop: mergedOptions.qualityLoop ?? false,
241
- maxIterations: mergedOptions.maxIterations ?? DEFAULT_CONFIG.maxIterations,
242
- noSmartTests: mergedOptions.noSmartTests ?? false,
243
- mcp: mcpEnabled,
244
- retry: retryEnabled,
245
- agent: mergedOptions.agent ?? settings.run.agent,
246
- aiderSettings: settings.run.aider,
247
- isolateParallel: mergedOptions.isolateParallel,
248
- };
249
- // Propagate verbose mode to UI config so spinners use text-only mode.
250
- // This prevents animated spinner control characters from colliding with
251
- // verbose console.log() calls from StateManager/MetricsWriter (#282).
252
- if (config.verbose) {
253
- ui.configure({ verbose: true });
254
- }
255
- // Initialize log writer if JSON logging enabled
256
- // Default: enabled via settings (logJson: true), can be disabled with --no-log
257
- let logWriter = null;
258
- const shouldLog = !mergedOptions.noLog &&
259
- !config.dryRun &&
260
- (mergedOptions.logJson ?? settings.run.logJson);
261
- if (shouldLog) {
262
- const runConfig = {
263
- phases: config.phases,
264
- sequential: config.sequential,
265
- qualityLoop: config.qualityLoop,
266
- maxIterations: config.maxIterations,
267
- chain: mergedOptions.chain,
268
- qaGate: mergedOptions.qaGate,
269
- };
270
- try {
271
- logWriter = new LogWriter({
272
- logPath: mergedOptions.logPath ?? settings.run.logPath,
273
- verbose: config.verbose,
274
- startCommit: getCommitHash(process.cwd()),
275
- });
276
- await logWriter.initialize(runConfig);
277
- }
278
- catch (err) {
279
- // Log initialization failure is non-fatal - warn and continue without logging
280
- // Common causes: permissions issues, disk full, invalid path
281
- const errorMessage = err instanceof Error ? err.message : String(err);
282
- console.log(chalk.yellow(` ! Log initialization failed, continuing without logging: ${errorMessage}`));
283
- logWriter = null;
284
- }
285
- }
286
- // Initialize state manager for persistent workflow state tracking
287
- // State tracking is always enabled (unless dry run)
288
- let stateManager = null;
289
- if (!config.dryRun) {
290
- stateManager = new StateManager({ verbose: config.verbose });
291
- }
292
- // Initialize shutdown manager for graceful interruption handling
293
- const shutdown = new ShutdownManager();
294
- // Register log writer finalization as cleanup task
295
- if (logWriter) {
296
- const writer = logWriter; // Capture for closure
297
- shutdown.registerCleanup("Finalize run logs", async () => {
298
- await writer.finalize();
299
- });
300
- }
301
- // Display configuration (columnar alignment)
302
- const pad = (label) => label.padEnd(15);
303
- console.log(chalk.gray(` ${pad("Stack")}${manifest.stack}`));
304
- if (autoDetectPhases) {
305
- console.log(chalk.gray(` ${pad("Phases")}auto-detect from labels`));
306
- }
307
- else {
308
- console.log(chalk.gray(` ${pad("Phases")}${config.phases.join(" \u2192 ")}`));
309
- }
310
- console.log(chalk.gray(` ${pad("Mode")}${config.sequential ? "sequential (stop-on-failure)" : `parallel (concurrency: ${config.concurrency})`}`));
311
- if (config.qualityLoop) {
312
- console.log(chalk.gray(` ${pad("Quality loop")}enabled (max ${config.maxIterations} iterations)`));
313
- }
314
- if (mergedOptions.testgen) {
315
- console.log(chalk.gray(` ${pad("Testgen")}enabled`));
316
- }
317
- if (config.noSmartTests) {
318
- console.log(chalk.gray(` ${pad("Smart tests")}disabled`));
319
- }
320
- if (config.dryRun) {
321
- console.log(chalk.yellow(` ${pad("!")}DRY RUN - no actual execution`));
322
- }
323
- if (logWriter) {
324
- console.log(chalk.gray(` ${pad("Logging")}JSON (run ${logWriter.getRunId()?.slice(0, 8)}...)`));
325
- }
326
- if (stateManager) {
327
- console.log(chalk.gray(` ${pad("State")}enabled`));
328
- }
329
- if (mergedOptions.force) {
330
- console.log(chalk.yellow(` ${pad("Force")}enabled (bypass state guard)`));
331
- }
332
- console.log(chalk.gray(` ${pad("Issues")}${issueNumbers.map((n) => `#${n}`).join(", ")}`));
333
- // ============================================================================
334
- // Pre-flight State Guard (#305)
335
- // ============================================================================
336
- // AC-5: Auto-cleanup at run start - reconcile stale ready_for_merge states
337
- if (stateManager && !config.dryRun) {
338
- try {
339
- const reconcileResult = await reconcileStateAtStartup({
340
- verbose: config.verbose,
341
- });
342
- if (reconcileResult.success && reconcileResult.advanced.length > 0) {
343
- console.log(chalk.gray(` State reconciled: ${reconcileResult.advanced.map((n) => `#${n}`).join(", ")} → merged`));
344
- }
345
- }
346
- catch (error) {
347
- // AC-8: Graceful degradation - don't block execution on reconciliation failure
348
- logNonFatalWarning(` ! State reconciliation failed, continuing...`, error, config.verbose);
349
- }
350
- }
351
- // AC-1 & AC-2: Pre-flight state guard - skip completed issues unless --force
352
- if (stateManager && !config.dryRun && !mergedOptions.force) {
353
- const skippedIssues = [];
354
- const activeIssues = [];
355
- for (const issueNumber of issueNumbers) {
356
- try {
357
- const issueState = await stateManager.getIssueState(issueNumber);
358
- if (issueState &&
359
- (issueState.status === "ready_for_merge" ||
360
- issueState.status === "merged")) {
361
- skippedIssues.push(issueNumber);
362
- console.log(chalk.yellow(` ! #${issueNumber}: already ${issueState.status} — skipping (use --force to re-run)`));
363
- }
364
- else {
365
- activeIssues.push(issueNumber);
366
- }
367
- }
368
- catch (error) {
369
- // AC-8: Graceful degradation - if state check fails, include the issue
370
- logNonFatalWarning(` ! State lookup failed for #${issueNumber}, including anyway...`, error, config.verbose);
371
- activeIssues.push(issueNumber);
372
- }
373
- }
374
- // Update issueNumbers to only include active issues
375
- if (skippedIssues.length > 0) {
376
- issueNumbers = activeIssues;
377
- if (issueNumbers.length === 0) {
378
- console.log(chalk.yellow(`\n All issues already completed. Use --force to re-run.`));
379
- return;
380
- }
381
- console.log(chalk.gray(` Active issues: ${issueNumbers.map((n) => `#${n}`).join(", ")}`));
382
- }
383
- }
384
- // Worktree isolation is enabled by default for multi-issue runs
385
- const useWorktreeIsolation = mergedOptions.worktreeIsolation !== false && issueNumbers.length > 0;
386
- if (useWorktreeIsolation) {
387
- console.log(chalk.gray(` Worktree isolation: enabled`));
388
- }
389
- if (resolvedBaseBranch) {
390
- console.log(chalk.gray(` Base branch: ${resolvedBaseBranch}`));
391
- }
392
- if (mergedOptions.chain) {
393
- console.log(chalk.gray(` Chain mode: enabled (each issue branches from previous)`));
394
- }
395
- if (mergedOptions.qaGate) {
396
- console.log(chalk.gray(` QA gate: enabled (chain waits for QA pass)`));
397
- }
398
- // Fetch issue info for all issues first
399
- const issueInfoMap = new Map();
400
- for (const issueNumber of issueNumbers) {
401
- issueInfoMap.set(issueNumber, await getIssueInfo(issueNumber));
402
- }
403
- // Create worktrees for all issues before execution (if isolation enabled)
404
- let worktreeMap = new Map();
405
- if (useWorktreeIsolation && !config.dryRun) {
406
- const issueData = issueNumbers.map((num) => ({
407
- number: num,
408
- title: issueInfoMap.get(num)?.title || `Issue #${num}`,
409
- }));
410
- // Use chain mode or standard worktree creation
411
- if (mergedOptions.chain) {
412
- worktreeMap = await ensureWorktreesChain(issueData, config.verbose, manifest.packageManager, resolvedBaseBranch);
413
- }
414
- else {
415
- worktreeMap = await ensureWorktrees(issueData, config.verbose, manifest.packageManager, resolvedBaseBranch);
416
- }
417
- // Register cleanup tasks for newly created worktrees (not pre-existing ones)
418
- for (const [issueNum, worktree] of worktreeMap.entries()) {
419
- if (!worktree.existed) {
420
- shutdown.registerCleanup(`Cleanup worktree for #${issueNum}`, async () => {
421
- // Remove worktree (leaves branch intact for recovery)
422
- const result = spawnSync("git", ["worktree", "remove", "--force", worktree.path], {
423
- stdio: "pipe",
424
- });
425
- if (result.status !== 0 && config.verbose) {
426
- console.log(chalk.yellow(` Warning: Could not remove worktree ${worktree.path}`));
427
- }
428
- });
429
- }
430
- }
49
+ let batches = null;
50
+ if (options.batch?.length) {
51
+ batches = parseBatches(options.batch);
52
+ console.log(chalk.gray(` Batch mode: ${batches.map((b) => `[${b.join(", ")}]`).join(" → ")}`));
431
53
  }
432
- // Shared context for all execution paths
433
- const batchCtx = {
434
- config,
435
- options: mergedOptions,
436
- issueInfoMap,
437
- worktreeMap,
438
- logWriter,
439
- stateManager,
440
- shutdownManager: shutdown,
441
- packageManager: manifest.packageManager,
442
- baseBranch: resolvedBaseBranch,
443
- };
444
- // Execute with graceful shutdown handling
445
- const results = [];
446
- let exitCode = 0;
447
- try {
448
- if (batches) {
449
- // Batch execution: run batches sequentially, issues within batch based on mode
450
- for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) {
451
- const batch = batches[batchIdx];
452
- console.log(chalk.blue(`\n Batch ${batchIdx + 1}/${batches.length}: Issues ${batch.map((n) => `#${n}`).join(", ")}`));
453
- const batchResults = await executeBatch(batch, batchCtx);
454
- results.push(...batchResults);
455
- // Check if batch failed and we should stop
456
- const batchFailed = batchResults.some((r) => !r.success);
457
- if (batchFailed && config.sequential) {
458
- console.log(chalk.yellow(`\n ! Batch ${batchIdx + 1} failed, stopping batch execution`));
459
- break;
460
- }
461
- }
462
- }
463
- else if (config.sequential) {
464
- // Sequential execution
465
- for (let i = 0; i < issueNumbers.length; i++) {
466
- const issueNumber = issueNumbers[i];
467
- const result = await executeOneIssue({
468
- issueNumber,
469
- batchCtx,
470
- chain: mergedOptions.chain
471
- ? { enabled: true, isLast: i === issueNumbers.length - 1 }
472
- : undefined,
473
- });
474
- results.push(result);
475
- // Check if shutdown was triggered
476
- if (shutdown.shuttingDown) {
477
- break;
478
- }
479
- if (!result.success) {
480
- // Check if QA gate is enabled and QA specifically failed
481
- if (mergedOptions.qaGate) {
482
- const qaResult = result.phaseResults.find((p) => p.phase === "qa");
483
- const qaFailed = qaResult && !qaResult.success;
484
- if (qaFailed) {
485
- // QA gate: pause chain with clear messaging
486
- console.log(chalk.yellow("\n ⏸️ QA Gate"));
487
- console.log(chalk.yellow(` Issue #${issueNumber} QA did not pass. Chain paused.`));
488
- console.log(chalk.gray(" Fix QA issues and re-run, or run /loop to auto-fix."));
489
- // Update state to waiting_for_qa_gate
490
- if (stateManager) {
491
- try {
492
- await stateManager.updateIssueStatus(issueNumber, "waiting_for_qa_gate");
493
- }
494
- catch {
495
- // State tracking errors shouldn't stop execution
496
- }
497
- }
498
- break;
499
- }
500
- }
501
- const chainInfo = mergedOptions.chain ? " (chain stopped)" : "";
502
- console.log(chalk.yellow(`\n ! Issue #${issueNumber} failed, stopping sequential execution${chainInfo}`));
503
- break;
504
- }
505
- }
506
- }
507
- else {
508
- // Default mode: run issues concurrently with configurable concurrency limit
509
- const limit = pLimit(config.concurrency);
510
- // Track progress for concurrent issues
511
- const issueStatus = new Map();
512
- for (const num of issueNumbers) {
513
- issueStatus.set(num, { state: "running" });
514
- }
515
- const renderProgressLine = () => {
516
- const parts = issueNumbers.map((num) => {
517
- const info = issueStatus.get(num);
518
- if (info.state === "done")
519
- return colors.success(`#${num} \u2714`);
520
- if (info.state === "failed")
521
- return colors.error(`#${num} \u2716`);
522
- return colors.muted(`#${num} \u00B7`);
523
- });
524
- return ` ${parts.join(" ")}`;
525
- };
526
- const updateProgress = (completedIssue) => {
527
- if (mergedOptions.quiet)
528
- return;
529
- if (process.stdout.isTTY) {
530
- // TTY: overwrite the progress line in place
531
- process.stdout.write(`\r${renderProgressLine()}`);
532
- }
533
- // Print a per-issue completion summary (works on both TTY and non-TTY)
534
- if (completedIssue != null) {
535
- const info = issueStatus.get(completedIssue);
536
- const duration = info.durationSeconds != null
537
- ? ` (${formatElapsedTime(info.durationSeconds)})`
538
- : "";
539
- if (info.state === "done") {
540
- const line = ` ${colors.success("\u2714")} #${completedIssue} completed${duration}`;
541
- if (process.stdout.isTTY) {
542
- // Move to a new line before printing the summary, then re-render progress
543
- process.stdout.write(`\n${line}\n${renderProgressLine()}`);
544
- }
545
- else {
546
- console.log(line);
547
- }
548
- }
549
- else {
550
- const errorSuffix = info.error ? `: ${info.error}` : "";
551
- const line = ` ${colors.error("\u2716")} #${completedIssue} failed${duration}${errorSuffix}`;
552
- if (process.stdout.isTTY) {
553
- process.stdout.write(`\n${line}\n${renderProgressLine()}`);
554
- }
555
- else {
556
- console.log(line);
557
- }
558
- }
559
- }
560
- };
561
- // Per-phase progress callback for parallel mode (AC-1, AC-3)
562
- const parallelStartTime = Date.now();
563
- let lastPhaseEventTime = Date.now();
564
- const onPhaseProgress = (issue, phase, event, extra) => {
565
- if (mergedOptions.quiet)
566
- return;
567
- lastPhaseEventTime = Date.now();
568
- let line;
569
- if (event === "start") {
570
- line = ` ${colors.running("\u25B8")} #${issue} ${phase}`;
571
- }
572
- else if (event === "complete") {
573
- const dur = extra?.durationSeconds != null
574
- ? ` ${formatElapsedTime(extra.durationSeconds)}`
575
- : "";
576
- line = ` ${colors.success("\u2714")} #${issue} ${phase}${dur}`;
577
- }
578
- else {
579
- line = ` ${colors.error("\u2716")} #${issue} ${phase}`;
580
- }
581
- console.log(line);
582
- };
583
- // 5-minute heartbeat timer, suppressed when phase events occur within window
584
- const HEARTBEAT_INTERVAL_MS = 300_000;
585
- const HEARTBEAT_SUPPRESS_MS = 60_000;
586
- const heartbeatTimer = setInterval(() => {
587
- if (mergedOptions.quiet)
588
- return;
589
- // Suppress if a phase event occurred recently
590
- if (Date.now() - lastPhaseEventTime < HEARTBEAT_SUPPRESS_MS)
591
- return;
592
- const elapsedSec = Math.round((Date.now() - parallelStartTime) / 1000);
593
- console.log(` ${colors.muted(`Still running... (${formatElapsedTime(elapsedSec)} elapsed)`)}`);
594
- }, HEARTBEAT_INTERVAL_MS);
595
- updateProgress();
596
- const settledResults = await Promise.allSettled(issueNumbers.map((issueNumber) => limit(async () => {
597
- // Check if shutdown was triggered before starting
598
- if (shutdown.shuttingDown) {
599
- return {
600
- issueNumber,
601
- success: false,
602
- phaseResults: [],
603
- durationSeconds: 0,
604
- loopTriggered: false,
605
- };
606
- }
607
- const result = await executeOneIssue({
608
- issueNumber,
609
- batchCtx: { ...batchCtx, onProgress: onPhaseProgress },
610
- parallelIssueNumber: issueNumber,
611
- });
612
- // Update progress with completion details
613
- issueStatus.set(issueNumber, {
614
- state: result.success ? "done" : "failed",
615
- durationSeconds: result.durationSeconds,
616
- error: result.phaseResults.find((p) => !p.success)?.error,
617
- });
618
- updateProgress(issueNumber);
619
- return result;
620
- })));
621
- // Clean up heartbeat timer
622
- clearInterval(heartbeatTimer);
623
- // Clear the progress line
624
- if (process.stdout.isTTY && !mergedOptions.quiet) {
625
- process.stdout.write("\n");
626
- }
627
- // Collect results from settled promises
628
- for (let i = 0; i < settledResults.length; i++) {
629
- const settled = settledResults[i];
630
- if (settled.status === "fulfilled") {
631
- results.push(settled.value);
632
- }
633
- else {
634
- // Defensive fallback — runIssueWithLogging catches errors internally,
635
- // so this path is unreachable in normal operation.
636
- results.push({
637
- issueNumber: issueNumbers[i],
638
- success: false,
639
- phaseResults: [],
640
- durationSeconds: 0,
641
- loopTriggered: false,
642
- });
643
- }
54
+ console.log(chalk.gray(` ${"Stack".padEnd(15)}${manifest.stack}`));
55
+ const onProgress = !options.quiet
56
+ ? (issue, phase, event, extra) => {
57
+ if (event === "start")
58
+ console.log(` ${colors.running("▸")} #${issue} ${phase}`);
59
+ else if (event === "complete") {
60
+ const dur = extra?.durationSeconds != null
61
+ ? ` ${formatElapsedTime(extra.durationSeconds)}`
62
+ : "";
63
+ console.log(` ${colors.success("✔")} #${issue} ${phase}${dur}`);
644
64
  }
65
+ else
66
+ console.log(` ${colors.error("✖")} #${issue} ${phase}`);
645
67
  }
646
- // Finalize log
647
- let logPath = null;
648
- if (logWriter) {
649
- logPath = await logWriter.finalize({
650
- endCommit: getCommitHash(process.cwd()),
651
- });
652
- }
653
- // Calculate success/failure counts
654
- const passed = results.filter((r) => r.success).length;
655
- const failed = results.filter((r) => !r.success).length;
656
- // Record metrics (local analytics)
657
- if (!config.dryRun && results.length > 0) {
658
- try {
659
- const metricsWriter = new MetricsWriter({ verbose: config.verbose });
660
- // Calculate total duration
661
- const totalDuration = results.reduce((sum, r) => sum + (r.durationSeconds ?? 0), 0);
662
- // Get unique phases from all results
663
- const allPhases = new Set();
664
- for (const result of results) {
665
- for (const phaseResult of result.phaseResults) {
666
- // Only include phases that are valid MetricPhases
667
- const phase = phaseResult.phase;
668
- if ([
669
- "spec",
670
- "security-review",
671
- "testgen",
672
- "exec",
673
- "test",
674
- "qa",
675
- "loop",
676
- ].includes(phase)) {
677
- allPhases.add(phase);
678
- }
679
- }
680
- }
681
- // Calculate aggregate metrics from worktrees
682
- let totalFilesChanged = 0;
683
- let totalLinesAdded = 0;
684
- let totalQaIterations = 0;
685
- for (const result of results) {
686
- const worktreeInfo = worktreeMap.get(result.issueNumber);
687
- if (worktreeInfo?.path) {
688
- const stats = getWorktreeDiffStats(worktreeInfo.path);
689
- totalFilesChanged += stats.filesChanged;
690
- totalLinesAdded += stats.linesAdded;
691
- }
692
- // Count QA iterations (loop phases indicate retries)
693
- if (result.loopTriggered) {
694
- totalQaIterations += result.phaseResults.filter((p) => p.phase === "loop").length;
695
- }
696
- }
697
- // Build CLI flags for metrics
698
- const cliFlags = [];
699
- if (mergedOptions.sequential)
700
- cliFlags.push("--sequential");
701
- if (mergedOptions.chain)
702
- cliFlags.push("--chain");
703
- if (mergedOptions.qaGate)
704
- cliFlags.push("--qa-gate");
705
- if (mergedOptions.qualityLoop)
706
- cliFlags.push("--quality-loop");
707
- if (mergedOptions.testgen)
708
- cliFlags.push("--testgen");
709
- // Read token usage from SessionEnd hook files (AC-5, AC-6)
710
- const tokenUsage = getTokenUsageForRun(undefined, true); // cleanup after reading
711
- // Record the run
712
- await metricsWriter.recordRun({
713
- issues: issueNumbers,
714
- phases: Array.from(allPhases),
715
- outcome: determineOutcome(passed, results.length),
716
- duration: totalDuration,
717
- model: process.env.ANTHROPIC_MODEL ?? "opus",
718
- flags: cliFlags,
719
- metrics: {
720
- tokensUsed: tokenUsage.tokensUsed,
721
- filesChanged: totalFilesChanged,
722
- linesAdded: totalLinesAdded,
723
- acceptanceCriteria: 0, // Would need to parse from issue
724
- qaIterations: totalQaIterations,
725
- // Token breakdown (AC-6)
726
- inputTokens: tokenUsage.inputTokens || undefined,
727
- outputTokens: tokenUsage.outputTokens || undefined,
728
- cacheTokens: tokenUsage.cacheTokens || undefined,
729
- },
730
- });
731
- if (config.verbose) {
732
- console.log(chalk.gray(` Metrics recorded to .sequant/metrics.json`));
733
- }
734
- }
735
- catch (metricsError) {
736
- // Metrics recording errors shouldn't stop execution
737
- logNonFatalWarning(` ! Metrics recording failed, continuing...`, metricsError, config.verbose);
738
- }
739
- }
740
- // Summary
741
- console.log("\n" + ui.divider());
742
- console.log(colors.info(" Summary"));
743
- console.log(ui.divider());
744
- console.log(`\n ${colors.success(`${passed} passed`)} ${colors.muted("\u00B7")} ${colors.error(`${failed} failed`)}`);
745
- for (const result of results) {
746
- const status = result.success
747
- ? ui.statusIcon("success")
748
- : ui.statusIcon("error");
749
- const duration = result.durationSeconds
750
- ? colors.muted(` (${formatDuration(result.durationSeconds)})`)
751
- : "";
752
- const phases = result.phaseResults
753
- .map((p) => p.success ? colors.success(p.phase) : colors.error(p.phase))
754
- .join(" → ");
755
- const loopInfo = result.loopTriggered ? colors.warning(" [loop]") : "";
756
- const prInfo = result.prUrl
757
- ? colors.muted(` → PR #${result.prNumber}`)
758
- : "";
759
- console.log(` ${status} #${result.issueNumber}: ${phases}${loopInfo}${prInfo}${duration}`);
760
- }
68
+ : undefined;
69
+ const result = await RunOrchestrator.run({
70
+ options,
71
+ settings,
72
+ manifest: {
73
+ stack: manifest.stack,
74
+ packageManager: manifest.packageManager ?? "npm",
75
+ },
76
+ onProgress,
77
+ }, issues, batches);
78
+ displaySummary(result);
79
+ if (result.exitCode !== 0)
80
+ process.exit(result.exitCode);
81
+ }
82
+ function displaySummary(result) {
83
+ const { results, logPath, config, mergedOptions } = result;
84
+ if (results.length === 0)
85
+ return;
86
+ const passed = results.filter((r) => r.success).length;
87
+ const failed = results.filter((r) => !r.success).length;
88
+ console.log("\n" + ui.divider());
89
+ console.log(colors.info(" Summary"));
90
+ console.log(ui.divider());
91
+ console.log(`\n ${colors.success(`${passed} passed`)} ${colors.muted("·")} ${colors.error(`${failed} failed`)}`);
92
+ for (const r of results) {
93
+ const status = r.success
94
+ ? ui.statusIcon("success")
95
+ : ui.statusIcon("error");
96
+ const duration = r.durationSeconds
97
+ ? colors.muted(` (${formatDuration(r.durationSeconds)})`)
98
+ : "";
99
+ const phases = r.phaseResults
100
+ .map((p) => (p.success ? colors.success(p.phase) : colors.error(p.phase)))
101
+ .join(" → ");
102
+ const loopInfo = r.loopTriggered ? colors.warning(" [loop]") : "";
103
+ const prInfo = r.prUrl ? colors.muted(` → PR #${r.prNumber}`) : "";
104
+ console.log(` ${status} #${r.issueNumber}: ${phases}${loopInfo}${prInfo}${duration}`);
105
+ }
106
+ console.log("");
107
+ if (logPath) {
108
+ console.log(colors.muted(` Log: ${logPath}`));
761
109
  console.log("");
762
- if (logPath) {
763
- console.log(colors.muted(` Log: ${logPath}`));
764
- console.log("");
765
- }
766
- // Reflection analysis (--reflect flag)
767
- if (mergedOptions.reflect && results.length > 0) {
768
- const reflection = analyzeRun({
769
- results,
770
- issueInfoMap,
771
- runLog: logWriter?.getRunLog() ?? null,
772
- config: {
773
- phases: config.phases,
774
- qualityLoop: config.qualityLoop,
775
- },
776
- });
777
- const reflectionOutput = formatReflection(reflection);
778
- if (reflectionOutput) {
779
- console.log(reflectionOutput);
780
- console.log("");
781
- }
782
- }
783
- // Suggest merge checks for multi-issue batches
784
- if (results.length > 1 && passed > 0 && !config.dryRun) {
785
- console.log(colors.muted(" Tip: Verify batch integration before merging:"));
786
- console.log(colors.muted(" sequant merge --check"));
787
- console.log("");
788
- }
789
- if (config.dryRun) {
790
- console.log(colors.warning(" ℹ️ This was a dry run. Use without --dry-run to execute."));
110
+ }
111
+ if (mergedOptions.reflect && results.length > 0) {
112
+ const reflection = analyzeRun({
113
+ results,
114
+ issueInfoMap: result.issueInfoMap,
115
+ runLog: result.logWriter?.getRunLog() ?? null,
116
+ config: { phases: config.phases, qualityLoop: config.qualityLoop },
117
+ });
118
+ const reflectionOutput = formatReflection(reflection);
119
+ if (reflectionOutput) {
120
+ console.log(reflectionOutput);
791
121
  console.log("");
792
122
  }
793
- // Set exit code if any failed
794
- if (failed > 0 && !config.dryRun) {
795
- exitCode = 1;
796
- }
797
123
  }
798
- finally {
799
- // Always dispose shutdown manager to clean up signal handlers
800
- shutdown.dispose();
124
+ if (results.length > 1 && passed > 0 && !config.dryRun) {
125
+ console.log(colors.muted(" Tip: Verify batch integration before merging:"));
126
+ console.log(colors.muted(" sequant merge --check"));
127
+ console.log("");
801
128
  }
802
- // Exit with error if any failed (outside try/finally so dispose() runs first)
803
- if (exitCode !== 0) {
804
- process.exit(exitCode);
129
+ if (config.dryRun) {
130
+ console.log(colors.warning(" ℹ️ This was a dry run. Use without --dry-run to execute."));
131
+ console.log("");
805
132
  }
806
133
  }