sequant 1.1.2 → 1.2.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 (72) hide show
  1. package/README.md +112 -10
  2. package/dist/bin/cli.js +3 -1
  3. package/dist/bin/cli.js.map +1 -1
  4. package/dist/src/commands/doctor.d.ts.map +1 -1
  5. package/dist/src/commands/doctor.js +33 -2
  6. package/dist/src/commands/doctor.js.map +1 -1
  7. package/dist/src/commands/doctor.test.js +63 -1
  8. package/dist/src/commands/doctor.test.js.map +1 -1
  9. package/dist/src/commands/init.d.ts +1 -0
  10. package/dist/src/commands/init.d.ts.map +1 -1
  11. package/dist/src/commands/init.js +75 -9
  12. package/dist/src/commands/init.js.map +1 -1
  13. package/dist/src/commands/init.test.js +137 -2
  14. package/dist/src/commands/init.test.js.map +1 -1
  15. package/dist/src/commands/logs.js +1 -1
  16. package/dist/src/commands/logs.js.map +1 -1
  17. package/dist/src/commands/run.d.ts +18 -0
  18. package/dist/src/commands/run.d.ts.map +1 -1
  19. package/dist/src/commands/run.js +613 -54
  20. package/dist/src/commands/run.js.map +1 -1
  21. package/dist/src/commands/run.test.d.ts +2 -0
  22. package/dist/src/commands/run.test.d.ts.map +1 -0
  23. package/dist/src/commands/run.test.js +155 -0
  24. package/dist/src/commands/run.test.js.map +1 -0
  25. package/dist/src/commands/update.d.ts.map +1 -1
  26. package/dist/src/commands/update.js +58 -6
  27. package/dist/src/commands/update.js.map +1 -1
  28. package/dist/src/index.d.ts +3 -1
  29. package/dist/src/index.d.ts.map +1 -1
  30. package/dist/src/index.js +2 -1
  31. package/dist/src/index.js.map +1 -1
  32. package/dist/src/lib/config.d.ts +19 -0
  33. package/dist/src/lib/config.d.ts.map +1 -0
  34. package/dist/src/lib/config.js +31 -0
  35. package/dist/src/lib/config.js.map +1 -0
  36. package/dist/src/lib/manifest.d.ts +3 -1
  37. package/dist/src/lib/manifest.d.ts.map +1 -1
  38. package/dist/src/lib/manifest.js +2 -1
  39. package/dist/src/lib/manifest.js.map +1 -1
  40. package/dist/src/lib/settings.d.ts +69 -0
  41. package/dist/src/lib/settings.d.ts.map +1 -0
  42. package/dist/src/lib/settings.js +79 -0
  43. package/dist/src/lib/settings.js.map +1 -0
  44. package/dist/src/lib/stacks.d.ts +28 -0
  45. package/dist/src/lib/stacks.d.ts.map +1 -1
  46. package/dist/src/lib/stacks.js +160 -17
  47. package/dist/src/lib/stacks.js.map +1 -1
  48. package/dist/src/lib/stacks.test.js +343 -1
  49. package/dist/src/lib/stacks.test.js.map +1 -1
  50. package/dist/src/lib/system.d.ts +8 -0
  51. package/dist/src/lib/system.d.ts.map +1 -1
  52. package/dist/src/lib/system.js +23 -0
  53. package/dist/src/lib/system.js.map +1 -1
  54. package/dist/src/lib/templates.d.ts +5 -1
  55. package/dist/src/lib/templates.d.ts.map +1 -1
  56. package/dist/src/lib/templates.js +3 -2
  57. package/dist/src/lib/templates.js.map +1 -1
  58. package/dist/src/lib/tty.d.ts +31 -0
  59. package/dist/src/lib/tty.d.ts.map +1 -0
  60. package/dist/src/lib/tty.js +81 -0
  61. package/dist/src/lib/tty.js.map +1 -0
  62. package/dist/src/lib/tty.test.d.ts +2 -0
  63. package/dist/src/lib/tty.test.d.ts.map +1 -0
  64. package/dist/src/lib/tty.test.js +227 -0
  65. package/dist/src/lib/tty.test.js.map +1 -0
  66. package/dist/src/lib/workflow/types.d.ts +2 -0
  67. package/dist/src/lib/workflow/types.d.ts.map +1 -1
  68. package/package.json +1 -1
  69. package/templates/hooks/post-tool.sh +4 -2
  70. package/templates/scripts/new-feature.sh +33 -9
  71. package/templates/skills/solve/SKILL.md +27 -11
  72. package/templates/skills/spec/SKILL.md +30 -2
@@ -6,10 +6,213 @@
6
6
  */
7
7
  import chalk from "chalk";
8
8
  import { spawnSync } from "child_process";
9
+ import { existsSync } from "fs";
10
+ import path from "path";
9
11
  import { query } from "@anthropic-ai/claude-agent-sdk";
10
12
  import { getManifest } from "../lib/manifest.js";
13
+ import { getSettings } from "../lib/settings.js";
14
+ import { PM_CONFIG } from "../lib/stacks.js";
11
15
  import { LogWriter, createPhaseLogFromTiming, } from "../lib/workflow/log-writer.js";
12
16
  import { DEFAULT_PHASES, DEFAULT_CONFIG, } from "../lib/workflow/types.js";
