sequant 1.16.1 → 1.18.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 (83) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +14 -2
  3. package/README.md +2 -0
  4. package/dist/bin/cli.js +2 -1
  5. package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +21 -0
  6. package/dist/marketplace/external_plugins/sequant/README.md +38 -0
  7. package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +292 -0
  8. package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +463 -0
  9. package/dist/marketplace/external_plugins/sequant/skills/_shared/references/prompt-templates.md +350 -0
  10. package/dist/marketplace/external_plugins/sequant/skills/_shared/references/subagent-types.md +131 -0
  11. package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +474 -0
  12. package/dist/marketplace/external_plugins/sequant/skills/clean/SKILL.md +211 -0
  13. package/dist/marketplace/external_plugins/sequant/skills/docs/SKILL.md +337 -0
  14. package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +807 -0
  15. package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +678 -0
  16. package/dist/marketplace/external_plugins/sequant/skills/improve/SKILL.md +668 -0
  17. package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +374 -0
  18. package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +570 -0
  19. package/dist/marketplace/external_plugins/sequant/skills/qa/references/code-quality-exemplars.md +107 -0
  20. package/dist/marketplace/external_plugins/sequant/skills/qa/references/code-review-checklist.md +65 -0
  21. package/dist/marketplace/external_plugins/sequant/skills/qa/references/quality-gates.md +179 -0
  22. package/dist/marketplace/external_plugins/sequant/skills/qa/references/semgrep-rules.md +207 -0
  23. package/dist/marketplace/external_plugins/sequant/skills/qa/references/testing-requirements.md +109 -0
  24. package/dist/marketplace/external_plugins/sequant/skills/qa/scripts/quality-checks.sh +622 -0
  25. package/dist/marketplace/external_plugins/sequant/skills/reflect/SKILL.md +175 -0
  26. package/dist/marketplace/external_plugins/sequant/skills/reflect/references/documentation-tiers.md +70 -0
  27. package/dist/marketplace/external_plugins/sequant/skills/reflect/references/phase-reflection.md +95 -0
  28. package/dist/marketplace/external_plugins/sequant/skills/security-review/SKILL.md +358 -0
  29. package/dist/marketplace/external_plugins/sequant/skills/security-review/references/security-checklists.md +432 -0
  30. package/dist/marketplace/external_plugins/sequant/skills/solve/SKILL.md +697 -0
  31. package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +754 -0
  32. package/dist/marketplace/external_plugins/sequant/skills/spec/references/parallel-groups.md +72 -0
  33. package/dist/marketplace/external_plugins/sequant/skills/spec/references/recommended-workflow.md +92 -0
  34. package/dist/marketplace/external_plugins/sequant/skills/spec/references/verification-criteria.md +104 -0
  35. package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +600 -0
  36. package/dist/marketplace/external_plugins/sequant/skills/testgen/SKILL.md +576 -0
  37. package/dist/marketplace/external_plugins/sequant/skills/verify/SKILL.md +281 -0
  38. package/dist/src/commands/run.d.ts +13 -274
  39. package/dist/src/commands/run.js +43 -1958
  40. package/dist/src/commands/sync.js +3 -0
  41. package/dist/src/commands/update.js +3 -0
  42. package/dist/src/lib/plugin-version-sync.d.ts +2 -1
  43. package/dist/src/lib/plugin-version-sync.js +28 -7
  44. package/dist/src/lib/solve-comment-parser.d.ts +26 -0
  45. package/dist/src/lib/solve-comment-parser.js +63 -7
  46. package/dist/src/lib/upstream/assessment.js +6 -3
  47. package/dist/src/lib/upstream/relevance.d.ts +5 -0
  48. package/dist/src/lib/upstream/relevance.js +24 -0
  49. package/dist/src/lib/upstream/report.js +18 -46
  50. package/dist/src/lib/upstream/types.d.ts +2 -0
  51. package/dist/src/lib/workflow/batch-executor.d.ts +117 -0
  52. package/dist/src/lib/workflow/batch-executor.js +574 -0
  53. package/dist/src/lib/workflow/phase-executor.d.ts +40 -0
  54. package/dist/src/lib/workflow/phase-executor.js +381 -0
  55. package/dist/src/lib/workflow/phase-mapper.d.ts +65 -0
  56. package/dist/src/lib/workflow/phase-mapper.js +147 -0
  57. package/dist/src/lib/workflow/pr-operations.d.ts +86 -0
  58. package/dist/src/lib/workflow/pr-operations.js +326 -0
  59. package/dist/src/lib/workflow/pr-status.d.ts +49 -0
  60. package/dist/src/lib/workflow/pr-status.js +131 -0
  61. package/dist/src/lib/workflow/run-reflect.d.ts +32 -0
  62. package/dist/src/lib/workflow/run-reflect.js +191 -0
  63. package/dist/src/lib/workflow/run-summary.d.ts +36 -0
  64. package/dist/src/lib/workflow/run-summary.js +142 -0
  65. package/dist/src/lib/workflow/state-cleanup.d.ts +79 -0
  66. package/dist/src/lib/workflow/state-cleanup.js +250 -0
  67. package/dist/src/lib/workflow/state-rebuild.d.ts +38 -0
  68. package/dist/src/lib/workflow/state-rebuild.js +140 -0
  69. package/dist/src/lib/workflow/state-utils.d.ts +14 -162
  70. package/dist/src/lib/workflow/state-utils.js +10 -677
  71. package/dist/src/lib/workflow/worktree-discovery.d.ts +61 -0
  72. package/dist/src/lib/workflow/worktree-discovery.js +229 -0
  73. package/dist/src/lib/workflow/worktree-manager.d.ts +205 -0
  74. package/dist/src/lib/workflow/worktree-manager.js +918 -0
  75. package/package.json +4 -2
  76. package/templates/skills/exec/SKILL.md +2 -2
  77. package/templates/skills/fullsolve/SKILL.md +15 -5
  78. package/templates/skills/loop/SKILL.md +1 -1
  79. package/templates/skills/qa/SKILL.md +47 -7
  80. package/templates/skills/solve/SKILL.md +92 -6
  81. package/templates/skills/spec/SKILL.md +57 -4
  82. package/templates/skills/test/SKILL.md +10 -0
  83. package/templates/skills/testgen/SKILL.md +1 -1
@@ -1,1556 +1,37 @@
1
1
  /**
2
2
  * sequant run - Execute workflow for GitHub issues
3
3
  *
4
- * Runs the Sequant workflow (/spec /exec → /qa) for one or more issues
5
- * using the Claude Agent SDK for proper skill invocation.
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
6
9
  */
7
10
  import chalk from "chalk";
8
11
  import { spawnSync } from "child_process";
9
- import { existsSync, readFileSync } from "fs";
10
- import path from "path";
11
- import { query } from "@anthropic-ai/claude-agent-sdk";
12
12
  import { getManifest } from "../lib/manifest.js";
13
13
  import { getSettings } from "../lib/settings.js";
14
- import { PM_CONFIG } from "../lib/stacks.js";
15
- import { LogWriter, createPhaseLogFromTiming, } from "../lib/workflow/log-writer.js";
14
+ import { LogWriter } from "../lib/workflow/log-writer.js";
16
15
  import { StateManager } from "../lib/workflow/state-manager.js";
17
16
  import { DEFAULT_PHASES, DEFAULT_CONFIG, } from "../lib/workflow/types.js";
18
17
  import { ShutdownManager } from "../lib/shutdown.js";
19
- import { getMcpServersConfig } from "../lib/system.js";
20
18
  import { checkVersionCached, getVersionWarning } from "../lib/version-check.js";
21
19
  import { MetricsWriter } from "../lib/workflow/metrics-writer.js";
22
20
  import { determineOutcome, } from "../lib/workflow/metrics-schema.js";
23
- import { getResumablePhasesForIssue } from "../lib/workflow/phase-detection.js";
24
21
  import { ui, colors } from "../lib/cli-ui.js";
25
- import { PhaseSpinner } from "../lib/phase-spinner.js";
26
- import { getGitDiffStats, getCommitHash, } from "../lib/workflow/git-diff-utils.js";
22
+ import { getCommitHash } from "../lib/workflow/git-diff-utils.js";
27
23
  import { getTokenUsageForRun } from "../lib/workflow/token-utils.js";
28
24
  import { reconcileStateAtStartup } from "../lib/workflow/state-utils.js";
