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.
- package/README.md +112 -10
- package/dist/bin/cli.js +3 -1
- package/dist/bin/cli.js.map +1 -1
- package/dist/src/commands/doctor.d.ts.map +1 -1
- package/dist/src/commands/doctor.js +33 -2
- package/dist/src/commands/doctor.js.map +1 -1
- package/dist/src/commands/doctor.test.js +63 -1
- package/dist/src/commands/doctor.test.js.map +1 -1
- package/dist/src/commands/init.d.ts +1 -0
- package/dist/src/commands/init.d.ts.map +1 -1
- package/dist/src/commands/init.js +75 -9
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/init.test.js +137 -2
- package/dist/src/commands/init.test.js.map +1 -1
- package/dist/src/commands/logs.js +1 -1
- package/dist/src/commands/logs.js.map +1 -1
- package/dist/src/commands/run.d.ts +18 -0
- package/dist/src/commands/run.d.ts.map +1 -1
- package/dist/src/commands/run.js +613 -54
- package/dist/src/commands/run.js.map +1 -1
- package/dist/src/commands/run.test.d.ts +2 -0
- package/dist/src/commands/run.test.d.ts.map +1 -0
- package/dist/src/commands/run.test.js +155 -0
- package/dist/src/commands/run.test.js.map +1 -0
- package/dist/src/commands/update.d.ts.map +1 -1
- package/dist/src/commands/update.js +58 -6
- package/dist/src/commands/update.js.map +1 -1
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/config.d.ts +19 -0
- package/dist/src/lib/config.d.ts.map +1 -0
- package/dist/src/lib/config.js +31 -0
- package/dist/src/lib/config.js.map +1 -0
- package/dist/src/lib/manifest.d.ts +3 -1
- package/dist/src/lib/manifest.d.ts.map +1 -1
- package/dist/src/lib/manifest.js +2 -1
- package/dist/src/lib/manifest.js.map +1 -1
- package/dist/src/lib/settings.d.ts +69 -0
- package/dist/src/lib/settings.d.ts.map +1 -0
- package/dist/src/lib/settings.js +79 -0
- package/dist/src/lib/settings.js.map +1 -0
- package/dist/src/lib/stacks.d.ts +28 -0
- package/dist/src/lib/stacks.d.ts.map +1 -1
- package/dist/src/lib/stacks.js +160 -17
- package/dist/src/lib/stacks.js.map +1 -1
- package/dist/src/lib/stacks.test.js +343 -1
- package/dist/src/lib/stacks.test.js.map +1 -1
- package/dist/src/lib/system.d.ts +8 -0
- package/dist/src/lib/system.d.ts.map +1 -1
- package/dist/src/lib/system.js +23 -0
- package/dist/src/lib/system.js.map +1 -1
- package/dist/src/lib/templates.d.ts +5 -1
- package/dist/src/lib/templates.d.ts.map +1 -1
- package/dist/src/lib/templates.js +3 -2
- package/dist/src/lib/templates.js.map +1 -1
- package/dist/src/lib/tty.d.ts +31 -0
- package/dist/src/lib/tty.d.ts.map +1 -0
- package/dist/src/lib/tty.js +81 -0
- package/dist/src/lib/tty.js.map +1 -0
- package/dist/src/lib/tty.test.d.ts +2 -0
- package/dist/src/lib/tty.test.d.ts.map +1 -0
- package/dist/src/lib/tty.test.js +227 -0
- package/dist/src/lib/tty.test.js.map +1 -0
- package/dist/src/lib/workflow/types.d.ts +2 -0
- package/dist/src/lib/workflow/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/templates/hooks/post-tool.sh +4 -2
- package/templates/scripts/new-feature.sh +33 -9
- package/templates/skills/solve/SKILL.md +27 -11
- package/templates/skills/spec/SKILL.md +30 -2
package/dist/src/commands/run.js
CHANGED
|
@@ -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
|
|
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
|
-
...(
|
|
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
|
-
//
|
|
105
|
-
if (
|
|
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
|
-
|
|
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
|
|
203
|
-
const [title, labelsStr] = output.split("|");
|
|
501
|
+
const data = JSON.parse(result.stdout.toString());
|
|
204
502
|
return {
|
|
205
|
-
title: title || `Issue #${issueNumber}`,
|
|
206
|
-
labels:
|
|
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
|
|
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
|
-
//
|
|
687
|
+
// Load settings and merge with environment config and CLI options
|
|
688
|
+
const settings = await getSettings();
|
|
289
689
|
const envConfig = getEnvConfig();
|
|
290
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
|
1042
|
+
const useQualityLoop = config.qualityLoop || detectedQualityLoop;
|
|
1043
|
+
const maxIterations = useQualityLoop ? config.maxIterations : 1;
|
|
485
1044
|
while (iteration < maxIterations) {
|
|
486
1045
|
iteration++;
|
|
487
|
-
if (
|
|
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 (
|
|
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;
|