17
+ /**
18
+ * Slugify a title for branch naming
19
+ */
20
+ function slugify(title) {
21
+ return title
22
+ .toLowerCase()
23
+ .replace(/[^a-z0-9]+/g, "-")
24
+ .replace(/^-+|-+$/g, "")
25
+ .substring(0, 50);
26
+ }
27
+ /**
28
+ * Get the git repository root directory
29
+ */
30
+ function getGitRoot() {
31
+ const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
32
+ stdio: "pipe",
33
+ });
34
+ if (result.status === 0) {
35
+ return result.stdout.toString().trim();
36
+ }
37
+ return null;
38
+ }
39
+ /**
40
+ * Check if a worktree exists for a given branch
41
+ */
42
+ function findExistingWorktree(branch) {
43
+ const result = spawnSync("git", ["worktree", "list", "--porcelain"], {
44
+ stdio: "pipe",
45
+ });
46
+ if (result.status !== 0)
47
+ return null;
48
+ const output = result.stdout.toString();
49
+ const lines = output.split("\n");
50
+ let currentPath = "";
51
+ for (const line of lines) {
52
+ if (line.startsWith("worktree ")) {
53
+ currentPath = line.substring(9);
54
+ }
55
+ else if (line.startsWith("branch refs/heads/") && line.includes(branch)) {
56
+ return currentPath;
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+ /**
62
+ * List all active worktrees with their branches
63
+ */
64
+ export function listWorktrees() {
65
+ const result = spawnSync("git", ["worktree", "list", "--porcelain"], {
66
+ stdio: "pipe",
67
+ });
68
+ if (result.status !== 0)
69
+ return [];
70
+ const output = result.stdout.toString();
71
+ const lines = output.split("\n");
72
+ const worktrees = [];
73
+ let currentPath = "";
74
+ let currentBranch = "";
75
+ for (const line of lines) {
76
+ if (line.startsWith("worktree ")) {
77
+ currentPath = line.substring(9);
78
+ }
79
+ else if (line.startsWith("branch refs/heads/")) {
80
+ currentBranch = line.substring(18);
81
+ // Extract issue number from branch name (e.g., feature/123-some-title)
82
+ const issueMatch = currentBranch.match(/feature\/(\d+)-/);
83
+ const issue = issueMatch ? parseInt(issueMatch[1], 10) : null;
84
+ worktrees.push({ path: currentPath, branch: currentBranch, issue });
85
+ currentPath = "";
86
+ currentBranch = "";
87
+ }
88
+ }
89
+ return worktrees;
90
+ }
91
+ /**
92
+ * Get changed files in a worktree compared to main
93
+ */
94
+ export function getWorktreeChangedFiles(worktreePath) {
95
+ const result = spawnSync("git", ["-C", worktreePath, "diff", "--name-only", "main...HEAD"], { stdio: "pipe" });
96
+ if (result.status !== 0)
97
+ return [];
98
+ return result.stdout
99
+ .toString()
100
+ .trim()
101
+ .split("\n")
102
+ .filter((f) => f.length > 0);
103
+ }
104
+ /**
105
+ * Create or reuse a worktree for an issue
106
+ */
107
+ async function ensureWorktree(issueNumber, title, verbose, packageManager) {
108
+ const gitRoot = getGitRoot();
109
+ if (!gitRoot) {
110
+ console.log(chalk.red(" ❌ Not in a git repository"));
111
+ return null;
112
+ }
113
+ const slug = slugify(title);
114
+ const branch = `feature/${issueNumber}-${slug}`;
115
+ const worktreesDir = path.join(path.dirname(gitRoot), "worktrees");
116
+ const worktreePath = path.join(worktreesDir, branch);
117
+ // Check if worktree already exists
118
+ const existingPath = findExistingWorktree(branch);
119
+ if (existingPath) {
120
+ if (verbose) {
121
+ console.log(chalk.gray(` 📂 Reusing existing worktree: ${existingPath}`));
122
+ }
123
+ return {
124
+ issue: issueNumber,
125
+ path: existingPath,
126
+ branch,
127
+ existed: true,
128
+ };
129
+ }
130
+ // Check if branch exists (but no worktree)
131
+ const branchCheck = spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { stdio: "pipe" });
132
+ const branchExists = branchCheck.status === 0;
133
+ if (verbose) {
134
+ console.log(chalk.gray(` 🌿 Creating worktree for #${issueNumber}...`));
135
+ }
136
+ // Ensure worktrees directory exists
137
+ if (!existsSync(worktreesDir)) {
138
+ spawnSync("mkdir", ["-p", worktreesDir], { stdio: "pipe" });
139
+ }
140
+ // Create the worktree
141
+ let createResult;
142
+ if (branchExists) {
143
+ // Use existing branch
144
+ createResult = spawnSync("git", ["worktree", "add", worktreePath, branch], {
145
+ stdio: "pipe",
146
+ });
147
+ }
148
+ else {
149
+ // Create new branch from main
150
+ createResult = spawnSync("git", ["worktree", "add", worktreePath, "-b", branch], { stdio: "pipe" });
151
+ }
152
+ if (createResult.status !== 0) {
153
+ const error = createResult.stderr.toString();
154
+ console.log(chalk.red(` ❌ Failed to create worktree: ${error}`));
155
+ return null;
156
+ }
157
+ // Copy .env.local if it exists
158
+ const envLocalSrc = path.join(gitRoot, ".env.local");
159
+ const envLocalDst = path.join(worktreePath, ".env.local");
160
+ if (existsSync(envLocalSrc) && !existsSync(envLocalDst)) {
161
+ spawnSync("cp", [envLocalSrc, envLocalDst], { stdio: "pipe" });
162
+ }
163
+ // Copy .claude/settings.local.json if it exists
164
+ const claudeSettingsSrc = path.join(gitRoot, ".claude", "settings.local.json");
165
+ const claudeSettingsDst = path.join(worktreePath, ".claude", "settings.local.json");
166
+ if (existsSync(claudeSettingsSrc) && !existsSync(claudeSettingsDst)) {
167
+ spawnSync("mkdir", ["-p", path.join(worktreePath, ".claude")], {
168
+ stdio: "pipe",
169
+ });
170
+ spawnSync("cp", [claudeSettingsSrc, claudeSettingsDst], { stdio: "pipe" });
171
+ }
172
+ // Install dependencies if needed
173
+ const nodeModulesPath = path.join(worktreePath, "node_modules");
174
+ if (!existsSync(nodeModulesPath)) {
175
+ if (verbose) {
176
+ console.log(chalk.gray(` 📦 Installing dependencies...`));
177
+ }
178
+ // Use detected package manager or default to npm
179
+ const pm = packageManager || "npm";
180
+ const pmConfig = PM_CONFIG[pm];
181
+ const [cmd, ...args] = pmConfig.installSilent.split(" ");
182
+ spawnSync(cmd, args, {
183
+ cwd: worktreePath,
184
+ stdio: "pipe",
185
+ });
186
+ }
187
+ if (verbose) {
188
+ console.log(chalk.green(` ✅ Worktree ready: ${worktreePath}`));
189
+ }
190
+ return {
191
+ issue: issueNumber,
192
+ path: worktreePath,
193
+ branch,
194
+ existed: false,
195
+ };
196
+ }
197
+ /**
198
+ * Ensure worktrees exist for all issues before execution
199
+ */
200
+ async function ensureWorktrees(issues, verbose, packageManager) {
201
+ const worktrees = new Map();
202
+ console.log(chalk.blue("\n 📂 Preparing worktrees..."));
203
+ for (const issue of issues) {
204
+ const worktree = await ensureWorktree(issue.number, issue.title, verbose, packageManager);
205
+ if (worktree) {
206
+ worktrees.set(issue.number, worktree);
207
+ }
208
+ }
209
+ const created = Array.from(worktrees.values()).filter((w) => !w.existed).length;
210
+ const reused = Array.from(worktrees.values()).filter((w) => w.existed).length;
211
+ if (created > 0 || reused > 0) {
212
+ console.log(chalk.gray(` Worktrees: ${created} created, ${reused} reused`));
213
+ }
214
+ return worktrees;
215
+ }
13
216
  /**
14
217
  * Natural language prompts for each phase
15
218
  * These prompts will invoke the corresponding skills via natural language
@@ -26,6 +229,84 @@ const PHASE_PROMPTS = {
26
229
  * UI-related labels that trigger automatic test phase
27
230
  */
28
231
  const UI_LABELS = ["ui", "frontend", "admin", "web", "browser"];
232
+ /**
233
+ * Bug-related labels that skip spec phase
234
+ */
235
+ const BUG_LABELS = ["bug", "fix", "hotfix", "patch"];
236
+ /**
237
+ * Documentation labels that skip spec phase
238
+ */
239
+ const DOCS_LABELS = ["docs", "documentation", "readme"];
240
+ /**
241
+ * Complex labels that enable quality loop
242
+ */
243
+ const COMPLEX_LABELS = ["complex", "refactor", "breaking", "major"];
244
+ /**
245
+ * Detect phases based on issue labels (like /solve logic)
246
+ */
247
+ function detectPhasesFromLabels(labels) {
248
+ const lowerLabels = labels.map((l) => l.toLowerCase());
249
+ // Check for bug/fix labels → exec → qa (skip spec)
250
+ const isBugFix = lowerLabels.some((label) => BUG_LABELS.some((bugLabel) => label.includes(bugLabel)));
251
+ // Check for docs labels → exec → qa (skip spec)
252
+ const isDocs = lowerLabels.some((label) => DOCS_LABELS.some((docsLabel) => label.includes(docsLabel)));
253
+ // Check for UI labels → add test phase
254
+ const isUI = lowerLabels.some((label) => UI_LABELS.some((uiLabel) => label.includes(uiLabel)));
255
+ // Check for complex labels → enable quality loop
256
+ const isComplex = lowerLabels.some((label) => COMPLEX_LABELS.some((complexLabel) => label.includes(complexLabel)));
257
+ // Build phase list
258
+ let phases;
259
+ if (isBugFix || isDocs) {
260
+ // Simple workflow: exec → qa
261
+ phases = ["exec", "qa"];
262
+ }
263
+ else if (isUI) {
264
+ // UI workflow: spec → exec → test → qa
265
+ phases = ["spec", "exec", "test", "qa"];
266
+ }
267
+ else {
268
+ // Standard workflow: spec → exec → qa
269
+ phases = ["spec", "exec", "qa"];
270
+ }
271
+ return { phases, qualityLoop: isComplex };
272
+ }
273
+ /**
274
+ * Parse recommended workflow from /spec output
275
+ *
276
+ * Looks for:
277
+ * ## Recommended Workflow
278
+ * **Phases:** exec → qa
279
+ * **Quality Loop:** enabled|disabled
280
+ */
281
+ function parseRecommendedWorkflow(output) {
282
+ // Find the Recommended Workflow section
283
+ const workflowMatch = output.match(/## Recommended Workflow[\s\S]*?\*\*Phases:\*\*\s*([^\n]+)/i);
284
+ if (!workflowMatch) {
285
+ return null;
286
+ }
287
+ // Parse phases from "exec → qa" or "spec → exec → test → qa" format
288
+ const phasesStr = workflowMatch[1].trim();
289
+ const phaseNames = phasesStr
290
+ .split(/\s*→\s*|\s*->\s*|\s*,\s*/)
291
+ .map((p) => p.trim().toLowerCase())
292
+ .filter((p) => p.length > 0);
293
+ // Validate and convert to Phase type
294
+ const validPhases = [];
295
+ for (const name of phaseNames) {
296
+ if (["spec", "testgen", "exec", "test", "qa", "loop"].includes(name)) {
297
+ validPhases.push(name);
298
+ }
299
+ }
300
+ if (validPhases.length === 0) {
301
+ return null;
302
+ }
303
+ // Parse quality loop setting
304
+ const qualityLoopMatch = output.match(/\*\*Quality Loop:\*\*\s*(enabled|disabled|true|false|yes|no)/i);
305
+ const qualityLoop = qualityLoopMatch
306
+ ? ["enabled", "true", "yes"].includes(qualityLoopMatch[1].toLowerCase())
307
+ : false;
308
+ return { phases: validPhases, qualityLoop };
309
+ }
29
310
  /**
30
311
  * Format duration in human-readable format
31
312
  */
@@ -43,10 +324,15 @@ function formatDuration(seconds) {
43
324
  function getPhasePrompt(phase, issueNumber) {
44
325
  return PHASE_PROMPTS[phase].replace(/\{issue\}/g, String(issueNumber));
45
326
  }
327
+ /**
328
+ * Phases that require worktree isolation (exec, test, qa)
329
+ * Spec runs in main repo since it's planning-only
330
+ */
331
+ const ISOLATED_PHASES = ["exec", "test", "qa"];
46
332
  /**
47
333
  * Execute a single phase for an issue using Claude Agent SDK
48
334
  */
49
- async function executePhase(issueNumber, phase, config, sessionId) {
335
+ async function executePhase(issueNumber, phase, config, sessionId, worktreePath) {
50
336
  const startTime = Date.now();
51
337
  if (config.dryRun) {
52
338
  // Dry run - just simulate
@@ -62,7 +348,13 @@ async function executePhase(issueNumber, phase, config, sessionId) {
62
348
  const prompt = getPhasePrompt(phase, issueNumber);
63
349
  if (config.verbose) {
64
350
  console.log(chalk.gray(` Prompt: ${prompt}`));
351
+ if (worktreePath && ISOLATED_PHASES.includes(phase)) {
352
+ console.log(chalk.gray(` Worktree: ${worktreePath}`));
353
+ }
65
354
  }
355
+ // Determine working directory and environment
356
+ const shouldUseWorktree = worktreePath && ISOLATED_PHASES.includes(phase);
357
+ const cwd = shouldUseWorktree ? worktreePath : process.cwd();
66
358
  try {
67
359
  // Create abort controller for timeout
68
360
  const abortController = new AbortController();
@@ -72,12 +364,25 @@ async function executePhase(issueNumber, phase, config, sessionId) {
72
364
  let resultSessionId;
73
365
  let resultMessage;
74
366
  let lastError;
367
+ let capturedOutput = "";
368
+ // Build environment with worktree isolation variables
369
+ const env = {
370
+ ...process.env,
371
+ CLAUDE_HOOKS_SMART_TESTS: config.noSmartTests ? "false" : "true",
372
+ };
373
+ // Set worktree isolation environment variables
374
+ if (shouldUseWorktree) {
375
+ env.SEQUANT_WORKTREE = worktreePath;
376
+ env.SEQUANT_ISSUE = String(issueNumber);
377
+ }
75
378
  // Execute using Claude Agent SDK
379
+ // Note: Don't resume sessions when switching to worktree (different cwd breaks resume)
380
+ const canResume = sessionId && !shouldUseWorktree;
76
381
  const queryInstance = query({
77
382
  prompt,
78
383
  options: {
79
384
  abortController,
80
- cwd: process.cwd(),
385
+ cwd,
81
386
  // Load project settings including skills
82
387
  settingSources: ["project"],
83
388
  // Use Claude Code's system prompt and tools
@@ -86,13 +391,10 @@ async function executePhase(issueNumber, phase, config, sessionId) {
86
391
  // Bypass permissions for headless execution
87
392
  permissionMode: "bypassPermissions",
88
393
  allowDangerouslySkipPermissions: true,
89
- // Resume from previous session if provided
90
- ...(sessionId ? { resume: sessionId } : {}),
91
- // Configure smart tests via environment
92
- env: {
93
- ...process.env,
94
- CLAUDE_HOOKS_SMART_TESTS: config.noSmartTests ? "false" : "true",
95
- },
394
+ // Resume from previous session if provided (but not when switching directories)
395
+ ...(canResume ? { resume: sessionId } : {}),
396
+ // Configure smart tests and worktree isolation via environment
397
+ env,
96
398
  },
97
399
  });
98
400
  // Stream and process messages
@@ -101,8 +403,8 @@ async function executePhase(issueNumber, phase, config, sessionId) {
101
403
  if (message.type === "system" && message.subtype === "init") {
102
404
  resultSessionId = message.session_id;
103
405
  }
104
- // Show streaming output in verbose mode
105
- if (config.verbose && message.type === "assistant") {
406
+ // Capture output from assistant messages
407
+ if (message.type === "assistant") {
106
408
  // Extract text content from the message
107
409
  const content = message.message.content;
108
410
  const textContent = content
@@ -110,7 +412,11 @@ async function executePhase(issueNumber, phase, config, sessionId) {
110
412
  .map((c) => c.text)
111
413
  .join("");
112
414
  if (textContent) {
113
- process.stdout.write(chalk.gray(textContent));
415
+ capturedOutput += textContent;
416
+ // Show streaming output in verbose mode
417
+ if (config.verbose) {
418
+ process.stdout.write(chalk.gray(textContent));
419
+ }
114
420
  }
115
421
  }
116
422
  // Capture the final result
@@ -128,6 +434,7 @@ async function executePhase(issueNumber, phase, config, sessionId) {
128
434
  success: true,
129
435
  durationSeconds,
130
436
  sessionId: resultSessionId,
437
+ output: capturedOutput,
131
438
  };
132
439
  }
133
440
  else {
@@ -189,21 +496,14 @@ async function executePhase(issueNumber, phase, config, sessionId) {
189
496
  */
190
497
  async function getIssueInfo(issueNumber) {
191
498
  try {
192
- const result = spawnSync("gh", [
193
- "issue",
194
- "view",
195
- String(issueNumber),
196
- "--json",
197
- "title,labels",
198
- "--jq",
199
- '"\(.title)|\(.labels | map(.name) | join(","))"',
200
- ], { stdio: "pipe", shell: true });
499
+ const result = spawnSync("gh", ["issue", "view", String(issueNumber), "--json", "title,labels"], { stdio: "pipe" });
201
500
  if (result.status === 0) {
202
- const output = result.stdout.toString().trim().replace(/^"|"$/g, "");
203
- const [title, labelsStr] = output.split("|");
501
+ const data = JSON.parse(result.stdout.toString());
204
502
  return {
205
- title: title || `Issue #${issueNumber}`,
206
- labels: labelsStr ? labelsStr.split(",").filter(Boolean) : [],
503
+ title: data.title || `Issue #${issueNumber}`,
504
+ labels: Array.isArray(data.labels)
505
+ ? data.labels.map((l) => l.name)
506
+ : [],
207
507
  };
208
508
  }
209
509
  }
@@ -212,6 +512,105 @@ async function getIssueInfo(issueNumber) {
212
512
  }
213
513
  return { title: `Issue #${issueNumber}`, labels: [] };
214
514
  }
515
+ /**
516
+ * Parse dependencies from issue body and labels
517
+ * Returns array of issue numbers this issue depends on
518
+ */
519
+ function parseDependencies(issueNumber) {
520
+ try {
521
+ const result = spawnSync("gh", ["issue", "view", String(issueNumber), "--json", "body,labels"], { stdio: "pipe" });
522
+ if (result.status !== 0)
523
+ return [];
524
+ const data = JSON.parse(result.stdout.toString());
525
+ const dependencies = [];
526
+ // Parse from body: "Depends on: #123" or "**Depends on**: #123"
527
+ if (data.body) {
528
+ const bodyMatch = data.body.match(/\*?\*?depends\s+on\*?\*?:?\s*#?(\d+)/gi);
529
+ if (bodyMatch) {
530
+ for (const match of bodyMatch) {
531
+ const numMatch = match.match(/(\d+)/);
532
+ if (numMatch) {
533
+ dependencies.push(parseInt(numMatch[1], 10));
534
+ }
535
+ }
536
+ }
537
+ }
538
+ // Parse from labels: "depends-on/123" or "depends-on-123"
539
+ if (data.labels && Array.isArray(data.labels)) {
540
+ for (const label of data.labels) {
541
+ const labelName = label.name || label;
542
+ const labelMatch = labelName.match(/depends-on[-/](\d+)/i);
543
+ if (labelMatch) {
544
+ dependencies.push(parseInt(labelMatch[1], 10));
545
+ }
546
+ }
547
+ }
548
+ return [...new Set(dependencies)]; // Remove duplicates
549
+ }
550
+ catch {
551
+ return [];
552
+ }
553
+ }
554
+ /**
555
+ * Sort issues by dependencies (topological sort)
556
+ * Issues with no dependencies come first, then issues that depend on them
557
+ */
558
+ function sortByDependencies(issueNumbers) {
559
+ // Build dependency graph
560
+ const dependsOn = new Map();
561
+ for (const issue of issueNumbers) {
562
+ const deps = parseDependencies(issue);
563
+ // Only include dependencies that are in our issue list
564
+ dependsOn.set(issue, deps.filter((d) => issueNumbers.includes(d)));
565
+ }
566
+ // Topological sort using Kahn's algorithm
567
+ const inDegree = new Map();
568
+ for (const issue of issueNumbers) {
569
+ inDegree.set(issue, 0);
570
+ }
571
+ for (const deps of dependsOn.values()) {
572
+ for (const dep of deps) {
573
+ inDegree.set(dep, (inDegree.get(dep) || 0) + 1);
574
+ }
575
+ }
576
+ // Note: inDegree counts how many issues depend on each issue
577
+ // We want to process issues that nothing depends on last
578
+ // So we sort by: issues nothing depends on first, then dependent issues
579
+ const sorted = [];
580
+ const queue = [];
581
+ // Start with issues that have no dependencies
582
+ for (const issue of issueNumbers) {
583
+ const deps = dependsOn.get(issue) || [];
584
+ if (deps.length === 0) {
585
+ queue.push(issue);
586
+ }
587
+ }
588
+ const visited = new Set();
589
+ while (queue.length > 0) {
590
+ const issue = queue.shift();
591
+ if (visited.has(issue))
592
+ continue;
593
+ visited.add(issue);
594
+ sorted.push(issue);
595
+ // Find issues that depend on this one
596
+ for (const [other, deps] of dependsOn.entries()) {
597
+ if (deps.includes(issue) && !visited.has(other)) {
598
+ // Check if all dependencies of 'other' are satisfied
599
+ const allDepsSatisfied = deps.every((d) => visited.has(d));
600
+ if (allDepsSatisfied) {
601
+ queue.push(other);
602
+ }
603
+ }
604
+ }
605
+ }
606
+ // Add any remaining issues (circular dependencies or unvisited)
607
+ for (const issue of issueNumbers) {
608
+ if (!visited.has(issue)) {
609
+ sorted.push(issue);
610
+ }
611
+ }
612
+ return sorted;
613
+ }
215
614
  /**
216
615
  * Check if an issue has UI-related labels
217
616
  */
@@ -278,16 +677,34 @@ function parseBatches(batchArgs) {
278
677
  * Main run command
279
678
  */
280
679
  export async function runCommand(issues, options) {
281
- console.log(chalk.blue("\n🚀 Sequant Workflow Execution\n"));
680
+ console.log(chalk.blue("\n🌐 Sequant Workflow Execution\n"));
282
681
  // Check if initialized
283
682
  const manifest = await getManifest();
284
683
  if (!manifest) {
285
684
  console.log(chalk.red("❌ Sequant is not initialized. Run `sequant init` first."));
286
685
  return;
287
686
  }
288
- // Merge environment config with CLI options
687
+ // Load settings and merge with environment config and CLI options
688
+ const settings = await getSettings();
289
689
  const envConfig = getEnvConfig();
290
- const mergedOptions = { ...envConfig, ...options };
690
+ // Settings provide defaults, env overrides settings, CLI overrides all
691
+ // Note: phases are auto-detected per-issue unless --phases is explicitly set
692
+ const mergedOptions = {
693
+ // Settings defaults (phases removed - now auto-detected)
694
+ sequential: options.sequential ?? settings.run.sequential,
695
+ timeout: options.timeout ?? settings.run.timeout,
696
+ logPath: options.logPath ?? settings.run.logPath,
697
+ qualityLoop: options.qualityLoop ?? settings.run.qualityLoop,
698
+ maxIterations: options.maxIterations ?? settings.run.maxIterations,
699
+ noSmartTests: options.noSmartTests ?? !settings.run.smartTests,
700
+ // Env overrides
701
+ ...envConfig,
702
+ // CLI explicit options override all
703
+ ...options,
704
+ };
705
+ // Determine if we should auto-detect phases from labels
706
+ const autoDetectPhases = !options.phases && settings.run.autoDetectPhases;
707
+ mergedOptions.autoDetectPhases = autoDetectPhases;
291
708
  // Parse issue numbers (or use batch mode)
292
709
  let issueNumbers;
293
710
  let batches = null;
@@ -301,17 +718,28 @@ export async function runCommand(issues, options) {
301
718
  }
302
719
  if (issueNumbers.length === 0) {
303
720
  console.log(chalk.red("❌ No valid issue numbers provided."));
304
- console.log(chalk.gray("\nUsage: sequant run <issues...> [options]"));
305
- console.log(chalk.gray("Example: sequant run 1 2 3 --sequential"));
306
- console.log(chalk.gray('Batch example: sequant run --batch "1 2" --batch "3"'));
721
+ console.log(chalk.gray("\nUsage: npx sequant run <issues...> [options]"));
722
+ console.log(chalk.gray("Example: npx sequant run 1 2 3 --sequential"));
723
+ console.log(chalk.gray('Batch example: npx sequant run --batch "1 2" --batch "3"'));
307
724
  return;
308
725
  }
726
+ // Sort issues by dependencies (if more than one issue)
727
+ if (issueNumbers.length > 1 && !batches) {
728
+ const originalOrder = [...issueNumbers];
729
+ issueNumbers = sortByDependencies(issueNumbers);
730
+ const orderChanged = !originalOrder.every((n, i) => n === issueNumbers[i]);
731
+ if (orderChanged) {
732
+ console.log(chalk.gray(` Dependency order: ${issueNumbers.map((n) => `#${n}`).join(" → ")}`));
733
+ }
734
+ }
309
735
  // Build config
736
+ // Note: config.phases is only used when --phases is explicitly set or autoDetect fails
737
+ const explicitPhases = mergedOptions.phases
738
+ ? mergedOptions.phases.split(",").map((p) => p.trim())
739
+ : null;
310
740
  const config = {
311
741
  ...DEFAULT_CONFIG,
312
- phases: mergedOptions.phases
313
- ? mergedOptions.phases.split(",").map((p) => p.trim())
314
- : DEFAULT_PHASES,
742
+ phases: explicitPhases ?? DEFAULT_PHASES,
315
743
  sequential: mergedOptions.sequential ?? false,
316
744
  dryRun: mergedOptions.dryRun ?? false,
317
745
  verbose: mergedOptions.verbose ?? false,
@@ -321,8 +749,12 @@ export async function runCommand(issues, options) {
321
749
  noSmartTests: mergedOptions.noSmartTests ?? false,
322
750
  };
323
751
  // Initialize log writer if JSON logging enabled
752
+ // Default: enabled via settings (logJson: true), can be disabled with --no-log
324
753
  let logWriter = null;
325
- if (mergedOptions.logJson && !config.dryRun) {
754
+ const shouldLog = !mergedOptions.noLog &&
755
+ !config.dryRun &&
756
+ (mergedOptions.logJson ?? settings.run.logJson);
757
+ if (shouldLog) {
326
758
  const runConfig = {
327
759
  phases: config.phases,
328
760
  sequential: config.sequential,
@@ -330,14 +762,19 @@ export async function runCommand(issues, options) {
330
762
  maxIterations: config.maxIterations,
331
763
  };
332
764
  logWriter = new LogWriter({
333
- logPath: mergedOptions.logPath,
765
+ logPath: mergedOptions.logPath ?? settings.run.logPath,
334
766
  verbose: config.verbose,
335
767
  });
336
768
  await logWriter.initialize(runConfig);
337
769
  }
338
770
  // Display configuration
339
771
  console.log(chalk.gray(` Stack: ${manifest.stack}`));
340
- console.log(chalk.gray(` Phases: ${config.phases.join(" → ")}`));
772
+ if (autoDetectPhases) {
773
+ console.log(chalk.gray(` Phases: auto-detect from labels`));
774
+ }
775
+ else {
776
+ console.log(chalk.gray(` Phases: ${config.phases.join(" → ")}`));
777
+ }
341
778
  console.log(chalk.gray(` Mode: ${config.sequential ? "sequential" : "parallel"}`));
342
779
  if (config.qualityLoop) {
343
780
  console.log(chalk.gray(` Quality loop: enabled (max ${config.maxIterations} iterations)`));
@@ -355,6 +792,25 @@ export async function runCommand(issues, options) {
355
792
  console.log(chalk.gray(` Logging: JSON (run ${logWriter.getRunId()?.slice(0, 8)}...)`));
356
793
  }
357
794
  console.log(chalk.gray(` Issues: ${issueNumbers.map((n) => `#${n}`).join(", ")}`));
795
+ // Worktree isolation is enabled by default for multi-issue runs
796
+ const useWorktreeIsolation = mergedOptions.worktreeIsolation !== false && issueNumbers.length > 0;
797
+ if (useWorktreeIsolation) {
798
+ console.log(chalk.gray(` Worktree isolation: enabled`));
799
+ }
800
+ // Fetch issue info for all issues first
801
+ const issueInfoMap = new Map();
802
+ for (const issueNumber of issueNumbers) {
803
+ issueInfoMap.set(issueNumber, await getIssueInfo(issueNumber));
804
+ }
805
+ // Create worktrees for all issues before execution (if isolation enabled)
806
+ let worktreeMap = new Map();
807
+ if (useWorktreeIsolation && !config.dryRun) {
808
+ const issueData = issueNumbers.map((num) => ({
809
+ number: num,
810
+ title: issueInfoMap.get(num)?.title || `Issue #${num}`,
811
+ }));
812
+ worktreeMap = await ensureWorktrees(issueData, config.verbose, manifest.packageManager);
813
+ }
358
814
  // Execute
359
815
  const results = [];
360
816
  if (batches) {
@@ -362,7 +818,7 @@ export async function runCommand(issues, options) {
362
818
  for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) {
363
819
  const batch = batches[batchIdx];
364
820
  console.log(chalk.blue(`\n Batch ${batchIdx + 1}/${batches.length}: Issues ${batch.map((n) => `#${n}`).join(", ")}`));
365
- const batchResults = await executeBatch(batch, config, logWriter, mergedOptions);
821
+ const batchResults = await executeBatch(batch, config, logWriter, mergedOptions, issueInfoMap, worktreeMap);
366
822
  results.push(...batchResults);
367
823
  // Check if batch failed and we should stop
368
824
  const batchFailed = batchResults.some((r) => !r.success);
@@ -375,12 +831,16 @@ export async function runCommand(issues, options) {
375
831
  else if (config.sequential) {
376
832
  // Sequential execution
377
833
  for (const issueNumber of issueNumbers) {
378
- const issueInfo = await getIssueInfo(issueNumber);
834
+ const issueInfo = issueInfoMap.get(issueNumber) ?? {
835
+ title: `Issue #${issueNumber}`,
836
+ labels: [],
837
+ };
838
+ const worktreeInfo = worktreeMap.get(issueNumber);
379
839
  // Start issue logging
380
840
  if (logWriter) {
381
841
  logWriter.startIssue(issueNumber, issueInfo.title, issueInfo.labels);
382
842
  }
383
- const result = await runIssueWithLogging(issueNumber, config, logWriter, issueInfo.labels, mergedOptions);
843
+ const result = await runIssueWithLogging(issueNumber, config, logWriter, issueInfo.labels, mergedOptions, worktreeInfo?.path);
384
844
  results.push(result);
385
845
  // Complete issue logging
386
846
  if (logWriter) {
@@ -396,12 +856,16 @@ export async function runCommand(issues, options) {
396
856
  // Parallel execution (for now, just run sequentially but don't stop on failure)
397
857
  // TODO: Add proper parallel execution with listr2
398
858
  for (const issueNumber of issueNumbers) {
399
- const issueInfo = await getIssueInfo(issueNumber);
859
+ const issueInfo = issueInfoMap.get(issueNumber) ?? {
860
+ title: `Issue #${issueNumber}`,
861
+ labels: [],
862
+ };
863
+ const worktreeInfo = worktreeMap.get(issueNumber);
400
864
  // Start issue logging
401
865
  if (logWriter) {
402
866
  logWriter.startIssue(issueNumber, issueInfo.title, issueInfo.labels);
403
867
  }
404
- const result = await runIssueWithLogging(issueNumber, config, logWriter, issueInfo.labels, mergedOptions);
868
+ const result = await runIssueWithLogging(issueNumber, config, logWriter, issueInfo.labels, mergedOptions, worktreeInfo?.path);
405
869
  results.push(result);
406
870
  // Complete issue logging
407
871
  if (logWriter) {
@@ -449,15 +913,19 @@ export async function runCommand(issues, options) {
449
913
  /**
450
914
  * Execute a batch of issues
451
915
  */
452
- async function executeBatch(issueNumbers, config, logWriter, options) {
916
+ async function executeBatch(issueNumbers, config, logWriter, options, issueInfoMap, worktreeMap) {
453
917
  const results = [];
454
918
  for (const issueNumber of issueNumbers) {
455
- const issueInfo = await getIssueInfo(issueNumber);
919
+ const issueInfo = issueInfoMap.get(issueNumber) ?? {
920
+ title: `Issue #${issueNumber}`,
921
+ labels: [],
922
+ };
923
+ const worktreeInfo = worktreeMap.get(issueNumber);
456
924
  // Start issue logging
457
925
  if (logWriter) {
458
926
  logWriter.startIssue(issueNumber, issueInfo.title, issueInfo.labels);
459
927
  }
460
- const result = await runIssueWithLogging(issueNumber, config, logWriter, issueInfo.labels, options);
928
+ const result = await runIssueWithLogging(issueNumber, config, logWriter, issueInfo.labels, options, worktreeInfo?.path);
461
929
  results.push(result);
462
930
  // Complete issue logging
463
931
  if (logWriter) {
@@ -469,22 +937,113 @@ async function executeBatch(issueNumbers, config, logWriter, options) {
469
937
  /**
470
938
  * Execute all phases for a single issue with logging and quality loop
471
939
  */
472
- async function runIssueWithLogging(issueNumber, config, logWriter, labels, options) {
940
+ async function runIssueWithLogging(issueNumber, config, logWriter, labels, options, worktreePath) {
473
941
  const startTime = Date.now();
474
942
  const phaseResults = [];
475
943
  let loopTriggered = false;
476
944
  let sessionId;
477
945
  console.log(chalk.blue(`\n Issue #${issueNumber}`));
946
+ if (worktreePath) {
947
+ console.log(chalk.gray(` Worktree: ${worktreePath}`));
948
+ }
478
949
  // Determine phases for this specific issue
479
- const phases = determinePhasesForIssue(config.phases, labels, options);
480
- if (phases.length !== config.phases.length) {
481
- console.log(chalk.gray(` Phases adjusted: ${phases.join(" → ")}`));
950
+ let phases;
951
+ let detectedQualityLoop = false;
952
+ let specAlreadyRan = false;
953
+ if (options.autoDetectPhases) {
954
+ // Check if labels indicate a simple bug/fix (skip spec entirely)
955
+ const lowerLabels = labels.map((l) => l.toLowerCase());
956
+ const isSimpleBugFix = lowerLabels.some((label) => BUG_LABELS.some((bugLabel) => label.includes(bugLabel)));
957
+ if (isSimpleBugFix) {
958
+ // Simple bug fix: skip spec, go straight to exec → qa
959
+ phases = ["exec", "qa"];
960
+ console.log(chalk.gray(` Bug fix detected: ${phases.join(" → ")}`));
961
+ }
962
+ else {
963
+ // Run spec first to get recommended workflow
964
+ console.log(chalk.gray(` Running spec to determine workflow...`));
965
+ console.log(chalk.gray(` ⏳ spec...`));
966
+ const specStartTime = new Date();
967
+ // Note: spec runs in main repo (not worktree) for planning
968
+ const specResult = await executePhase(issueNumber, "spec", config, sessionId, worktreePath);
969
+ const specEndTime = new Date();
970
+ if (specResult.sessionId) {
971
+ sessionId = specResult.sessionId;
972
+ }
973
+ phaseResults.push(specResult);
974
+ specAlreadyRan = true;
975
+ // Log spec phase result
976
+ if (logWriter) {
977
+ const phaseLog = createPhaseLogFromTiming("spec", issueNumber, specStartTime, specEndTime, specResult.success
978
+ ? "success"
979
+ : specResult.error?.includes("Timeout")
980
+ ? "timeout"
981
+ : "failure", { error: specResult.error });
982
+ logWriter.logPhase(phaseLog);
983
+ }
984
+ if (!specResult.success) {
985
+ console.log(chalk.red(` ✗ spec: ${specResult.error}`));
986
+ const durationSeconds = (Date.now() - startTime) / 1000;
987
+ return {
988
+ issueNumber,
989
+ success: false,
990
+ phaseResults,
991
+ durationSeconds,
992
+ loopTriggered: false,
993
+ };
994
+ }
995
+ const duration = specResult.durationSeconds
996
+ ? ` (${formatDuration(specResult.durationSeconds)})`
997
+ : "";
998
+ console.log(chalk.green(` ✓ spec${duration}`));
999
+ // Parse recommended workflow from spec output
1000
+ let parsedWorkflow = specResult.output
1001
+ ? parseRecommendedWorkflow(specResult.output)
1002
+ : null;
1003
+ if (parsedWorkflow) {
1004
+ // Remove spec from phases since we already ran it
1005
+ phases = parsedWorkflow.phases.filter((p) => p !== "spec");
1006
+ detectedQualityLoop = parsedWorkflow.qualityLoop;
1007
+ console.log(chalk.gray(` Spec recommends: ${phases.join(" → ")}${detectedQualityLoop ? " (quality loop)" : ""}`));
1008
+ }
1009
+ else {
1010
+ // Fall back to label-based detection
1011
+ console.log(chalk.yellow(` Could not parse spec recommendation, using label-based detection`));
1012
+ const detected = detectPhasesFromLabels(labels);
1013
+ phases = detected.phases.filter((p) => p !== "spec");
1014
+ detectedQualityLoop = detected.qualityLoop;
1015
+ console.log(chalk.gray(` Fallback: ${phases.join(" → ")}`));
1016
+ }
1017
+ }
1018
+ }
1019
+ else {
1020
+ // Use explicit phases with adjustments
1021
+ phases = determinePhasesForIssue(config.phases, labels, options);
1022
+ if (phases.length !== config.phases.length) {
1023
+ console.log(chalk.gray(` Phases adjusted: ${phases.join(" → ")}`));
1024
+ }
1025
+ }
1026
+ // Add testgen phase if requested (and spec was in the phases)
1027
+ if (options.testgen &&
1028
+ (phases.includes("spec") || specAlreadyRan) &&
1029
+ !phases.includes("testgen")) {
1030
+ // Insert testgen at the beginning if spec already ran, otherwise after spec
1031
+ if (specAlreadyRan) {
1032
+ phases.unshift("testgen");
1033
+ }
1034
+ else {
1035
+ const specIndex = phases.indexOf("spec");
1036
+ if (specIndex !== -1) {
1037
+ phases.splice(specIndex + 1, 0, "testgen");
1038
+ }
1039
+ }
482
1040
  }
483
1041
  let iteration = 0;
484
- const maxIterations = config.qualityLoop ? config.maxIterations : 1;
1042
+ const useQualityLoop = config.qualityLoop || detectedQualityLoop;
1043
+ const maxIterations = useQualityLoop ? config.maxIterations : 1;
485
1044
  while (iteration < maxIterations) {
486
1045
  iteration++;
487
- if (config.qualityLoop && iteration > 1) {
1046
+ if (useQualityLoop && iteration > 1) {
488
1047
  console.log(chalk.yellow(` Quality loop iteration ${iteration}/${maxIterations}`));
489
1048
  loopTriggered = true;
490
1049
  }
@@ -492,7 +1051,7 @@ async function runIssueWithLogging(issueNumber, config, logWriter, labels, optio
492
1051
  for (const phase of phases) {
493
1052
  console.log(chalk.gray(` ⏳ ${phase}...`));
494
1053
  const phaseStartTime = new Date();
495
- const result = await executePhase(issueNumber, phase, config, sessionId);
1054
+ const result = await executePhase(issueNumber, phase, config, sessionId, worktreePath);
496
1055
  const phaseEndTime = new Date();
497
1056
  // Capture session ID for subsequent phases
498
1057
  if (result.sessionId) {
@@ -518,9 +1077,9 @@ async function runIssueWithLogging(issueNumber, config, logWriter, labels, optio
518
1077
  console.log(chalk.red(` ✗ ${phase}: ${result.error}`));
519
1078
  phasesFailed = true;
520
1079
  // If quality loop enabled, run loop phase to fix issues
521
- if (config.qualityLoop && iteration < maxIterations) {
1080
+ if (useQualityLoop && iteration < maxIterations) {
522
1081
  console.log(chalk.yellow(` Running /loop to fix issues...`));
523
- const loopResult = await executePhase(issueNumber, "loop", config, sessionId);
1082
+ const loopResult = await executePhase(issueNumber, "loop", config, sessionId, worktreePath);
524
1083
  phaseResults.push(loopResult);
525
1084
  if (loopResult.sessionId) {
526
1085
  sessionId = loopResult.sessionId;