29
- /**
30
- * Slugify a title for branch naming
31
- */
32
- function slugify(title) {
33
- return title
34
- .toLowerCase()
35
- .replace(/[^a-z0-9]+/g, "-")
36
- .replace(/^-+|-+$/g, "")
37
- .substring(0, 50);
38
- }
39
- /**
40
- * Parse QA verdict from phase output
41
- *
42
- * Looks for verdict patterns in the QA output:
43
- * - "### Verdict: READY_FOR_MERGE"
44
- * - "**Verdict:** AC_NOT_MET"
45
- * - "Verdict: AC_MET_BUT_NOT_A_PLUS"
46
- *
47
- * @param output - The captured output from QA phase
48
- * @returns The parsed verdict or null if not found
49
- */
50
- export function parseQaVerdict(output) {
51
- if (!output)
52
- return null;
53
- // Match various verdict formats:
54
- // - "### Verdict: X" (markdown header)
55
- // - "**Verdict:** X" (bold label with colon inside)
56
- // - "**Verdict:** **X**" (bold label and bold value)
57
- // - "Verdict: X" (plain)
58
- // Case insensitive, handles optional markdown formatting
59
- const verdictMatch = output.match(/(?:###?\s*)?(?:\*\*)?Verdict:?\*?\*?\s*\*?\*?\s*(READY_FOR_MERGE|AC_MET_BUT_NOT_A_PLUS|AC_NOT_MET|NEEDS_VERIFICATION)\*?\*?/i);
60
- if (!verdictMatch)
61
- return null;
62
- // Normalize to uppercase with underscores
63
- const verdict = verdictMatch[1].toUpperCase().replace(/-/g, "_");
64
- return verdict;
65
- }
66
- /**
67
- * Get the git repository root directory
68
- */
69
- function getGitRoot() {
70
- const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
71
- stdio: "pipe",
72
- });
73
- if (result.status === 0) {
74
- return result.stdout.toString().trim();
75
- }
76
- return null;
77
- }
78
- /**
79
- * Check if a worktree exists for a given branch
80
- */
81
- function findExistingWorktree(branch) {
82
- const result = spawnSync("git", ["worktree", "list", "--porcelain"], {
83
- stdio: "pipe",
84
- });
85
- if (result.status !== 0)
86
- return null;
87
- const output = result.stdout.toString();
88
- const lines = output.split("\n");
89
- let currentPath = "";
90
- for (const line of lines) {
91
- if (line.startsWith("worktree ")) {
92
- currentPath = line.substring(9);
93
- }
94
- else if (line.startsWith("branch refs/heads/") && line.includes(branch)) {
95
- return currentPath;
96
- }
97
- }
98
- return null;
99
- }
100
- /**
101
- * Check if a worktree is stale (behind origin/main) and should be recreated
102
- *
103
- * @param worktreePath - Path to the worktree
104
- * @param verbose - Enable verbose output
105
- * @returns Freshness check result
106
- */
107
- export function checkWorktreeFreshness(worktreePath, verbose) {
108
- const result = {
109
- isStale: false,
110
- commitsBehind: 0,
111
- hasUncommittedChanges: false,
112
- hasUnpushedCommits: false,
113
- };
114
- // Fetch latest main to ensure accurate comparison
115
- spawnSync("git", ["-C", worktreePath, "fetch", "origin", "main"], {
116
- stdio: "pipe",
117
- timeout: 30000,
118
- });
119
- // Check for uncommitted changes
120
- const statusResult = spawnSync("git", ["-C", worktreePath, "status", "--porcelain"], { stdio: "pipe" });
121
- if (statusResult.status === 0) {
122
- result.hasUncommittedChanges =
123
- statusResult.stdout.toString().trim().length > 0;
124
- }
125
- // Get merge base with origin/main
126
- const mergeBaseResult = spawnSync("git", ["-C", worktreePath, "merge-base", "HEAD", "origin/main"], { stdio: "pipe" });
127
- if (mergeBaseResult.status !== 0) {
128
- // Can't determine merge base - not stale
129
- return result;
130
- }
131
- const mergeBase = mergeBaseResult.stdout.toString().trim();
132
- // Get origin/main HEAD
133
- const mainHeadResult = spawnSync("git", ["-C", worktreePath, "rev-parse", "origin/main"], { stdio: "pipe" });
134
- if (mainHeadResult.status !== 0) {
135
- return result;
136
- }
137
- const mainHead = mainHeadResult.stdout.toString().trim();
138
- // Count commits behind main
139
- if (mergeBase !== mainHead) {
140
- const countResult = spawnSync("git", ["-C", worktreePath, "rev-list", "--count", `${mergeBase}..${mainHead}`], { stdio: "pipe" });
141
- if (countResult.status === 0) {
142
- result.commitsBehind = parseInt(countResult.stdout.toString().trim(), 10);
143
- // Consider stale if more than 5 commits behind (configurable threshold)
144
- result.isStale = result.commitsBehind > 5;
145
- }
146
- }
147
- // Check for unpushed commits (work in progress)
148
- const unpushedResult = spawnSync("git", ["-C", worktreePath, "log", "--oneline", "@{u}..HEAD"], { stdio: "pipe" });
149
- if (unpushedResult.status === 0) {
150
- result.hasUnpushedCommits =
151
- unpushedResult.stdout.toString().trim().length > 0;
152
- }
153
- if (verbose && result.isStale) {
154
- console.log(chalk.gray(` 📊 Worktree is ${result.commitsBehind} commits behind origin/main`));
155
- }
156
- return result;
157
- }
158
- /**
159
- * Remove and recreate a stale worktree
160
- *
161
- * @param existingPath - Path to existing worktree
162
- * @param branch - Branch name
163
- * @param verbose - Enable verbose output
164
- * @returns true if worktree was removed
165
- */
166
- export function removeStaleWorktree(existingPath, branch, verbose) {
167
- if (verbose) {
168
- console.log(chalk.gray(` 🗑️ Removing stale worktree...`));
169
- }
170
- // Remove the worktree
171
- const removeResult = spawnSync("git", ["worktree", "remove", "--force", existingPath], { stdio: "pipe" });
172
- if (removeResult.status !== 0) {
173
- const error = removeResult.stderr.toString();
174
- console.log(chalk.yellow(` ⚠️ Could not remove worktree: ${error}`));
175
- return false;
176
- }
177
- // Delete the branch so it can be recreated fresh
178
- const deleteResult = spawnSync("git", ["branch", "-D", branch], {
179
- stdio: "pipe",
180
- });
181
- if (deleteResult.status !== 0 && verbose) {
182
- console.log(chalk.gray(` ℹ️ Branch ${branch} not deleted (may not exist locally)`));
183
- }
184
- return true;
185
- }
186
- /**
187
- * List all active worktrees with their branches
188
- */
189
- export function listWorktrees() {
190
- const result = spawnSync("git", ["worktree", "list", "--porcelain"], {
191
- stdio: "pipe",
192
- });
193
- if (result.status !== 0)
194
- return [];
195
- const output = result.stdout.toString();
196
- const lines = output.split("\n");
197
- const worktrees = [];
198
- let currentPath = "";
199
- let currentBranch = "";
200
- for (const line of lines) {
201
- if (line.startsWith("worktree ")) {
202
- currentPath = line.substring(9);
203
- }
204
- else if (line.startsWith("branch refs/heads/")) {
205
- currentBranch = line.substring(18);
206
- // Extract issue number from branch name (e.g., feature/123-some-title)
207
- const issueMatch = currentBranch.match(/feature\/(\d+)-/);
208
- const issue = issueMatch ? parseInt(issueMatch[1], 10) : null;
209
- worktrees.push({ path: currentPath, branch: currentBranch, issue });
210
- currentPath = "";
211
- currentBranch = "";
212
- }
213
- }
214
- return worktrees;
215
- }
216
- /**
217
- * Get changed files in a worktree compared to main
218
- */
219
- export function getWorktreeChangedFiles(worktreePath) {
220
- const result = spawnSync("git", ["-C", worktreePath, "diff", "--name-only", "main...HEAD"], { stdio: "pipe" });
221
- if (result.status !== 0)
222
- return [];
223
- return result.stdout
224
- .toString()
225
- .trim()
226
- .split("\n")
227
- .filter((f) => f.length > 0);
228
- }
229
- /**
230
- * Get diff stats for a worktree (files changed, lines added)
231
- * Returns aggregate metrics only - no file paths to preserve privacy
232
- */
233
- export function getWorktreeDiffStats(worktreePath) {
234
- const result = spawnSync("git", ["-C", worktreePath, "diff", "--stat", "main...HEAD"], { stdio: "pipe" });
235
- if (result.status !== 0) {
236
- return { filesChanged: 0, linesAdded: 0 };
237
- }
238
- const output = result.stdout.toString();
239
- const lines = output.trim().split("\n");
240
- // Summary line is last and looks like: " 5 files changed, 100 insertions(+), 20 deletions(-)"
241
- const summaryLine = lines[lines.length - 1];
242
- if (!summaryLine) {
243
- return { filesChanged: 0, linesAdded: 0 };
244
- }
245
- const filesMatch = summaryLine.match(/(\d+)\s+files?\s+changed/);
246
- const insertionsMatch = summaryLine.match(/(\d+)\s+insertions?\(\+\)/);
247
- return {
248
- filesChanged: filesMatch ? parseInt(filesMatch[1], 10) : 0,
249
- linesAdded: insertionsMatch ? parseInt(insertionsMatch[1], 10) : 0,
250
- };
251
- }
252
- /**
253
- * Read cache metrics from QA phase (AC-7)
254
- *
255
- * @param worktreePath - Path to the worktree
256
- * @returns CacheMetrics or undefined if not available
257
- */
258
- function readCacheMetrics(worktreePath) {
259
- const cacheMetricsPath = worktreePath
260
- ? path.join(worktreePath, ".sequant/.cache/qa/cache-metrics.json")
261
- : ".sequant/.cache/qa/cache-metrics.json";
262
- if (!existsSync(cacheMetricsPath)) {
263
- return undefined;
264
- }
265
- try {
266
- const content = readFileSync(cacheMetricsPath, "utf-8");
267
- const data = JSON.parse(content);
268
- if (typeof data.hits === "number" &&
269
- typeof data.misses === "number" &&
270
- typeof data.skipped === "number") {
271
- return {
272
- hits: data.hits,
273
- misses: data.misses,
274
- skipped: data.skipped,
275
- };
276
- }
277
- }
278
- catch {
279
- // Ignore parse errors
280
- }
281
- return undefined;
282
- }
283
- /**
284
- * Filter phases based on resume status.
285
- *
286
- * When `resume` is true, calls `getResumablePhasesForIssue` to determine
287
- * which phases have already completed (via GitHub issue comment markers)
288
- * and removes them from the execution list.
289
- *
290
- * @param issueNumber - GitHub issue number
291
- * @param phases - The phases to potentially filter
292
- * @param resume - Whether the --resume flag is set
293
- * @returns Object with filtered phases and any skipped phases
294
- */
295
- export function filterResumedPhases(issueNumber, phases, resume) {
296
- if (!resume) {
297
- return { phases: [...phases], skipped: [] };
298
- }
299
- const resumable = getResumablePhasesForIssue(issueNumber, phases);
300
- const skipped = phases.filter((p) => !resumable.includes(p));
301
- return { phases: resumable, skipped };
302
- }
303
- /**
304
- * Create or reuse a worktree for an issue
305
- * @param baseBranch - Optional branch to use as base instead of origin/main (for chain mode)
306
- * @param chainMode - If true and branch exists, rebase onto baseBranch instead of using as-is
307
- */
308
- async function ensureWorktree(issueNumber, title, verbose, packageManager, baseBranch, chainMode) {
309
- const gitRoot = getGitRoot();
310
- if (!gitRoot) {
311
- console.log(chalk.red(" ❌ Not in a git repository"));
312
- return null;
313
- }
314
- const slug = slugify(title);
315
- const branch = `feature/${issueNumber}-${slug}`;
316
- const worktreesDir = path.join(path.dirname(gitRoot), "worktrees");
317
- const worktreePath = path.join(worktreesDir, branch);
318
- // Check if worktree already exists
319
- let existingPath = findExistingWorktree(branch);
320
- if (existingPath) {
321
- // AC-3: Check if worktree is stale and needs recreation
322
- const freshness = checkWorktreeFreshness(existingPath, verbose);
323
- if (freshness.isStale) {
324
- // AC-3: Handle stale worktrees - check for work in progress
325
- if (freshness.hasUncommittedChanges) {
326
- console.log(chalk.yellow(` ⚠️ Worktree is ${freshness.commitsBehind} commits behind main but has uncommitted changes`));
327
- console.log(chalk.yellow(` ℹ️ Keeping existing worktree. Commit or stash changes, then re-run.`));
328
- // Continue with existing worktree
329
- }
330
- else if (freshness.hasUnpushedCommits) {
331
- console.log(chalk.yellow(` ⚠️ Worktree is ${freshness.commitsBehind} commits behind main but has unpushed commits`));
332
- console.log(chalk.yellow(` ℹ️ Keeping existing worktree with WIP commits.`));
333
- // Continue with existing worktree
334
- }
335
- else {
336
- // Safe to recreate - no uncommitted/unpushed work
337
- console.log(chalk.yellow(` ⚠️ Worktree is ${freshness.commitsBehind} commits behind main — recreating fresh`));
338
- if (removeStaleWorktree(existingPath, branch, verbose)) {
339
- existingPath = null; // Will fall through to create new worktree
340
- }
341
- }
342
- }
343
- }
344
- if (existingPath) {
345
- if (verbose) {
346
- console.log(chalk.gray(` 📂 Reusing existing worktree: ${existingPath}`));
347
- }
348
- // In chain mode, rebase existing worktree onto previous chain link
349
- if (chainMode && baseBranch) {
350
- if (verbose) {
351
- console.log(chalk.gray(` 🔄 Rebasing existing worktree onto chain base (${baseBranch})...`));
352
- }
353
- const rebaseResult = spawnSync("git", ["-C", existingPath, "rebase", baseBranch], { stdio: "pipe" });
354
- if (rebaseResult.status !== 0) {
355
- const rebaseError = rebaseResult.stderr.toString();
356
- // Check if it's a conflict
357
- if (rebaseError.includes("CONFLICT") ||
358
- rebaseError.includes("could not apply")) {
359
- console.log(chalk.yellow(` ⚠️ Rebase conflict detected. Aborting rebase and keeping original branch state.`));
360
- console.log(chalk.yellow(` ℹ️ Branch ${branch} is not properly chained. Manual rebase may be required.`));
361
- // Abort the rebase to restore branch state
362
- spawnSync("git", ["-C", existingPath, "rebase", "--abort"], {
363
- stdio: "pipe",
364
- });
365
- }
366
- else {
367
- console.log(chalk.yellow(` ⚠️ Rebase failed: ${rebaseError.trim()}`));
368
- console.log(chalk.yellow(` ℹ️ Continuing with branch in its original state.`));
369
- }
370
- return {
371
- issue: issueNumber,
372
- path: existingPath,
373
- branch,
374
- existed: true,
375
- rebased: false,
376
- };
377
- }
378
- if (verbose) {
379
- console.log(chalk.green(` ✅ Existing worktree rebased onto ${baseBranch}`));
380
- }
381
- return {
382
- issue: issueNumber,
383
- path: existingPath,
384
- branch,
385
- existed: true,
386
- rebased: true,
387
- };
388
- }
389
- return {
390
- issue: issueNumber,
391
- path: existingPath,
392
- branch,
393
- existed: true,
394
- rebased: false,
395
- };
396
- }
397
- // Check if branch exists (but no worktree)
398
- const branchCheck = spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { stdio: "pipe" });
399
- const branchExists = branchCheck.status === 0;
400
- if (verbose) {
401
- console.log(chalk.gray(` 🌿 Creating worktree for #${issueNumber}...`));
402
- }
403
- // Determine the base for the new branch
404
- // For custom base branches, use origin/<branch> if it's a remote-style reference
405
- // For local branches (chain mode), use as-is
406
- const isLocalBranch = baseBranch && !baseBranch.startsWith("origin/") && baseBranch !== "main";
407
- const baseRef = baseBranch
408
- ? isLocalBranch
409
- ? baseBranch
410
- : baseBranch.startsWith("origin/")
411
- ? baseBranch
412
- : `origin/${baseBranch}`
413
- : "origin/main";
414
- // Fetch the base branch to ensure worktree starts from fresh baseline
415
- const branchToFetch = baseBranch
416
- ? baseBranch.replace(/^origin\//, "")
417
- : "main";
418
- if (!isLocalBranch) {
419
- if (verbose) {
420
- console.log(chalk.gray(` 🔄 Fetching latest ${branchToFetch}...`));
421
- }
422
- const fetchResult = spawnSync("git", ["fetch", "origin", branchToFetch], {
423
- stdio: "pipe",
424
- });
425
- if (fetchResult.status !== 0 && verbose) {
426
- console.log(chalk.yellow(` ⚠️ Could not fetch origin/${branchToFetch}, using local state`));
427
- }
428
- }
429
- else if (verbose) {
430
- console.log(chalk.gray(` 🔗 Chaining from branch: ${baseBranch}`));
431
- }
432
- // Ensure worktrees directory exists
433
- if (!existsSync(worktreesDir)) {
434
- spawnSync("mkdir", ["-p", worktreesDir], { stdio: "pipe" });
435
- }
436
- // Create the worktree
437
- let createResult;
438
- let needsRebase = false;
439
- if (branchExists) {
440
- // Use existing branch
441
- createResult = spawnSync("git", ["worktree", "add", worktreePath, branch], {
442
- stdio: "pipe",
443
- });
444
- // In chain mode with existing branch, mark for rebase onto previous chain link
445
- if (chainMode && baseBranch) {
446
- needsRebase = true;
447
- }
448
- }
449
- else {
450
- // Create new branch from base reference (origin/main or previous branch in chain)
451
- createResult = spawnSync("git", ["worktree", "add", worktreePath, "-b", branch, baseRef], { stdio: "pipe" });
452
- }
453
- if (createResult.status !== 0) {
454
- const error = createResult.stderr.toString();
455
- console.log(chalk.red(` ❌ Failed to create worktree: ${error}`));
456
- return null;
457
- }
458
- // Rebase existing branch onto chain base if needed
459
- let rebased = false;
460
- if (needsRebase) {
461
- if (verbose) {
462
- console.log(chalk.gray(` 🔄 Rebasing existing branch onto previous chain link (${baseRef})...`));
463
- }
464
- const rebaseResult = spawnSync("git", ["-C", worktreePath, "rebase", baseRef], {
465
- stdio: "pipe",
466
- });
467
- if (rebaseResult.status !== 0) {
468
- const rebaseError = rebaseResult.stderr.toString();
469
- // Check if it's a conflict
470
- if (rebaseError.includes("CONFLICT") ||
471
- rebaseError.includes("could not apply")) {
472
- console.log(chalk.yellow(` ⚠️ Rebase conflict detected. Aborting rebase and keeping original branch state.`));
473
- console.log(chalk.yellow(` ℹ️ Branch ${branch} is not properly chained. Manual rebase may be required.`));
474
- // Abort the rebase to restore branch state
475
- spawnSync("git", ["-C", worktreePath, "rebase", "--abort"], {
476
- stdio: "pipe",
477
- });
478
- }
479
- else {
480
- console.log(chalk.yellow(` ⚠️ Rebase failed: ${rebaseError.trim()}`));
481
- console.log(chalk.yellow(` ℹ️ Continuing with branch in its original state.`));
482
- }
483
- }
484
- else {
485
- rebased = true;
486
- if (verbose) {
487
- console.log(chalk.green(` ✅ Branch rebased onto ${baseRef}`));
488
- }
489
- }
490
- }
491
- // Copy .env.local if it exists
492
- const envLocalSrc = path.join(gitRoot, ".env.local");
493
- const envLocalDst = path.join(worktreePath, ".env.local");
494
- if (existsSync(envLocalSrc) && !existsSync(envLocalDst)) {
495
- spawnSync("cp", [envLocalSrc, envLocalDst], { stdio: "pipe" });
496
- }
497
- // Copy .claude/settings.local.json if it exists
498
- const claudeSettingsSrc = path.join(gitRoot, ".claude", "settings.local.json");
499
- const claudeSettingsDst = path.join(worktreePath, ".claude", "settings.local.json");
500
- if (existsSync(claudeSettingsSrc) && !existsSync(claudeSettingsDst)) {
501
- spawnSync("mkdir", ["-p", path.join(worktreePath, ".claude")], {
502
- stdio: "pipe",
503
- });
504
- spawnSync("cp", [claudeSettingsSrc, claudeSettingsDst], { stdio: "pipe" });
505
- }
506
- // Install dependencies if needed
507
- const nodeModulesPath = path.join(worktreePath, "node_modules");
508
- if (!existsSync(nodeModulesPath)) {
509
- if (verbose) {
510
- console.log(chalk.gray(` 📦 Installing dependencies...`));
511
- }
512
- // Use detected package manager or default to npm
513
- const pm = packageManager || "npm";
514
- const pmConfig = PM_CONFIG[pm];
515
- const [cmd, ...args] = pmConfig.installSilent.split(" ");
516
- spawnSync(cmd, args, {
517
- cwd: worktreePath,
518
- stdio: "pipe",
519
- });
520
- }
521
- if (verbose) {
522
- console.log(chalk.green(` ✅ Worktree ready: ${worktreePath}`));
523
- }
524
- return {
525
- issue: issueNumber,
526
- path: worktreePath,
527
- branch,
528
- existed: false,
529
- rebased,
530
- };
531
- }
532
- /**
533
- * Ensure worktrees exist for all issues before execution
534
- * @param baseBranch - Optional base branch for worktree creation (default: main)
535
- */
536
- async function ensureWorktrees(issues, verbose, packageManager, baseBranch) {
537
- const worktrees = new Map();
538
- const baseDisplay = baseBranch || "main";
539
- console.log(chalk.blue(`\n 📂 Preparing worktrees from ${baseDisplay}...`));
540
- for (const issue of issues) {
541
- const worktree = await ensureWorktree(issue.number, issue.title, verbose, packageManager, baseBranch, false);
542
- if (worktree) {
543
- worktrees.set(issue.number, worktree);
544
- }
545
- }
546
- const created = Array.from(worktrees.values()).filter((w) => !w.existed).length;
547
- const reused = Array.from(worktrees.values()).filter((w) => w.existed).length;
548
- if (created > 0 || reused > 0) {
549
- console.log(chalk.gray(` Worktrees: ${created} created, ${reused} reused`));
550
- }
551
- return worktrees;
552
- }
553
- /**
554
- * Ensure worktrees exist for all issues in chain mode
555
- * Each issue branches from the previous issue's branch
556
- * @param baseBranch - Optional starting base branch for the chain (default: main)
557
- */
558
- async function ensureWorktreesChain(issues, verbose, packageManager, baseBranch) {
559
- const worktrees = new Map();
560
- const baseDisplay = baseBranch || "main";
561
- console.log(chalk.blue(`\n 🔗 Preparing chained worktrees from ${baseDisplay}...`));
562
- // First issue starts from the specified base branch (or main)
563
- let previousBranch = baseBranch;
564
- for (const issue of issues) {
565
- const worktree = await ensureWorktree(issue.number, issue.title, verbose, packageManager, previousBranch, // Chain from previous branch (or base branch for first issue)
566
- true);
567
- if (worktree) {
568
- worktrees.set(issue.number, worktree);
569
- previousBranch = worktree.branch; // Next issue will branch from this
570
- }
571
- else {
572
- // If worktree creation fails, stop the chain
573
- console.log(chalk.red(` ❌ Chain broken: could not create worktree for #${issue.number}`));
574
- break;
575
- }
576
- }
577
- const created = Array.from(worktrees.values()).filter((w) => !w.existed).length;
578
- const reused = Array.from(worktrees.values()).filter((w) => w.existed).length;
579
- const rebased = Array.from(worktrees.values()).filter((w) => w.rebased).length;
580
- if (created > 0 || reused > 0) {
581
- let msg = ` Chained worktrees: ${created} created, ${reused} reused`;
582
- if (rebased > 0) {
583
- msg += `, ${rebased} rebased`;
584
- }
585
- console.log(chalk.gray(msg));
586
- }
587
- // Show chain structure
588
- if (worktrees.size > 0) {
589
- const chainOrder = issues
590
- .filter((i) => worktrees.has(i.number))
591
- .map((i) => `#${i.number}`)
592
- .join(" → ");
593
- console.log(chalk.gray(` Chain: ${baseDisplay} → ${chainOrder}`));
594
- }
595
- return worktrees;
596
- }
597
- /**
598
- * Create a checkpoint commit in the worktree after QA passes
599
- * This allows recovery in case later issues in the chain fail
600
- * @internal Exported for testing
601
- */
602
- export function createCheckpointCommit(worktreePath, issueNumber, verbose) {
603
- // Check if there are uncommitted changes
604
- const statusResult = spawnSync("git", ["-C", worktreePath, "status", "--porcelain"], { stdio: "pipe" });
605
- if (statusResult.status !== 0) {
606
- if (verbose) {
607
- console.log(chalk.yellow(` ⚠️ Could not check git status for checkpoint`));
608
- }
609
- return false;
610
- }
611
- const hasChanges = statusResult.stdout.toString().trim().length > 0;
612
- if (!hasChanges) {
613
- if (verbose) {
614
- console.log(chalk.gray(` 📌 No changes to checkpoint (already committed)`));
615
- }
616
- return true;
617
- }
618
- // Stage all changes
619
- const addResult = spawnSync("git", ["-C", worktreePath, "add", "-A"], {
620
- stdio: "pipe",
621
- });
622
- if (addResult.status !== 0) {
623
- if (verbose) {
624
- console.log(chalk.yellow(` ⚠️ Could not stage changes for checkpoint`));
625
- }
626
- return false;
627
- }
628
- // Create checkpoint commit
629
- const commitMessage = `checkpoint(#${issueNumber}): QA passed
630
-
631
- This is an automatic checkpoint commit created after issue #${issueNumber}
632
- passed QA in chain mode. It serves as a recovery point if later issues fail.`;
633
- const commitResult = spawnSync("git", ["-C", worktreePath, "commit", "-m", commitMessage], { stdio: "pipe" });
634
- if (commitResult.status !== 0) {
635
- const error = commitResult.stderr.toString();
636
- if (verbose) {
637
- console.log(chalk.yellow(` ⚠️ Could not create checkpoint commit: ${error}`));
638
- }
639
- return false;
640
- }
641
- console.log(chalk.green(` 📌 Checkpoint commit created for #${issueNumber}`));
642
- return true;
643
- }
644
- /**
645
- * Lockfile names for different package managers
646
- */
647
- const LOCKFILES = [
648
- "package-lock.json",
649
- "pnpm-lock.yaml",
650
- "bun.lock",
651
- "yarn.lock",
652
- ];
653
- /**
654
- * Check if any lockfile changed during a rebase and re-run install if needed.
655
- * This prevents dependency drift when the lockfile was updated on main.
656
- * @param worktreePath Path to the worktree
657
- * @param packageManager Package manager to use for install
658
- * @param verbose Whether to show verbose output
659
- * @param preRebaseRef Git ref pointing to pre-rebase HEAD (defaults to ORIG_HEAD,
660
- * which git sets automatically after rebase). Using ORIG_HEAD captures all
661
- * lockfile changes across multi-commit rebases, unlike HEAD~1 which only
662
- * checks the last commit.
663
- * @returns true if reinstall was performed, false otherwise
664
- * @internal Exported for testing
665
- */
666
- export function reinstallIfLockfileChanged(worktreePath, packageManager, verbose, preRebaseRef = "ORIG_HEAD") {
667
- // Compare pre-rebase state to current HEAD to detect all lockfile changes
668
- // introduced by the rebase (including changes from main that were pulled in)
669
- let lockfileChanged = false;
670
- for (const lockfile of LOCKFILES) {
671
- const result = spawnSync("git", [
672
- "-C",
673
- worktreePath,
674
- "diff",
675
- "--name-only",
676
- `${preRebaseRef}..HEAD`,
677
- "--",
678
- lockfile,
679
- ], { stdio: "pipe" });
680
- if (result.status === 0 && result.stdout.toString().trim().length > 0) {
681
- lockfileChanged = true;
682
- if (verbose) {
683
- console.log(chalk.gray(` 📦 Lockfile changed: ${lockfile}`));
684
- }
685
- break;
686
- }
687
- }
688
- if (!lockfileChanged) {
689
- if (verbose) {
690
- console.log(chalk.gray(` 📦 No lockfile changes detected`));
691
- }
692
- return false;
693
- }
694
- // Re-run install to sync node_modules with updated lockfile
695
- console.log(chalk.blue(` 📦 Reinstalling dependencies (lockfile changed)...`));
696
- const pm = packageManager || "npm";
697
- const pmConfig = PM_CONFIG[pm];
698
- const [cmd, ...args] = pmConfig.installSilent.split(" ");
699
- const installResult = spawnSync(cmd, args, {
700
- cwd: worktreePath,
701
- stdio: "pipe",
702
- });
703
- if (installResult.status !== 0) {
704
- const error = installResult.stderr.toString();
705
- console.log(chalk.yellow(` ⚠️ Dependency reinstall failed: ${error.trim()}`));
706
- return false;
707
- }
708
- console.log(chalk.green(` ✅ Dependencies reinstalled`));
709
- return true;
710
- }
711
- /**
712
- * Rebase the worktree branch onto origin/main before PR creation.
713
- * This ensures the branch is up-to-date and prevents lockfile drift.
714
- *
715
- * @param worktreePath Path to the worktree
716
- * @param issueNumber Issue number (for logging)
717
- * @param packageManager Package manager to use if reinstall needed
718
- * @param verbose Whether to show verbose output
719
- * @returns RebaseResult indicating success/failure and whether reinstall was performed
720
- * @internal Exported for testing
721
- */
722
- export function rebaseBeforePR(worktreePath, issueNumber, packageManager, verbose) {
723
- if (verbose) {
724
- console.log(chalk.gray(` 🔄 Rebasing #${issueNumber} onto origin/main before PR...`));
725
- }
726
- // Fetch latest main to ensure we're rebasing onto fresh state
727
- const fetchResult = spawnSync("git", ["-C", worktreePath, "fetch", "origin", "main"], {
728
- stdio: "pipe",
729
- });
730
- if (fetchResult.status !== 0) {
731
- const error = fetchResult.stderr.toString();
732
- console.log(chalk.yellow(` ⚠️ Could not fetch origin/main: ${error.trim()}`));
733
- // Continue anyway - might work with local state
734
- }
735
- // Perform the rebase
736
- const rebaseResult = spawnSync("git", ["-C", worktreePath, "rebase", "origin/main"], { stdio: "pipe" });
737
- if (rebaseResult.status !== 0) {
738
- const rebaseError = rebaseResult.stderr.toString();
739
- // Check if it's a conflict
740
- if (rebaseError.includes("CONFLICT") ||
741
- rebaseError.includes("could not apply")) {
742
- console.log(chalk.yellow(` ⚠️ Rebase conflict detected. Aborting rebase and keeping original branch state.`));
743
- console.log(chalk.yellow(` ℹ️ PR will be created without rebase. Manual rebase may be required before merge.`));
744
- // Abort the rebase to restore branch state
745
- spawnSync("git", ["-C", worktreePath, "rebase", "--abort"], {
746
- stdio: "pipe",
747
- });
748
- return {
749
- performed: true,
750
- success: false,
751
- reinstalled: false,
752
- error: "Rebase conflict - manual resolution required",
753
- };
754
- }
755
- else {
756
- console.log(chalk.yellow(` ⚠️ Rebase failed: ${rebaseError.trim()}`));
757
- console.log(chalk.yellow(` ℹ️ Continuing with branch in its original state.`));
758
- return {
759
- performed: true,
760
- success: false,
761
- reinstalled: false,
762
- error: rebaseError.trim(),
763
- };
764
- }
765
- }
766
- console.log(chalk.green(` ✅ Branch rebased onto origin/main`));
767
- // Check if lockfile changed and reinstall if needed
768
- const reinstalled = reinstallIfLockfileChanged(worktreePath, packageManager, verbose);
769
- return {
770
- performed: true,
771
- success: true,
772
- reinstalled,
773
- };
774
- }
775
- /**
776
- * Push branch and create a PR after successful QA.
777
- *
778
- * Handles both fresh PR creation and detection of existing PRs.
779
- * Failures are warnings — they don't fail the run.
780
- *
781
- * @param worktreePath Path to the worktree
782
- * @param issueNumber Issue number
783
- * @param issueTitle Issue title (for PR title)
784
- * @param branch Branch name
785
- * @param verbose Whether to show verbose output
786
- * @returns PRCreationResult with PR info or error
787
- * @internal Exported for testing
788
- */
789
- export function createPR(worktreePath, issueNumber, issueTitle, branch, verbose, labels) {
790
- // Step 1: Check for existing PR on this branch
791
- const existingPR = spawnSync("gh", ["pr", "view", branch, "--json", "number,url"], { stdio: "pipe", cwd: worktreePath, timeout: 15000 });
792
- if (existingPR.status === 0 && existingPR.stdout) {
793
- try {
794
- const prInfo = JSON.parse(existingPR.stdout.toString());
795
- if (prInfo.number && prInfo.url) {
796
- if (verbose) {
797
- console.log(chalk.gray(` ℹ️ PR #${prInfo.number} already exists for branch ${branch}`));
798
- }
799
- return {
800
- attempted: true,
801
- success: true,
802
- prNumber: prInfo.number,
803
- prUrl: prInfo.url,
804
- };
805
- }
806
- }
807
- catch {
808
- // JSON parse failed — no existing PR, continue to create
809
- }
810
- }
811
- // Step 2: Push branch to remote
812
- if (verbose) {
813
- console.log(chalk.gray(` 🚀 Pushing branch ${branch} to origin...`));
814
- }
815
- const pushResult = spawnSync("git", ["-C", worktreePath, "push", "-u", "origin", branch], { stdio: "pipe", timeout: 60000 });
816
- if (pushResult.status !== 0) {
817
- const pushError = pushResult.stderr?.toString().trim() ?? "Unknown error";
818
- console.log(chalk.yellow(` ⚠️ git push failed: ${pushError}`));
819
- return {
820
- attempted: true,
821
- success: false,
822
- error: `git push failed: ${pushError}`,
823
- };
824
- }
825
- // Step 3: Create PR
826
- if (verbose) {
827
- console.log(chalk.gray(` 📝 Creating PR for #${issueNumber}...`));
828
- }
829
- const isBug = labels?.some((l) => /^bug/i.test(l));
830
- const prefix = isBug ? "fix" : "feat";
831
- const prTitle = `${prefix}(#${issueNumber}): ${issueTitle}`;
832
- const prBody = [
833
- `## Summary`,
834
- ``,
835
- `Automated PR for issue #${issueNumber}.`,
836
- ``,
837
- `Fixes #${issueNumber}`,
838
- ``,
839
- `---`,
840
- `🤖 Generated by \`sequant run\``,
841
- ].join("\n");
842
- const prResult = spawnSync("gh", ["pr", "create", "--title", prTitle, "--body", prBody, "--head", branch], { stdio: "pipe", cwd: worktreePath, timeout: 30000 });
843
- if (prResult.status !== 0) {
844
- const prError = prResult.stderr?.toString().trim() ?? "Unknown error";
845
- // Check if PR already exists (race condition or push-before-PR scenarios)
846
- if (prError.includes("already exists")) {
847
- const retryView = spawnSync("gh", ["pr", "view", branch, "--json", "number,url"], { stdio: "pipe", cwd: worktreePath, timeout: 15000 });
848
- if (retryView.status === 0 && retryView.stdout) {
849
- try {
850
- const prInfo = JSON.parse(retryView.stdout.toString());
851
- return {
852
- attempted: true,
853
- success: true,
854
- prNumber: prInfo.number,
855
- prUrl: prInfo.url,
856
- };
857
- }
858
- catch {
859
- // Fall through to error
860
- }
861
- }
862
- }
863
- console.log(chalk.yellow(` ⚠️ PR creation failed: ${prError}`));
864
- return {
865
- attempted: true,
866
- success: false,
867
- error: `gh pr create failed: ${prError}`,
868
- };
869
- }
870
- // Step 4: Extract PR URL from output and get PR details
871
- const prOutput = prResult.stdout?.toString().trim() ?? "";
872
- const prUrlMatch = prOutput.match(/https:\/\/github\.com\/[^\s]+\/pull\/(\d+)/);
873
- if (prUrlMatch) {
874
- const prNumber = parseInt(prUrlMatch[1], 10);
875
- const prUrl = prUrlMatch[0];
876
- console.log(chalk.green(` ✅ PR #${prNumber} created: ${prUrl}`));
877
- return {
878
- attempted: true,
879
- success: true,
880
- prNumber,
881
- prUrl,
882
- };
883
- }
884
- // Fallback: try gh pr view to get details
885
- const viewResult = spawnSync("gh", ["pr", "view", branch, "--json", "number,url"], { stdio: "pipe", cwd: worktreePath, timeout: 15000 });
886
- if (viewResult.status === 0 && viewResult.stdout) {
887
- try {
888
- const prInfo = JSON.parse(viewResult.stdout.toString());
889
- console.log(chalk.green(` ✅ PR #${prInfo.number} created: ${prInfo.url}`));
890
- return {
891
- attempted: true,
892
- success: true,
893
- prNumber: prInfo.number,
894
- prUrl: prInfo.url,
895
- };
896
- }
897
- catch {
898
- // Fall through
899
- }
900
- }
901
- // PR was created but we couldn't parse the URL
902
- console.log(chalk.yellow(` ⚠️ PR created but could not extract URL from output: ${prOutput}`));
903
- return {
904
- attempted: true,
905
- success: true,
906
- error: "PR created but URL extraction failed",
907
- };
908
- }
909
- /**
910
- * Natural language prompts for each phase
911
- * These prompts will invoke the corresponding skills via natural language
912
- */
913
- const PHASE_PROMPTS = {
914
- spec: "Review GitHub issue #{issue} and create an implementation plan with verification criteria. Run the /spec {issue} workflow.",
915
- "security-review": "Perform a deep security analysis for GitHub issue #{issue} focusing on auth, permissions, and sensitive operations. Run the /security-review {issue} workflow.",
916
- testgen: "Generate test stubs for GitHub issue #{issue} based on the specification. Run the /testgen {issue} workflow.",
917
- exec: "Implement the feature for GitHub issue #{issue} following the spec. Run the /exec {issue} workflow.",
918
- test: "Execute structured browser-based testing for GitHub issue #{issue}. Run the /test {issue} workflow.",
919
- qa: "Review the implementation for GitHub issue #{issue} against acceptance criteria. Run the /qa {issue} workflow.",
920
- loop: "Parse test/QA findings for GitHub issue #{issue} and iterate until quality gates pass. Run the /loop {issue} workflow.",
921
- };
922
- /**
923
- * UI-related labels that trigger automatic test phase
924
- */
925
- const UI_LABELS = ["ui", "frontend", "admin", "web", "browser"];
926
- /**
927
- * Bug-related labels that skip spec phase
928
- */
929
- const BUG_LABELS = ["bug", "fix", "hotfix", "patch"];
930
- /**
931
- * Documentation labels that skip spec phase
932
- */
933
- const DOCS_LABELS = ["docs", "documentation", "readme"];
934
- /**
935
- * Complex labels that enable quality loop
936
- */
937
- const COMPLEX_LABELS = ["complex", "refactor", "breaking", "major"];
938
- /**
939
- * Security-related labels that trigger security-review phase
940
- */
941
- const SECURITY_LABELS = [
942
- "security",
943
- "auth",
944
- "authentication",
945
- "permissions",
946
- "admin",
947
- ];
948
- /**
949
- * Detect phases based on issue labels (like /solve logic)
950
- */
951
- export function detectPhasesFromLabels(labels) {
952
- const lowerLabels = labels.map((l) => l.toLowerCase());
953
- // Check for bug/fix labels → exec → qa (skip spec)
954
- const isBugFix = lowerLabels.some((label) => BUG_LABELS.some((bugLabel) => label.includes(bugLabel)));
955
- // Check for docs labels → exec → qa (skip spec)
956
- const isDocs = lowerLabels.some((label) => DOCS_LABELS.some((docsLabel) => label.includes(docsLabel)));
957
- // Check for UI labels → add test phase
958
- const isUI = lowerLabels.some((label) => UI_LABELS.some((uiLabel) => label.includes(uiLabel)));
959
- // Check for complex labels → enable quality loop
960
- const isComplex = lowerLabels.some((label) => COMPLEX_LABELS.some((complexLabel) => label.includes(complexLabel)));
961
- // Check for security labels → add security-review phase
962
- const isSecurity = lowerLabels.some((label) => SECURITY_LABELS.some((secLabel) => label.includes(secLabel)));
963
- // Build phase list
964
- let phases;
965
- if (isBugFix || isDocs) {
966
- // Simple workflow: exec → qa
967
- phases = ["exec", "qa"];
968
- }
969
- else if (isUI) {
970
- // UI workflow: spec → exec → test → qa
971
- phases = ["spec", "exec", "test", "qa"];
972
- }
973
- else {
974
- // Standard workflow: spec → exec → qa
975
- phases = ["spec", "exec", "qa"];
976
- }
977
- // Add security-review phase after spec if security labels detected
978
- if (isSecurity && phases.includes("spec")) {
979
- const specIndex = phases.indexOf("spec");
980
- phases.splice(specIndex + 1, 0, "security-review");
981
- }
982
- return { phases, qualityLoop: isComplex };
983
- }
984
- /**
985
- * Parse recommended workflow from /spec output
986
- *
987
- * Looks for:
988
- * ## Recommended Workflow
989
- * **Phases:** exec → qa
990
- * **Quality Loop:** enabled|disabled
991
- */
992
- export function parseRecommendedWorkflow(output) {
993
- // Find the Recommended Workflow section
994
- const workflowMatch = output.match(/## Recommended Workflow[\s\S]*?\*\*Phases:\*\*\s*([^\n]+)/i);
995
- if (!workflowMatch) {
996
- return null;
997
- }
998
- // Parse phases from "exec → qa" or "spec → exec → test → qa" format
999
- const phasesStr = workflowMatch[1].trim();
1000
- const phaseNames = phasesStr
1001
- .split(/\s*→\s*|\s*->\s*|\s*,\s*/)
1002
- .map((p) => p.trim().toLowerCase())
1003
- .filter((p) => p.length > 0);
1004
- // Validate and convert to Phase type
1005
- const validPhases = [];
1006
- for (const name of phaseNames) {
1007
- if ([
1008
- "spec",
1009
- "security-review",
1010
- "testgen",
1011
- "exec",
1012
- "test",
1013
- "qa",
1014
- "loop",
1015
- ].includes(name)) {
1016
- validPhases.push(name);
1017
- }
1018
- }
1019
- if (validPhases.length === 0) {
1020
- return null;
1021
- }
1022
- // Parse quality loop setting
1023
- const qualityLoopMatch = output.match(/\*\*Quality Loop:\*\*\s*(enabled|disabled|true|false|yes|no)/i);
1024
- const qualityLoop = qualityLoopMatch
1025
- ? ["enabled", "true", "yes"].includes(qualityLoopMatch[1].toLowerCase())
1026
- : false;
1027
- return { phases: validPhases, qualityLoop };
1028
- }
1029
- /**
1030
- * Format duration in human-readable format
1031
- */
1032
- function formatDuration(seconds) {
1033
- if (seconds < 60) {
1034
- return `${seconds.toFixed(1)}s`;
1035
- }
1036
- const mins = Math.floor(seconds / 60);
1037
- const secs = seconds % 60;
1038
- return `${mins}m ${secs.toFixed(0)}s`;
1039
- }
1040
- /**
1041
- * Get the prompt for a phase with the issue number substituted
1042
- */
1043
- function getPhasePrompt(phase, issueNumber) {
1044
- return PHASE_PROMPTS[phase].replace(/\{issue\}/g, String(issueNumber));
1045
- }
1046
- /**
1047
- * Phases that require worktree isolation (exec, test, qa)
1048
- * Spec runs in main repo since it's planning-only
1049
- */
1050
- const ISOLATED_PHASES = ["exec", "test", "qa"];
1051
- /**
1052
- * Execute a single phase for an issue using Claude Agent SDK
1053
- */
1054
- async function executePhase(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner) {
1055
- const startTime = Date.now();
1056
- if (config.dryRun) {
1057
- // Dry run - just simulate
1058
- if (config.verbose) {
1059
- console.log(chalk.gray(` Would execute: /${phase} ${issueNumber}`));
1060
- }
1061
- return {
1062
- phase,
1063
- success: true,
1064
- durationSeconds: 0,
1065
- };
1066
- }
1067
- const prompt = getPhasePrompt(phase, issueNumber);
1068
- if (config.verbose) {
1069
- console.log(chalk.gray(` Prompt: ${prompt}`));
1070
- if (worktreePath && ISOLATED_PHASES.includes(phase)) {
1071
- console.log(chalk.gray(` Worktree: ${worktreePath}`));
1072
- }
1073
- }
1074
- // Determine working directory and environment
1075
- const shouldUseWorktree = worktreePath && ISOLATED_PHASES.includes(phase);
1076
- const cwd = shouldUseWorktree ? worktreePath : process.cwd();
1077
- // Track stderr for error diagnostics (declared outside try for catch access)
1078
- let capturedStderr = "";
1079
- try {
1080
- // Check if shutdown is in progress
1081
- if (shutdownManager?.shuttingDown) {
1082
- return {
1083
- phase,
1084
- success: false,
1085
- durationSeconds: 0,
1086
- error: "Shutdown in progress",
1087
- };
1088
- }
1089
- // Create abort controller for timeout
1090
- const abortController = new AbortController();
1091
- const timeoutId = setTimeout(() => {
1092
- abortController.abort();
1093
- }, config.phaseTimeout * 1000);
1094
- // Register abort controller with shutdown manager for graceful shutdown
1095
- if (shutdownManager) {
1096
- shutdownManager.setAbortController(abortController);
1097
- }
1098
- let resultSessionId;
1099
- let resultMessage;
1100
- let lastError;
1101
- let capturedOutput = "";
1102
- // Build environment with worktree isolation variables
1103
- const env = {
1104
- ...process.env,
1105
- CLAUDE_HOOKS_SMART_TESTS: config.noSmartTests ? "false" : "true",
1106
- };
1107
- // Set worktree isolation environment variables
1108
- if (shouldUseWorktree) {
1109
- env.SEQUANT_WORKTREE = worktreePath;
1110
- env.SEQUANT_ISSUE = String(issueNumber);
1111
- }
1112
- // Set orchestration context for skills to detect they're part of a workflow
1113
- // Skills can check these to skip redundant pre-flight checks
1114
- env.SEQUANT_ORCHESTRATOR = "sequant-run";
1115
- env.SEQUANT_PHASE = phase;
1116
- // Execute using Claude Agent SDK
1117
- // Note: Don't resume sessions when switching to worktree (different cwd breaks resume)
1118
- const canResume = sessionId && !shouldUseWorktree;
1119
- // Get MCP servers config if enabled
1120
- // Reads from Claude Desktop config and passes to SDK for headless MCP support
1121
- const mcpServers = config.mcp ? getMcpServersConfig() : undefined;
1122
- // Track whether we're actively streaming verbose output
1123
- // Pausing spinner once per streaming session prevents truncation from rapid pause/resume cycles
1124
- // (Issue #283: ora's stop() clears the current line, which can truncate output when
1125
- // pause/resume is called for every chunk in rapid succession)
1126
- let verboseStreamingActive = false;
1127
- const queryInstance = query({
1128
- prompt,
1129
- options: {
1130
- abortController,
1131
- cwd,
1132
- // Load project settings including skills
1133
- settingSources: ["project"],
1134
- // Use Claude Code's system prompt and tools
1135
- systemPrompt: { type: "preset", preset: "claude_code" },
1136
- tools: { type: "preset", preset: "claude_code" },
1137
- // Bypass permissions for headless execution
1138
- permissionMode: "bypassPermissions",
1139
- allowDangerouslySkipPermissions: true,
1140
- // Resume from previous session if provided (but not when switching directories)
1141
- ...(canResume ? { resume: sessionId } : {}),
1142
- // Configure smart tests and worktree isolation via environment
1143
- env,
1144
- // Pass MCP servers for headless mode (AC-2)
1145
- ...(mcpServers ? { mcpServers } : {}),
1146
- // Capture stderr for debugging (helps diagnose early exit failures)
1147
- stderr: (data) => {
1148
- capturedStderr += data;
1149
- // Write stderr in verbose mode
1150
- if (config.verbose) {
1151
- // Pause spinner once to avoid truncation (Issue #283)
1152
- if (!verboseStreamingActive) {
1153
- spinner?.pause();
1154
- verboseStreamingActive = true;
1155
- }
1156
- process.stderr.write(chalk.red(data));
1157
- }
1158
- },
1159
- },
1160
- });
1161
- // Stream and process messages
1162
- for await (const message of queryInstance) {
1163
- // Capture session ID from system init message
1164
- if (message.type === "system" && message.subtype === "init") {
1165
- resultSessionId = message.session_id;
1166
- }
1167
- // Capture output from assistant messages
1168
- if (message.type === "assistant") {
1169
- // Extract text content from the message
1170
- const content = message.message.content;
1171
- const textContent = content
1172
- .filter((c) => c.type === "text" && c.text)
1173
- .map((c) => c.text)
1174
- .join("");
1175
- if (textContent) {
1176
- capturedOutput += textContent;
1177
- // Show streaming output in verbose mode
1178
- if (config.verbose) {
1179
- // Pause spinner once at start of streaming to avoid truncation
1180
- // (Issue #283: repeated pause/resume causes ora to clear lines between chunks)
1181
- if (!verboseStreamingActive) {
1182
- spinner?.pause();
1183
- verboseStreamingActive = true;
1184
- }
1185
- process.stdout.write(chalk.gray(textContent));
1186
- }
1187
- }
1188
- }
1189
- // Capture the final result
1190
- if (message.type === "result") {
1191
- resultMessage = message;
1192
- }
1193
- }
1194
- // Resume spinner after streaming completes (if we paused it)
1195
- if (verboseStreamingActive) {
1196
- spinner?.resume();
1197
- verboseStreamingActive = false;
1198
- }
1199
- clearTimeout(timeoutId);
1200
- // Clear abort controller from shutdown manager
1201
- if (shutdownManager) {
1202
- shutdownManager.clearAbortController();
1203
- }
1204
- const durationSeconds = (Date.now() - startTime) / 1000;
1205
- // Check result status
1206
- if (resultMessage) {
1207
- if (resultMessage.subtype === "success") {
1208
- // For QA phase, check the verdict to determine actual success
1209
- // SDK "success" just means the query completed - we need to parse the verdict
1210
- if (phase === "qa" && capturedOutput) {
1211
- const verdict = parseQaVerdict(capturedOutput);
1212
- // Only READY_FOR_MERGE and NEEDS_VERIFICATION are considered passing
1213
- // NEEDS_VERIFICATION is external verification, not a code quality issue
1214
- if (verdict &&
1215
- verdict !== "READY_FOR_MERGE" &&
1216
- verdict !== "NEEDS_VERIFICATION") {
1217
- return {
1218
- phase,
1219
- success: false,
1220
- durationSeconds,
1221
- error: `QA verdict: ${verdict}`,
1222
- sessionId: resultSessionId,
1223
- output: capturedOutput,
1224
- verdict, // Include parsed verdict
1225
- };
1226
- }
1227
- // Pass case - include verdict for logging
1228
- return {
1229
- phase,
1230
- success: true,
1231
- durationSeconds,
1232
- sessionId: resultSessionId,
1233
- output: capturedOutput,
1234
- verdict: verdict ?? undefined, // Include if found
1235
- };
1236
- }
1237
- return {
1238
- phase,
1239
- success: true,
1240
- durationSeconds,
1241
- sessionId: resultSessionId,
1242
- output: capturedOutput,
1243
- };
1244
- }
1245
- else {
1246
- // Handle error subtypes
1247
- const errorSubtype = resultMessage.subtype;
1248
- if (errorSubtype === "error_max_turns") {
1249
- lastError = "Max turns reached";
1250
- }
1251
- else if (errorSubtype === "error_during_execution") {
1252
- lastError =
1253
- resultMessage.errors?.join(", ") || "Error during execution";
1254
- }
1255
- else if (errorSubtype === "error_max_budget_usd") {
1256
- lastError = "Budget limit exceeded";
1257
- }
1258
- else {
1259
- lastError = `Error: ${errorSubtype}`;
1260
- }
1261
- return {
1262
- phase,
1263
- success: false,
1264
- durationSeconds,
1265
- error: lastError,
1266
- sessionId: resultSessionId,
1267
- };
1268
- }
1269
- }
1270
- // No result message received
1271
- return {
1272
- phase,
1273
- success: false,
1274
- durationSeconds: (Date.now() - startTime) / 1000,
1275
- error: "No result received from Claude",
1276
- sessionId: resultSessionId,
1277
- };
1278
- }
1279
- catch (err) {
1280
- const durationSeconds = (Date.now() - startTime) / 1000;
1281
- const error = err instanceof Error ? err.message : String(err);
1282
- // Check if it was an abort (timeout)
1283
- if (error.includes("abort") || error.includes("AbortError")) {
1284
- return {
1285
- phase,
1286
- success: false,
1287
- durationSeconds,
1288
- error: `Timeout after ${config.phaseTimeout}s`,
1289
- };
1290
- }
1291
- // Include stderr in error message if available (helps diagnose early exit failures)
1292
- const stderrSuffix = capturedStderr
1293
- ? `\nStderr: ${capturedStderr.slice(0, 500)}`
1294
- : "";
1295
- return {
1296
- phase,
1297
- success: false,
1298
- durationSeconds,
1299
- error: error + stderrSuffix,
1300
- };
1301
- }
1302
- }
1303
- /**
1304
- * Cold-start retry threshold in seconds.
1305
- * Failures under this duration are likely Claude Code subprocess initialization
1306
- * issues rather than genuine phase failures (based on empirical data: cold-start
1307
- * failures consistently complete in 15-39s vs 150-310s for real work).
1308
- */
1309
- const COLD_START_THRESHOLD_SECONDS = 60;
1310
- const COLD_START_MAX_RETRIES = 2;
1311
- /**
1312
- * Execute a phase with automatic retry for cold-start failures and MCP fallback.
1313
- *
1314
- * Retry strategy:
1315
- * 1. If phase fails within COLD_START_THRESHOLD_SECONDS, retry up to COLD_START_MAX_RETRIES times
1316
- * 2. If still failing and MCP is enabled, retry once with MCP disabled (npx-based MCP servers
1317
- * can fail on first run due to cold-cache issues)
1318
- *
1319
- * The MCP fallback is safe because MCP servers are optional enhancements, not required
1320
- * for core functionality.
1321
- */
1322
- /**
1323
- * @internal Exported for testing only
1324
- */
1325
- export async function executePhaseWithRetry(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner,
1326
- /** @internal Injected for testing — defaults to module-level executePhase */
1327
- executePhaseFn = executePhase) {
1328
- // Skip retry logic if explicitly disabled
1329
- if (config.retry === false) {
1330
- return executePhaseFn(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner);
1331
- }
1332
- let lastResult;
1333
- // Phase 1: Cold-start retry attempts (with MCP enabled if configured)
1334
- for (let attempt = 0; attempt <= COLD_START_MAX_RETRIES; attempt++) {
1335
- lastResult = await executePhaseFn(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner);
1336
- const duration = lastResult.durationSeconds ?? 0;
1337
- // Success or genuine failure (took long enough to be real work)
1338
- if (lastResult.success || duration >= COLD_START_THRESHOLD_SECONDS) {
1339
- return lastResult;
1340
- }
1341
- // Cold-start failure detected — retry
1342
- if (attempt < COLD_START_MAX_RETRIES) {
1343
- if (config.verbose) {
1344
- console.log(chalk.yellow(`\n ⟳ Cold-start failure detected (${duration.toFixed(1)}s), retrying... (attempt ${attempt + 2}/${COLD_START_MAX_RETRIES + 1})`));
1345
- }
1346
- }
1347
- }
1348
- // Capture the original error for better diagnostics
1349
- const originalError = lastResult.error;
1350
- // Phase 2: MCP fallback - if MCP is enabled and we're still failing, try without MCP
1351
- // This handles npx-based MCP servers that fail on first run due to cold-cache issues
1352
- if (config.mcp && !lastResult.success) {
1353
- console.log(chalk.yellow(`\n ⚠️ Phase failed with MCP enabled, retrying without MCP...`));
1354
- // Create config copy with MCP disabled
1355
- const configWithoutMcp = {
1356
- ...config,
1357
- mcp: false,
1358
- };
1359
- const retryResult = await executePhaseFn(issueNumber, phase, configWithoutMcp, sessionId, worktreePath, shutdownManager, spinner);
1360
- if (retryResult.success) {
1361
- console.log(chalk.green(` ✓ Phase succeeded without MCP (MCP cold-start issue detected)`));
1362
- return retryResult;
1363
- }
1364
- // Both attempts failed - return original error for better diagnostics
1365
- return {
1366
- ...lastResult,
1367
- error: originalError,
1368
- };
1369
- }
1370
- return lastResult;
1371
- }
1372
- /**
1373
- * Fetch issue info from GitHub
1374
- */
1375
- async function getIssueInfo(issueNumber) {
1376
- try {
1377
- const result = spawnSync("gh", ["issue", "view", String(issueNumber), "--json", "title,labels"], { stdio: "pipe" });
1378
- if (result.status === 0) {
1379
- const data = JSON.parse(result.stdout.toString());
1380
- return {
1381
- title: data.title || `Issue #${issueNumber}`,
1382
- labels: Array.isArray(data.labels)
1383
- ? data.labels.map((l) => l.name)
1384
- : [],
1385
- };
1386
- }
1387
- }
1388
- catch {
1389
- // Ignore errors, use defaults
1390
- }
1391
- return { title: `Issue #${issueNumber}`, labels: [] };
1392
- }
1393
- /**
1394
- * Parse dependencies from issue body and labels
1395
- * Returns array of issue numbers this issue depends on
1396
- */
1397
- function parseDependencies(issueNumber) {
1398
- try {
1399
- const result = spawnSync("gh", ["issue", "view", String(issueNumber), "--json", "body,labels"], { stdio: "pipe" });
1400
- if (result.status !== 0)
1401
- return [];
1402
- const data = JSON.parse(result.stdout.toString());
1403
- const dependencies = [];
1404
- // Parse from body: "Depends on: #123" or "**Depends on**: #123"
1405
- if (data.body) {
1406
- const bodyMatch = data.body.match(/\*?\*?depends\s+on\*?\*?:?\s*#?(\d+)/gi);
1407
- if (bodyMatch) {
1408
- for (const match of bodyMatch) {
1409
- const numMatch = match.match(/(\d+)/);
1410
- if (numMatch) {
1411
- dependencies.push(parseInt(numMatch[1], 10));
1412
- }
1413
- }
1414
- }
1415
- }
1416
- // Parse from labels: "depends-on/123" or "depends-on-123"
1417
- if (data.labels && Array.isArray(data.labels)) {
1418
- for (const label of data.labels) {
1419
- const labelName = label.name || label;
1420
- const labelMatch = labelName.match(/depends-on[-/](\d+)/i);
1421
- if (labelMatch) {
1422
- dependencies.push(parseInt(labelMatch[1], 10));
1423
- }
1424
- }
1425
- }
1426
- return [...new Set(dependencies)]; // Remove duplicates
1427
- }
1428
- catch {
1429
- return [];
1430
- }
1431
- }
1432
- /**
1433
- * Sort issues by dependencies (topological sort)
1434
- * Issues with no dependencies come first, then issues that depend on them
1435
- */
1436
- function sortByDependencies(issueNumbers) {
1437
- // Build dependency graph
1438
- const dependsOn = new Map();
1439
- for (const issue of issueNumbers) {
1440
- const deps = parseDependencies(issue);
1441
- // Only include dependencies that are in our issue list
1442
- dependsOn.set(issue, deps.filter((d) => issueNumbers.includes(d)));
1443
- }
1444
- // Topological sort using Kahn's algorithm
1445
- const inDegree = new Map();
1446
- for (const issue of issueNumbers) {
1447
- inDegree.set(issue, 0);
1448
- }
1449
- for (const deps of dependsOn.values()) {
1450
- for (const dep of deps) {
1451
- inDegree.set(dep, (inDegree.get(dep) || 0) + 1);
1452
- }
1453
- }
1454
- // Note: inDegree counts how many issues depend on each issue
1455
- // We want to process issues that nothing depends on last
1456
- // So we sort by: issues nothing depends on first, then dependent issues
1457
- const sorted = [];
1458
- const queue = [];
1459
- // Start with issues that have no dependencies
1460
- for (const issue of issueNumbers) {
1461
- const deps = dependsOn.get(issue) || [];
1462
- if (deps.length === 0) {
1463
- queue.push(issue);
1464
- }
1465
- }
1466
- const visited = new Set();
1467
- while (queue.length > 0) {
1468
- const issue = queue.shift();
1469
- if (visited.has(issue))
1470
- continue;
1471
- visited.add(issue);
1472
- sorted.push(issue);
1473
- // Find issues that depend on this one
1474
- for (const [other, deps] of dependsOn.entries()) {
1475
- if (deps.includes(issue) && !visited.has(other)) {
1476
- // Check if all dependencies of 'other' are satisfied
1477
- const allDepsSatisfied = deps.every((d) => visited.has(d));
1478
- if (allDepsSatisfied) {
1479
- queue.push(other);
1480
- }
1481
- }
1482
- }
1483
- }
1484
- // Add any remaining issues (circular dependencies or unvisited)
1485
- for (const issue of issueNumbers) {
1486
- if (!visited.has(issue)) {
1487
- sorted.push(issue);
1488
- }
1489
- }
1490
- return sorted;
1491
- }
1492
- /**
1493
- * Check if an issue has UI-related labels
1494
- */
1495
- function hasUILabels(labels) {
1496
- return labels.some((label) => UI_LABELS.some((uiLabel) => label.toLowerCase().includes(uiLabel)));
1497
- }
1498
- /**
1499
- * Determine phases to run based on options and issue labels
1500
- */
1501
- function determinePhasesForIssue(basePhases, labels, options) {
1502
- const phases = [...basePhases];
1503
- // Add testgen phase after spec if requested
1504
- if (options.testgen && phases.includes("spec")) {
1505
- const specIndex = phases.indexOf("spec");
1506
- if (!phases.includes("testgen")) {
1507
- phases.splice(specIndex + 1, 0, "testgen");
1508
- }
1509
- }
1510
- // Auto-detect UI issues and add test phase
1511
- if (hasUILabels(labels) && !phases.includes("test")) {
1512
- // Add test phase before qa if present, otherwise at the end
1513
- const qaIndex = phases.indexOf("qa");
1514
- if (qaIndex !== -1) {
1515
- phases.splice(qaIndex, 0, "test");
1516
- }
1517
- else {
1518
- phases.push("test");
1519
- }
1520
- }
1521
- return phases;
1522
- }
1523
- /**
1524
- * Parse environment variables for CI configuration
1525
- */
1526
- function getEnvConfig() {
1527
- const config = {};
1528
- if (process.env.SEQUANT_QUALITY_LOOP === "true") {
1529
- config.qualityLoop = true;
1530
- }
1531
- if (process.env.SEQUANT_MAX_ITERATIONS) {
1532
- const maxIter = parseInt(process.env.SEQUANT_MAX_ITERATIONS, 10);
1533
- if (!isNaN(maxIter)) {
1534
- config.maxIterations = maxIter;
1535
- }
1536
- }
1537
- if (process.env.SEQUANT_SMART_TESTS === "false") {
1538
- config.noSmartTests = true;
1539
- }
1540
- if (process.env.SEQUANT_TESTGEN === "true") {
1541
- config.testgen = true;
1542
- }
1543
- return config;
1544
- }
1545
- /**
1546
- * Parse batch arguments into groups of issues
1547
- */
1548
- function parseBatches(batchArgs) {
1549
- return batchArgs.map((batch) => batch
1550
- .split(/\s+/)
1551
- .map((n) => parseInt(n, 10))
1552
- .filter((n) => !isNaN(n)));
1553
- }
25
+ import { analyzeRun, formatReflection } from "../lib/workflow/run-reflect.js";
26
+ // Extracted modules
27
+ import { detectDefaultBranch, ensureWorktrees, ensureWorktreesChain, getWorktreeDiffStats, } from "../lib/workflow/worktree-manager.js";
28
+ import { formatDuration } from "../lib/workflow/phase-executor.js";
29
+ import { getIssueInfo, sortByDependencies, parseBatches, getEnvConfig, executeBatch, runIssueWithLogging, } from "../lib/workflow/batch-executor.js";
30
+ // Re-export public API for backwards compatibility
31
+ export { parseQaVerdict, formatDuration, executePhaseWithRetry, } from "../lib/workflow/phase-executor.js";
32
+ export { detectDefaultBranch, checkWorktreeFreshness, removeStaleWorktree, listWorktrees, getWorktreeChangedFiles, getWorktreeDiffStats, readCacheMetrics, filterResumedPhases, ensureWorktree, createCheckpointCommit, reinstallIfLockfileChanged, rebaseBeforePR, createPR, } from "../lib/workflow/worktree-manager.js";
33
+ export { detectPhasesFromLabels, parseRecommendedWorkflow, determinePhasesForIssue, } from "../lib/workflow/phase-mapper.js";
34
+ export { getIssueInfo, sortByDependencies, parseBatches, getEnvConfig, executeBatch, runIssueWithLogging, } from "../lib/workflow/batch-executor.js";
1554
35
  /**
1555
36
  * Main run command
1556
37
  */
@@ -1609,8 +90,10 @@ export async function runCommand(issues, options) {
1609
90
  // Determine if we should auto-detect phases from labels
1610
91
  const autoDetectPhases = !options.phases && settings.run.autoDetectPhases;
1611
92
  mergedOptions.autoDetectPhases = autoDetectPhases;
1612
- // Resolve base branch: CLI flag → settings.run.defaultBase → 'main'
1613
- const resolvedBaseBranch = options.base ?? settings.run.defaultBase ?? undefined;
93
+ // Resolve base branch: CLI flag → settings.run.defaultBase → auto-detect → 'main'
94
+ const resolvedBaseBranch = options.base ??
95
+ settings.run.defaultBase ??
96
+ detectDefaultBranch(mergedOptions.verbose ?? false);
1614
97
  // Parse issue numbers (or use batch mode)
1615
98
  let issueNumbers;
1616
99
  let batches = null;
@@ -1753,7 +236,7 @@ export async function runCommand(issues, options) {
1753
236
  else {
1754
237
  console.log(chalk.gray(` Phases: ${config.phases.join(" → ")}`));
1755
238
  }
1756
- console.log(chalk.gray(` Mode: ${config.sequential ? "sequential" : "parallel"}`));
239
+ console.log(chalk.gray(` Mode: ${config.sequential ? "stop-on-failure" : "continue-on-failure"}`));
1757
240
  if (config.qualityLoop) {
1758
241
  console.log(chalk.gray(` Quality loop: enabled (max ${config.maxIterations} iterations)`));
1759
242
  }
@@ -1885,7 +368,7 @@ export async function runCommand(issues, options) {
1885
368
  for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) {
1886
369
  const batch = batches[batchIdx];
1887
370
  console.log(chalk.blue(`\n Batch ${batchIdx + 1}/${batches.length}: Issues ${batch.map((n) => `#${n}`).join(", ")}`));
1888
- const batchResults = await executeBatch(batch, config, logWriter, stateManager, mergedOptions, issueInfoMap, worktreeMap, shutdown, manifest.packageManager);
371
+ const batchResults = await executeBatch(batch, config, logWriter, stateManager, mergedOptions, issueInfoMap, worktreeMap, shutdown, manifest.packageManager, resolvedBaseBranch);
1889
372
  results.push(...batchResults);
1890
373
  // Check if batch failed and we should stop
1891
374
  const batchFailed = batchResults.some((r) => !r.success);
@@ -1911,7 +394,7 @@ export async function runCommand(issues, options) {
1911
394
  const result = await runIssueWithLogging(issueNumber, config, logWriter, stateManager, issueInfo.title, issueInfo.labels, mergedOptions, worktreeInfo?.path, worktreeInfo?.branch, shutdown, mergedOptions.chain, // Enable checkpoint commits in chain mode
1912
395
  manifest.packageManager,
1913
396
  // In chain mode, only the last issue should trigger pre-PR rebase
1914
- mergedOptions.chain ? i === issueNumbers.length - 1 : undefined);
397
+ mergedOptions.chain ? i === issueNumbers.length - 1 : undefined, resolvedBaseBranch);
1915
398
  results.push(result);
1916
399
  // Record PR info in log before completing issue
1917
400
  if (logWriter && result.prNumber && result.prUrl) {
@@ -1954,7 +437,7 @@ export async function runCommand(issues, options) {
1954
437
  }
1955
438
  }
1956
439
  else {
1957
- // Parallel execution (for now, just run sequentially but don't stop on failure)
440
+ // Default mode: run issues serially but continue on failure (don't stop)
1958
441
  // TODO: Add proper parallel execution with listr2
1959
442
  for (const issueNumber of issueNumbers) {
1960
443
  // Check if shutdown was triggered
@@ -1971,7 +454,7 @@ export async function runCommand(issues, options) {
1971
454
  logWriter.startIssue(issueNumber, issueInfo.title, issueInfo.labels);
1972
455
  }
1973
456
  const result = await runIssueWithLogging(issueNumber, config, logWriter, stateManager, issueInfo.title, issueInfo.labels, mergedOptions, worktreeInfo?.path, worktreeInfo?.branch, shutdown, false, // Parallel mode doesn't support chain
1974
- manifest.packageManager);
457
+ manifest.packageManager, undefined, resolvedBaseBranch);
1975
458
  results.push(result);
1976
459
  // Record PR info in log before completing issue
1977
460
  if (logWriter && result.prNumber && result.prUrl) {
@@ -2105,6 +588,23 @@ export async function runCommand(issues, options) {
2105
588
  console.log(colors.muted(` 📝 Log: ${logPath}`));
2106
589
  console.log("");
2107
590
  }
591
+ // Reflection analysis (--reflect flag)
592
+ if (mergedOptions.reflect && results.length > 0) {
593
+ const reflection = analyzeRun({
594
+ results,
595
+ issueInfoMap,
596
+ runLog: logWriter?.getRunLog() ?? null,
597
+ config: {
598
+ phases: config.phases,
599
+ qualityLoop: config.qualityLoop,
600
+ },
601
+ });
602
+ const reflectionOutput = formatReflection(reflection);
603
+ if (reflectionOutput) {
604
+ console.log(reflectionOutput);
605
+ console.log("");
606
+ }
607
+ }
2108
608
  // Suggest merge checks for multi-issue batches
2109
609
  if (results.length > 1 && passed > 0 && !config.dryRun) {
2110
610
  console.log(colors.muted(" 💡 Verify batch integration before merging:"));
@@ -2129,418 +629,3 @@ export async function runCommand(issues, options) {
2129
629
  process.exit(exitCode);
2130
630
  }
2131
631
  }
2132
- /**
2133
- * Execute a batch of issues
2134
- */
2135
- async function executeBatch(issueNumbers, config, logWriter, stateManager, options, issueInfoMap, worktreeMap, shutdownManager, packageManager) {
2136
- const results = [];
2137
- for (const issueNumber of issueNumbers) {
2138
- // Check if shutdown was triggered
2139
- if (shutdownManager?.shuttingDown) {
2140
- break;
2141
- }
2142
- const issueInfo = issueInfoMap.get(issueNumber) ?? {
2143
- title: `Issue #${issueNumber}`,
2144
- labels: [],
2145
- };
2146
- const worktreeInfo = worktreeMap.get(issueNumber);
2147
- // Start issue logging
2148
- if (logWriter) {
2149
- logWriter.startIssue(issueNumber, issueInfo.title, issueInfo.labels);
2150
- }
2151
- const result = await runIssueWithLogging(issueNumber, config, logWriter, stateManager, issueInfo.title, issueInfo.labels, options, worktreeInfo?.path, worktreeInfo?.branch, shutdownManager, false, // Batch mode doesn't support chain
2152
- packageManager);
2153
- results.push(result);
2154
- // Record PR info in log before completing issue
2155
- if (logWriter && result.prNumber && result.prUrl) {
2156
- logWriter.setPRInfo(result.prNumber, result.prUrl);
2157
- }
2158
- // Complete issue logging
2159
- if (logWriter) {
2160
- logWriter.completeIssue();
2161
- }
2162
- }
2163
- return results;
2164
- }
2165
- /**
2166
- * Execute all phases for a single issue with logging and quality loop
2167
- */
2168
- async function runIssueWithLogging(issueNumber, config, logWriter, stateManager, issueTitle, labels, options, worktreePath, branch, shutdownManager, chainMode, packageManager, isLastInChain) {
2169
- const startTime = Date.now();
2170
- const phaseResults = [];
2171
- let loopTriggered = false;
2172
- let sessionId;
2173
- console.log(chalk.blue(`\n Issue #${issueNumber}`));
2174
- if (worktreePath) {
2175
- console.log(chalk.gray(` Worktree: ${worktreePath}`));
2176
- }
2177
- // Initialize state tracking for this issue
2178
- if (stateManager) {
2179
- try {
2180
- const existingState = await stateManager.getIssueState(issueNumber);
2181
- if (!existingState) {
2182
- await stateManager.initializeIssue(issueNumber, issueTitle, {
2183
- worktree: worktreePath,
2184
- branch,
2185
- qualityLoop: config.qualityLoop,
2186
- maxIterations: config.maxIterations,
2187
- });
2188
- }
2189
- else {
2190
- // Update worktree info if it changed
2191
- if (worktreePath && branch) {
2192
- await stateManager.updateWorktreeInfo(issueNumber, worktreePath, branch);
2193
- }
2194
- }
2195
- }
2196
- catch (error) {
2197
- // State tracking errors shouldn't stop execution
2198
- if (config.verbose) {
2199
- console.log(chalk.yellow(` ⚠️ State tracking error: ${error}`));
2200
- }
2201
- }
2202
- }
2203
- // Determine phases for this specific issue
2204
- let phases;
2205
- let detectedQualityLoop = false;
2206
- let specAlreadyRan = false;
2207
- if (options.autoDetectPhases) {
2208
- // Check if labels indicate a simple bug/fix (skip spec entirely)
2209
- const lowerLabels = labels.map((l) => l.toLowerCase());
2210
- const isSimpleBugFix = lowerLabels.some((label) => BUG_LABELS.some((bugLabel) => label.includes(bugLabel)));
2211
- if (isSimpleBugFix) {
2212
- // Simple bug fix: skip spec, go straight to exec → qa
2213
- phases = ["exec", "qa"];
2214
- console.log(chalk.gray(` Bug fix detected: ${phases.join(" → ")}`));
2215
- }
2216
- else {
2217
- // Run spec first to get recommended workflow
2218
- console.log(chalk.gray(` Running spec to determine workflow...`));
2219
- // Create spinner for spec phase (1 of estimated 3: spec, exec, qa)
2220
- const specSpinner = new PhaseSpinner({
2221
- phase: "spec",
2222
- phaseIndex: 1,
2223
- totalPhases: 3, // Estimate; will be refined after spec
2224
- shutdownManager,
2225
- });
2226
- specSpinner.start();
2227
- // Track spec phase start in state
2228
- if (stateManager) {
2229
- try {
2230
- await stateManager.updatePhaseStatus(issueNumber, "spec", "in_progress");
2231
- }
2232
- catch {
2233
- // State tracking errors shouldn't stop execution
2234
- }
2235
- }
2236
- const specStartTime = new Date();
2237
- // Note: spec runs in main repo (not worktree) for planning
2238
- const specResult = await executePhaseWithRetry(issueNumber, "spec", config, sessionId, worktreePath, // Will be ignored for spec (non-isolated phase)
2239
- shutdownManager, specSpinner);
2240
- const specEndTime = new Date();
2241
- if (specResult.sessionId) {
2242
- sessionId = specResult.sessionId;
2243
- // Update session ID in state for resume capability
2244
- if (stateManager) {
2245
- try {
2246
- await stateManager.updateSessionId(issueNumber, specResult.sessionId);
2247
- }
2248
- catch {
2249
- // State tracking errors shouldn't stop execution
2250
- }
2251
- }
2252
- }
2253
- phaseResults.push(specResult);
2254
- specAlreadyRan = true;
2255
- // Log spec phase result
2256
- // Note: Spec runs in main repo, not worktree, so no git diff stats
2257
- if (logWriter) {
2258
- const phaseLog = createPhaseLogFromTiming("spec", issueNumber, specStartTime, specEndTime, specResult.success
2259
- ? "success"
2260
- : specResult.error?.includes("Timeout")
2261
- ? "timeout"
2262
- : "failure", { error: specResult.error });
2263
- logWriter.logPhase(phaseLog);
2264
- }
2265
- // Track spec phase completion in state
2266
- if (stateManager) {
2267
- try {
2268
- const phaseStatus = specResult.success ? "completed" : "failed";
2269
- await stateManager.updatePhaseStatus(issueNumber, "spec", phaseStatus, {
2270
- error: specResult.error,
2271
- });
2272
- }
2273
- catch {
2274
- // State tracking errors shouldn't stop execution
2275
- }
2276
- }
2277
- if (!specResult.success) {
2278
- specSpinner.fail(specResult.error);
2279
- const durationSeconds = (Date.now() - startTime) / 1000;
2280
- return {
2281
- issueNumber,
2282
- success: false,
2283
- phaseResults,
2284
- durationSeconds,
2285
- loopTriggered: false,
2286
- };
2287
- }
2288
- specSpinner.succeed();
2289
- // Parse recommended workflow from spec output
2290
- const parsedWorkflow = specResult.output
2291
- ? parseRecommendedWorkflow(specResult.output)
2292
- : null;
2293
- if (parsedWorkflow) {
2294
- // Remove spec from phases since we already ran it
2295
- phases = parsedWorkflow.phases.filter((p) => p !== "spec");
2296
- detectedQualityLoop = parsedWorkflow.qualityLoop;
2297
- console.log(chalk.gray(` Spec recommends: ${phases.join(" → ")}${detectedQualityLoop ? " (quality loop)" : ""}`));
2298
- }
2299
- else {
2300
- // Fall back to label-based detection
2301
- console.log(chalk.yellow(` Could not parse spec recommendation, using label-based detection`));
2302
- const detected = detectPhasesFromLabels(labels);
2303
- phases = detected.phases.filter((p) => p !== "spec");
2304
- detectedQualityLoop = detected.qualityLoop;
2305
- console.log(chalk.gray(` Fallback: ${phases.join(" → ")}`));
2306
- }
2307
- }
2308
- }
2309
- else {
2310
- // Use explicit phases with adjustments
2311
- phases = determinePhasesForIssue(config.phases, labels, options);
2312
- if (phases.length !== config.phases.length) {
2313
- console.log(chalk.gray(` Phases adjusted: ${phases.join(" → ")}`));
2314
- }
2315
- }
2316
- // Resume: filter out completed phases if --resume flag is set
2317
- if (options.resume) {
2318
- const resumeResult = filterResumedPhases(issueNumber, phases, true);
2319
- if (resumeResult.skipped.length > 0) {
2320
- console.log(chalk.gray(` Resume: skipping completed phases: ${resumeResult.skipped.join(", ")}`));
2321
- phases = resumeResult.phases;
2322
- }
2323
- // Also skip spec if it was auto-detected as completed
2324
- if (specAlreadyRan &&
2325
- resumeResult.skipped.length === 0 &&
2326
- resumeResult.phases.length === 0) {
2327
- console.log(chalk.gray(` Resume: all phases already completed`));
2328
- }
2329
- }
2330
- // Add testgen phase if requested (and spec was in the phases)
2331
- if (options.testgen &&
2332
- (phases.includes("spec") || specAlreadyRan) &&
2333
- !phases.includes("testgen")) {
2334
- // Insert testgen at the beginning if spec already ran, otherwise after spec
2335
- if (specAlreadyRan) {
2336
- phases.unshift("testgen");
2337
- }
2338
- else {
2339
- const specIndex = phases.indexOf("spec");
2340
- if (specIndex !== -1) {
2341
- phases.splice(specIndex + 1, 0, "testgen");
2342
- }
2343
- }
2344
- }
2345
- let iteration = 0;
2346
- const useQualityLoop = config.qualityLoop || detectedQualityLoop;
2347
- const maxIterations = useQualityLoop ? config.maxIterations : 1;
2348
- let completedSuccessfully = false;
2349
- while (iteration < maxIterations) {
2350
- iteration++;
2351
- if (useQualityLoop && iteration > 1) {
2352
- console.log(chalk.yellow(` Quality loop iteration ${iteration}/${maxIterations}`));
2353
- loopTriggered = true;
2354
- }
2355
- let phasesFailed = false;
2356
- // Calculate total phases for progress indicator
2357
- // If spec already ran in auto-detect mode, it's counted separately
2358
- const totalPhases = specAlreadyRan ? phases.length + 1 : phases.length;
2359
- const phaseIndexOffset = specAlreadyRan ? 1 : 0;
2360
- for (let phaseIdx = 0; phaseIdx < phases.length; phaseIdx++) {
2361
- const phase = phases[phaseIdx];
2362
- const phaseNumber = phaseIdx + 1 + phaseIndexOffset;
2363
- // Create spinner for this phase
2364
- const phaseSpinner = new PhaseSpinner({
2365
- phase,
2366
- phaseIndex: phaseNumber,
2367
- totalPhases,
2368
- shutdownManager,
2369
- iteration: useQualityLoop ? iteration : undefined,
2370
- });
2371
- phaseSpinner.start();
2372
- // Track phase start in state
2373
- if (stateManager) {
2374
- try {
2375
- await stateManager.updatePhaseStatus(issueNumber, phase, "in_progress");
2376
- }
2377
- catch {
2378
- // State tracking errors shouldn't stop execution
2379
- }
2380
- }
2381
- const phaseStartTime = new Date();
2382
- const result = await executePhaseWithRetry(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, phaseSpinner);
2383
- const phaseEndTime = new Date();
2384
- // Capture session ID for subsequent phases
2385
- if (result.sessionId) {
2386
- sessionId = result.sessionId;
2387
- // Update session ID in state for resume capability
2388
- if (stateManager) {
2389
- try {
2390
- await stateManager.updateSessionId(issueNumber, result.sessionId);
2391
- }
2392
- catch {
2393
- // State tracking errors shouldn't stop execution
2394
- }
2395
- }
2396
- }
2397
- phaseResults.push(result);
2398
- // Log phase result with observability data (AC-1, AC-2, AC-3, AC-7)
2399
- if (logWriter) {
2400
- // Capture git diff stats for worktree phases (AC-1, AC-3)
2401
- const diffStats = worktreePath
2402
- ? getGitDiffStats(worktreePath)
2403
- : undefined;
2404
- // Capture commit hash after phase (AC-2)
2405
- const commitHash = worktreePath
2406
- ? getCommitHash(worktreePath)
2407
- : undefined;
2408
- // Read cache metrics for QA phase (AC-7)
2409
- const cacheMetrics = phase === "qa" ? readCacheMetrics(worktreePath) : undefined;
2410
- const phaseLog = createPhaseLogFromTiming(phase, issueNumber, phaseStartTime, phaseEndTime, result.success
2411
- ? "success"
2412
- : result.error?.includes("Timeout")
2413
- ? "timeout"
2414
- : "failure", {
2415
- error: result.error,
2416
- verdict: result.verdict,
2417
- // Observability fields (AC-1, AC-2, AC-3, AC-7)
2418
- filesModified: diffStats?.filesModified,
2419
- fileDiffStats: diffStats?.fileDiffStats,
2420
- commitHash,
2421
- cacheMetrics,
2422
- });
2423
- logWriter.logPhase(phaseLog);
2424
- }
2425
- // Track phase completion in state
2426
- if (stateManager) {
2427
- try {
2428
- const phaseStatus = result.success
2429
- ? "completed"
2430
- : result.error?.includes("Timeout")
2431
- ? "failed"
2432
- : "failed";
2433
- await stateManager.updatePhaseStatus(issueNumber, phase, phaseStatus, { error: result.error });
2434
- }
2435
- catch {
2436
- // State tracking errors shouldn't stop execution
2437
- }
2438
- }
2439
- if (result.success) {
2440
- phaseSpinner.succeed();
2441
- }
2442
- else {
2443
- phaseSpinner.fail(result.error);
2444
- phasesFailed = true;
2445
- // If quality loop enabled, run loop phase to fix issues
2446
- if (useQualityLoop && iteration < maxIterations) {
2447
- // Create spinner for loop phase
2448
- const loopSpinner = new PhaseSpinner({
2449
- phase: "loop",
2450
- phaseIndex: phaseNumber,
2451
- totalPhases,
2452
- shutdownManager,
2453
- iteration,
2454
- });
2455
- loopSpinner.start();
2456
- const loopResult = await executePhaseWithRetry(issueNumber, "loop", config, sessionId, worktreePath, shutdownManager, loopSpinner);
2457
- phaseResults.push(loopResult);
2458
- if (loopResult.sessionId) {
2459
- sessionId = loopResult.sessionId;
2460
- }
2461
- if (loopResult.success) {
2462
- loopSpinner.succeed();
2463
- // Continue to next iteration
2464
- break;
2465
- }
2466
- else {
2467
- loopSpinner.fail(loopResult.error);
2468
- }
2469
- }
2470
- // Stop on first failure (if not in quality loop or loop failed)
2471
- break;
2472
- }
2473
- }
2474
- // If all phases passed, exit the loop
2475
- if (!phasesFailed) {
2476
- completedSuccessfully = true;
2477
- break;
2478
- }
2479
- // If we're not in quality loop mode, don't retry
2480
- if (!config.qualityLoop) {
2481
- break;
2482
- }
2483
- }
2484
- const durationSeconds = (Date.now() - startTime) / 1000;
2485
- // Success is determined by whether all phases completed in any iteration,
2486
- // not whether all accumulated phase results passed (which would fail after loop recovery)
2487
- const success = completedSuccessfully;
2488
- // Update final issue status in state
2489
- if (stateManager) {
2490
- try {
2491
- const finalStatus = success ? "ready_for_merge" : "in_progress";
2492
- await stateManager.updateIssueStatus(issueNumber, finalStatus);
2493
- }
2494
- catch {
2495
- // State tracking errors shouldn't stop execution
2496
- }
2497
- }
2498
- // Create checkpoint commit in chain mode after QA passes
2499
- if (success && chainMode && worktreePath) {
2500
- createCheckpointCommit(worktreePath, issueNumber, config.verbose);
2501
- }
2502
- // Rebase onto origin/main before PR creation (unless --no-rebase)
2503
- // This ensures the branch is up-to-date and prevents lockfile drift
2504
- // AC-1: Non-chain mode rebases onto origin/main before PR
2505
- // AC-2: Chain mode rebases only the final branch onto origin/main before PR
2506
- // (intermediate branches must stay based on their predecessor)
2507
- const shouldRebase = success &&
2508
- worktreePath &&
2509
- !options.noRebase &&
2510
- (!chainMode || isLastInChain);
2511
- if (shouldRebase) {
2512
- rebaseBeforePR(worktreePath, issueNumber, packageManager, config.verbose);
2513
- }
2514
- // Create PR after successful QA + rebase (unless --no-pr)
2515
- let prNumber;
2516
- let prUrl;
2517
- const shouldCreatePR = success && worktreePath && branch && !options.noPr;
2518
- if (shouldCreatePR) {
2519
- const prResult = createPR(worktreePath, issueNumber, issueTitle, branch, config.verbose, labels);
2520
- if (prResult.success && prResult.prNumber && prResult.prUrl) {
2521
- prNumber = prResult.prNumber;
2522
- prUrl = prResult.prUrl;
2523
- // Update workflow state with PR info
2524
- if (stateManager) {
2525
- try {
2526
- await stateManager.updatePRInfo(issueNumber, {
2527
- number: prResult.prNumber,
2528
- url: prResult.prUrl,
2529
- });
2530
- }
2531
- catch {
2532
- // State tracking errors shouldn't stop execution
2533
- }
2534
- }
2535
- }
2536
- }
2537
- return {
2538
- issueNumber,
2539
- success,
2540
- phaseResults,
2541
- durationSeconds,
2542
- loopTriggered,
2543
- prNumber,
2544
- prUrl,
2545
- };
2546
- }