ai-sdlc 0.2.0-alpha.6 → 0.2.0-alpha.61
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 +65 -1057
- package/dist/agents/implementation.d.ts +36 -1
- package/dist/agents/implementation.d.ts.map +1 -1
- package/dist/agents/implementation.js +259 -30
- package/dist/agents/implementation.js.map +1 -1
- package/dist/agents/index.d.ts +2 -0
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/index.js +2 -0
- package/dist/agents/index.js.map +1 -1
- package/dist/agents/orchestrator.d.ts +61 -0
- package/dist/agents/orchestrator.d.ts.map +1 -0
- package/dist/agents/orchestrator.js +443 -0
- package/dist/agents/orchestrator.js.map +1 -0
- package/dist/agents/planning.d.ts +1 -1
- package/dist/agents/planning.d.ts.map +1 -1
- package/dist/agents/planning.js +55 -4
- package/dist/agents/planning.js.map +1 -1
- package/dist/agents/refinement.d.ts.map +1 -1
- package/dist/agents/refinement.js +22 -3
- package/dist/agents/refinement.js.map +1 -1
- package/dist/agents/research.d.ts +85 -1
- package/dist/agents/research.d.ts.map +1 -1
- package/dist/agents/research.js +506 -16
- package/dist/agents/research.js.map +1 -1
- package/dist/agents/review.d.ts +116 -2
- package/dist/agents/review.d.ts.map +1 -1
- package/dist/agents/review.js +847 -93
- package/dist/agents/review.js.map +1 -1
- package/dist/agents/rework.d.ts.map +1 -1
- package/dist/agents/rework.js +25 -4
- package/dist/agents/rework.js.map +1 -1
- package/dist/agents/single-task.d.ts +41 -0
- package/dist/agents/single-task.d.ts.map +1 -0
- package/dist/agents/single-task.js +357 -0
- package/dist/agents/single-task.js.map +1 -0
- package/dist/agents/state-assessor.d.ts +3 -3
- package/dist/agents/state-assessor.d.ts.map +1 -1
- package/dist/agents/state-assessor.js +6 -6
- package/dist/agents/state-assessor.js.map +1 -1
- package/dist/agents/test-pattern-detector.d.ts +49 -0
- package/dist/agents/test-pattern-detector.d.ts.map +1 -0
- package/dist/agents/test-pattern-detector.js +273 -0
- package/dist/agents/test-pattern-detector.js.map +1 -0
- package/dist/agents/verification.d.ts +11 -0
- package/dist/agents/verification.d.ts.map +1 -1
- package/dist/agents/verification.js +99 -12
- package/dist/agents/verification.js.map +1 -1
- package/dist/cli/batch-processor.d.ts +64 -0
- package/dist/cli/batch-processor.d.ts.map +1 -0
- package/dist/cli/batch-processor.js +85 -0
- package/dist/cli/batch-processor.js.map +1 -0
- package/dist/cli/batch-validator.d.ts +80 -0
- package/dist/cli/batch-validator.d.ts.map +1 -0
- package/dist/cli/batch-validator.js +121 -0
- package/dist/cli/batch-validator.js.map +1 -0
- package/dist/cli/commands/migrate.js +1 -1
- package/dist/cli/commands/migrate.js.map +1 -1
- package/dist/cli/commands.d.ts +67 -3
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +1765 -198
- package/dist/cli/commands.js.map +1 -1
- package/dist/cli/daemon.d.ts.map +1 -1
- package/dist/cli/daemon.js +25 -3
- package/dist/cli/daemon.js.map +1 -1
- package/dist/cli/runner.d.ts.map +1 -1
- package/dist/cli/runner.js +35 -12
- package/dist/cli/runner.js.map +1 -1
- package/dist/core/auth.d.ts +43 -0
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js +105 -1
- package/dist/core/auth.js.map +1 -1
- package/dist/core/client.d.ts +25 -1
- package/dist/core/client.d.ts.map +1 -1
- package/dist/core/client.js +247 -7
- package/dist/core/client.js.map +1 -1
- package/dist/core/config.d.ts +32 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +146 -3
- package/dist/core/config.js.map +1 -1
- package/dist/core/conflict-detector.d.ts +108 -0
- package/dist/core/conflict-detector.d.ts.map +1 -0
- package/dist/core/conflict-detector.js +413 -0
- package/dist/core/conflict-detector.js.map +1 -0
- package/dist/core/git-utils.d.ts +28 -0
- package/dist/core/git-utils.d.ts.map +1 -0
- package/dist/core/git-utils.js +146 -0
- package/dist/core/git-utils.js.map +1 -0
- package/dist/core/index.d.ts +19 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +19 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/kanban.d.ts +1 -1
- package/dist/core/kanban.d.ts.map +1 -1
- package/dist/core/kanban.js +3 -3
- package/dist/core/kanban.js.map +1 -1
- package/dist/core/llm-utils.d.ts +103 -0
- package/dist/core/llm-utils.d.ts.map +1 -0
- package/dist/core/llm-utils.js +368 -0
- package/dist/core/llm-utils.js.map +1 -0
- package/dist/core/logger.d.ts +92 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +221 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/process-manager.d.ts +15 -0
- package/dist/core/process-manager.d.ts.map +1 -0
- package/dist/core/process-manager.js +132 -0
- package/dist/core/process-manager.js.map +1 -0
- package/dist/core/story-logger.d.ts +102 -0
- package/dist/core/story-logger.d.ts.map +1 -0
- package/dist/core/story-logger.js +265 -0
- package/dist/core/story-logger.js.map +1 -0
- package/dist/core/story.d.ts +113 -20
- package/dist/core/story.d.ts.map +1 -1
- package/dist/core/story.js +328 -40
- package/dist/core/story.js.map +1 -1
- package/dist/core/task-parser.d.ts +59 -0
- package/dist/core/task-parser.d.ts.map +1 -0
- package/dist/core/task-parser.js +235 -0
- package/dist/core/task-parser.js.map +1 -0
- package/dist/core/task-progress.d.ts +92 -0
- package/dist/core/task-progress.d.ts.map +1 -0
- package/dist/core/task-progress.js +280 -0
- package/dist/core/task-progress.js.map +1 -0
- package/dist/core/workflow-state.d.ts +45 -6
- package/dist/core/workflow-state.d.ts.map +1 -1
- package/dist/core/workflow-state.js +201 -12
- package/dist/core/workflow-state.js.map +1 -1
- package/dist/core/worktree.d.ts +186 -0
- package/dist/core/worktree.d.ts.map +1 -0
- package/dist/core/worktree.js +554 -0
- package/dist/core/worktree.js.map +1 -0
- package/dist/index.js +146 -5
- package/dist/index.js.map +1 -1
- package/dist/services/error-classifier.d.ts +119 -0
- package/dist/services/error-classifier.d.ts.map +1 -0
- package/dist/services/error-classifier.js +182 -0
- package/dist/services/error-classifier.js.map +1 -0
- package/dist/types/index.d.ts +381 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/package.json +5 -2
- package/templates/story.md +5 -0
package/dist/cli/commands.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import ora from 'ora';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import * as readline from 'readline';
|
|
5
|
+
import { spawnSync } from 'child_process';
|
|
6
|
+
import { getSdlcRoot, loadConfig, initConfig, validateWorktreeBasePath, DEFAULT_WORKTREE_CONFIG } from '../core/config.js';
|
|
7
|
+
import { initializeKanban, kanbanExists, assessState, getBoardStats, findStoryBySlug, findStoriesByStatus } from '../core/kanban.js';
|
|
8
|
+
import { createStory, parseStory, resetRPIVCycle, isAtMaxRetries, unblockStory, getStory, findStoryById, updateStoryField, writeStory, sanitizeStoryId, autoCompleteStoryAfterReview, incrementImplementationRetryCount, getEffectiveMaxImplementationRetries, isAtMaxImplementationRetries, updateStoryStatus } from '../core/story.js';
|
|
9
|
+
import { GitWorktreeService, getLastCompletedPhase, getNextPhase } from '../core/worktree.js';
|
|
7
10
|
import { ReviewDecision } from '../types/index.js';
|
|
8
11
|
import { getThemedChalk } from '../core/theme.js';
|
|
9
12
|
import { saveWorkflowState, loadWorkflowState, clearWorkflowState, generateWorkflowId, calculateStoryHash, hasWorkflowState, } from '../core/workflow-state.js';
|
|
@@ -12,6 +15,17 @@ import { getStoryFlags as getStoryFlagsUtil, formatStatus as formatStatusUtil }
|
|
|
12
15
|
import { migrateToFolderPerStory } from './commands/migrate.js';
|
|
13
16
|
import { generateReviewSummary } from '../agents/review.js';
|
|
14
17
|
import { getTerminalWidth } from './formatting.js';
|
|
18
|
+
import { validateGitState } from '../core/git-utils.js';
|
|
19
|
+
import { StoryLogger } from '../core/story-logger.js';
|
|
20
|
+
import { detectConflicts } from '../core/conflict-detector.js';
|
|
21
|
+
import { getLogger } from '../core/logger.js';
|
|
22
|
+
/**
|
|
23
|
+
* Branch divergence threshold for warnings
|
|
24
|
+
* When a worktree branch has diverged by more than this number of commits
|
|
25
|
+
* from the base branch (ahead or behind), a warning will be displayed
|
|
26
|
+
* suggesting the user rebase to sync with latest changes.
|
|
27
|
+
*/
|
|
28
|
+
const DIVERGENCE_WARNING_THRESHOLD = 10;
|
|
15
29
|
/**
|
|
16
30
|
* Initialize the .ai-sdlc folder structure
|
|
17
31
|
*/
|
|
@@ -49,7 +63,7 @@ export async function status(options) {
|
|
|
49
63
|
console.log(c.warning('ai-sdlc not initialized. Run `ai-sdlc init` first.'));
|
|
50
64
|
return;
|
|
51
65
|
}
|
|
52
|
-
const assessment = assessState(sdlcRoot);
|
|
66
|
+
const assessment = await assessState(sdlcRoot);
|
|
53
67
|
const stats = getBoardStats(sdlcRoot);
|
|
54
68
|
console.log();
|
|
55
69
|
console.log(c.bold('═══ AI SDLC Board ═══'));
|
|
@@ -114,10 +128,58 @@ export async function status(options) {
|
|
|
114
128
|
console.log(c.success('No pending actions. Board is up to date!'));
|
|
115
129
|
}
|
|
116
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Validate file path for security (path traversal, symlinks, allowed directories)
|
|
133
|
+
*/
|
|
134
|
+
function validateFilePath(filePath) {
|
|
135
|
+
const resolvedPath = path.resolve(filePath);
|
|
136
|
+
const allowedDir = path.resolve(process.cwd());
|
|
137
|
+
// Check path traversal: resolved path must be within current directory
|
|
138
|
+
if (!resolvedPath.startsWith(allowedDir + path.sep) && resolvedPath !== allowedDir) {
|
|
139
|
+
throw new Error('Security: File path must be within current directory (path traversal detected)');
|
|
140
|
+
}
|
|
141
|
+
// Check if file exists before checking if it's a symlink
|
|
142
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
143
|
+
throw new Error(`File not found: ${path.basename(filePath)}`);
|
|
144
|
+
}
|
|
145
|
+
// Check for symbolic links (security risk)
|
|
146
|
+
const stats = fs.lstatSync(resolvedPath);
|
|
147
|
+
if (stats.isSymbolicLink()) {
|
|
148
|
+
throw new Error('Security: Symbolic links are not allowed');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Validate file extension against whitelist
|
|
153
|
+
*/
|
|
154
|
+
function validateFileExtension(filePath) {
|
|
155
|
+
const allowedExtensions = ['.md', '.txt', '.markdown'];
|
|
156
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
157
|
+
if (!allowedExtensions.includes(ext)) {
|
|
158
|
+
throw new Error(`Invalid file type: only ${allowedExtensions.join(', ')} files are allowed`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Validate file size (10MB maximum)
|
|
163
|
+
*/
|
|
164
|
+
function validateFileSize(filePath) {
|
|
165
|
+
const maxSize = 10 * 1024 * 1024; // 10MB
|
|
166
|
+
const stats = fs.statSync(filePath);
|
|
167
|
+
if (stats.size > maxSize) {
|
|
168
|
+
const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
|
|
169
|
+
throw new Error(`File too large: ${sizeMB}MB (maximum 10MB)`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Sanitize file content (strip null bytes, validate UTF-8)
|
|
174
|
+
*/
|
|
175
|
+
function sanitizeFileContent(content) {
|
|
176
|
+
// Strip null bytes that could truncate strings
|
|
177
|
+
return content.replace(/\0/g, '');
|
|
178
|
+
}
|
|
117
179
|
/**
|
|
118
180
|
* Add a new story to the backlog
|
|
119
181
|
*/
|
|
120
|
-
export async function add(title) {
|
|
182
|
+
export async function add(title, options) {
|
|
121
183
|
const spinner = ora('Creating story...').start();
|
|
122
184
|
try {
|
|
123
185
|
const config = loadConfig();
|
|
@@ -127,10 +189,83 @@ export async function add(title) {
|
|
|
127
189
|
spinner.fail('ai-sdlc not initialized. Run `ai-sdlc init` first.');
|
|
128
190
|
return;
|
|
129
191
|
}
|
|
130
|
-
|
|
192
|
+
// Validate that either title or file is provided (not both, not neither)
|
|
193
|
+
if (!title && !options?.file) {
|
|
194
|
+
spinner.fail('Error: Must provide either a title or --file option');
|
|
195
|
+
console.log(c.dim('Usage:'));
|
|
196
|
+
console.log(c.dim(' ai-sdlc add "Story Title"'));
|
|
197
|
+
console.log(c.dim(' ai-sdlc add --file story.md'));
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
if (title && options?.file) {
|
|
201
|
+
spinner.fail('Error: Cannot provide both title and --file option');
|
|
202
|
+
console.log(c.dim('Use either:'));
|
|
203
|
+
console.log(c.dim(' ai-sdlc add "Story Title"'));
|
|
204
|
+
console.log(c.dim(' ai-sdlc add --file story.md'));
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
let storyTitle;
|
|
208
|
+
let storyContent;
|
|
209
|
+
// Handle file input with security validation
|
|
210
|
+
if (options?.file) {
|
|
211
|
+
spinner.text = 'Reading file...';
|
|
212
|
+
const filePath = options.file;
|
|
213
|
+
try {
|
|
214
|
+
// Security validations
|
|
215
|
+
validateFilePath(filePath);
|
|
216
|
+
validateFileExtension(filePath);
|
|
217
|
+
// Read file (includes existence check via fs.readFileSync)
|
|
218
|
+
const resolvedPath = path.resolve(filePath);
|
|
219
|
+
// Validate file size before reading
|
|
220
|
+
validateFileSize(resolvedPath);
|
|
221
|
+
// Read and sanitize content
|
|
222
|
+
const rawContent = fs.readFileSync(resolvedPath, 'utf-8');
|
|
223
|
+
storyContent = sanitizeFileContent(rawContent);
|
|
224
|
+
// Extract title from content or use filename
|
|
225
|
+
const { extractTitleFromContent } = await import('../core/story.js');
|
|
226
|
+
const extractedTitle = extractTitleFromContent(storyContent);
|
|
227
|
+
if (extractedTitle) {
|
|
228
|
+
storyTitle = extractedTitle;
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
// Fall back to filename without extension
|
|
232
|
+
storyTitle = path.basename(filePath, path.extname(filePath));
|
|
233
|
+
}
|
|
234
|
+
spinner.text = `Creating story from ${path.basename(filePath)}...`;
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
spinner.fail('Failed to read file');
|
|
238
|
+
if (error instanceof Error) {
|
|
239
|
+
// Sanitize error messages to avoid leaking system paths
|
|
240
|
+
if (error.message.startsWith('Security:') || error.message.startsWith('Invalid file type:') || error.message.startsWith('File too large:')) {
|
|
241
|
+
console.log(c.error(error.message));
|
|
242
|
+
}
|
|
243
|
+
else if (error.message.includes('ENOENT')) {
|
|
244
|
+
console.log(c.error(`File not found: ${path.basename(filePath)}`));
|
|
245
|
+
}
|
|
246
|
+
else if (error.message.includes('EACCES') || error.message.includes('EPERM')) {
|
|
247
|
+
console.log(c.error(`Permission denied: ${path.basename(filePath)}`));
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
console.log(c.error(`Unable to read file: ${path.basename(filePath)}`));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
// Traditional title-only input
|
|
258
|
+
storyTitle = title;
|
|
259
|
+
}
|
|
260
|
+
// Create the story
|
|
261
|
+
const story = await createStory(storyTitle, sdlcRoot, {}, storyContent);
|
|
131
262
|
spinner.succeed(c.success(`Created: ${story.path}`));
|
|
132
263
|
console.log(c.dim(` ID: ${story.frontmatter.id}`));
|
|
264
|
+
console.log(c.dim(` Title: ${story.frontmatter.title}`));
|
|
133
265
|
console.log(c.dim(` Slug: ${story.slug}`));
|
|
266
|
+
if (options?.file) {
|
|
267
|
+
console.log(c.dim(` Source: ${path.basename(options.file)}`));
|
|
268
|
+
}
|
|
134
269
|
console.log();
|
|
135
270
|
console.log(c.info('Next step:'), `ai-sdlc run`);
|
|
136
271
|
}
|
|
@@ -152,6 +287,35 @@ function validateAutoStoryOptions(options) {
|
|
|
152
287
|
' - ai-sdlc run --story <id> --step <phase> (single phase)');
|
|
153
288
|
}
|
|
154
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Validates flag combinations for --batch conflicts
|
|
292
|
+
* @throws Error if conflicting flags are detected
|
|
293
|
+
*/
|
|
294
|
+
function validateBatchOptions(options) {
|
|
295
|
+
if (!options.batch) {
|
|
296
|
+
return; // No batch flag, nothing to validate
|
|
297
|
+
}
|
|
298
|
+
// --batch and --story are mutually exclusive
|
|
299
|
+
if (options.story) {
|
|
300
|
+
throw new Error('Cannot combine --batch with --story flag.\n' +
|
|
301
|
+
'Use either:\n' +
|
|
302
|
+
' - ai-sdlc run --batch S-001,S-002,S-003 (batch processing)\n' +
|
|
303
|
+
' - ai-sdlc run --auto --story <id> (single story)');
|
|
304
|
+
}
|
|
305
|
+
// --batch and --watch are mutually exclusive
|
|
306
|
+
if (options.watch) {
|
|
307
|
+
throw new Error('Cannot combine --batch with --watch flag.\n' +
|
|
308
|
+
'Use either:\n' +
|
|
309
|
+
' - ai-sdlc run --batch S-001,S-002,S-003 (batch processing)\n' +
|
|
310
|
+
' - ai-sdlc run --watch (daemon mode)');
|
|
311
|
+
}
|
|
312
|
+
// --batch and --continue are mutually exclusive
|
|
313
|
+
if (options.continue) {
|
|
314
|
+
throw new Error('Cannot combine --batch with --continue flag.\n' +
|
|
315
|
+
'Batch mode does not support resuming from checkpoints.\n' +
|
|
316
|
+
'Use: ai-sdlc run --batch S-001,S-002,S-003');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
155
319
|
/**
|
|
156
320
|
* Determines if a specific phase should be executed based on story state
|
|
157
321
|
* @param story The story to check
|
|
@@ -205,6 +369,396 @@ function generateFullSDLCActions(story, c) {
|
|
|
205
369
|
}
|
|
206
370
|
return actions;
|
|
207
371
|
}
|
|
372
|
+
/**
|
|
373
|
+
* Actions that modify git and require validation
|
|
374
|
+
*/
|
|
375
|
+
const GIT_MODIFYING_ACTIONS = ['implement', 'review', 'create_pr'];
|
|
376
|
+
/**
|
|
377
|
+
* Check if any actions in the list require git validation
|
|
378
|
+
*/
|
|
379
|
+
function requiresGitValidation(actions) {
|
|
380
|
+
return actions.some(action => GIT_MODIFYING_ACTIONS.includes(action.type));
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Determine if worktree mode should be used based on CLI flags, story frontmatter, and config.
|
|
384
|
+
* Priority order:
|
|
385
|
+
* 1. CLI --no-worktree flag (explicit disable)
|
|
386
|
+
* 2. CLI --worktree flag (explicit enable)
|
|
387
|
+
* 3. Story frontmatter.worktree_path exists (auto-enable for resuming)
|
|
388
|
+
* 4. Config worktree.enabled (default behavior)
|
|
389
|
+
*/
|
|
390
|
+
export function determineWorktreeMode(options, worktreeConfig, targetStory) {
|
|
391
|
+
if (options.worktree === false)
|
|
392
|
+
return false;
|
|
393
|
+
if (options.worktree === true)
|
|
394
|
+
return true;
|
|
395
|
+
if (targetStory?.frontmatter.worktree_path)
|
|
396
|
+
return true;
|
|
397
|
+
return worktreeConfig.enabled;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Display git validation errors and warnings
|
|
401
|
+
*/
|
|
402
|
+
function displayGitValidationResult(result, c) {
|
|
403
|
+
if (result.errors.length > 0) {
|
|
404
|
+
console.log();
|
|
405
|
+
console.log(c.error('Git validation failed:'));
|
|
406
|
+
for (const error of result.errors) {
|
|
407
|
+
console.log(c.error(` - ${error}`));
|
|
408
|
+
}
|
|
409
|
+
console.log();
|
|
410
|
+
console.log(c.info('To override this check, use --force (at your own risk)'));
|
|
411
|
+
}
|
|
412
|
+
if (result.warnings.length > 0) {
|
|
413
|
+
console.log();
|
|
414
|
+
console.log(c.warning('Git validation warnings:'));
|
|
415
|
+
for (const warning of result.warnings) {
|
|
416
|
+
console.log(c.warning(` - ${warning}`));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Display detailed information about an existing worktree
|
|
422
|
+
*/
|
|
423
|
+
function displayExistingWorktreeInfo(status, c) {
|
|
424
|
+
console.log();
|
|
425
|
+
console.log(c.warning('A worktree already exists for this story:'));
|
|
426
|
+
console.log();
|
|
427
|
+
console.log(c.bold(' Worktree Path:'), status.path);
|
|
428
|
+
console.log(c.bold(' Branch: '), status.branch);
|
|
429
|
+
if (status.lastCommit) {
|
|
430
|
+
console.log(c.bold(' Last Commit: '), `${status.lastCommit.hash.substring(0, 7)} - ${status.lastCommit.message}`);
|
|
431
|
+
console.log(c.bold(' Committed: '), status.lastCommit.timestamp);
|
|
432
|
+
}
|
|
433
|
+
const statusLabel = status.workingDirectoryStatus === 'clean'
|
|
434
|
+
? c.success('clean')
|
|
435
|
+
: c.warning(status.workingDirectoryStatus);
|
|
436
|
+
console.log(c.bold(' Working Dir: '), statusLabel);
|
|
437
|
+
if (status.modifiedFiles.length > 0) {
|
|
438
|
+
console.log();
|
|
439
|
+
console.log(c.warning(' Modified files:'));
|
|
440
|
+
for (const file of status.modifiedFiles.slice(0, 5)) {
|
|
441
|
+
console.log(c.dim(` M ${file}`));
|
|
442
|
+
}
|
|
443
|
+
if (status.modifiedFiles.length > 5) {
|
|
444
|
+
console.log(c.dim(` ... and ${status.modifiedFiles.length - 5} more`));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (status.untrackedFiles.length > 0) {
|
|
448
|
+
console.log();
|
|
449
|
+
console.log(c.warning(' Untracked files:'));
|
|
450
|
+
for (const file of status.untrackedFiles.slice(0, 5)) {
|
|
451
|
+
console.log(c.dim(` ? ${file}`));
|
|
452
|
+
}
|
|
453
|
+
if (status.untrackedFiles.length > 5) {
|
|
454
|
+
console.log(c.dim(` ... and ${status.untrackedFiles.length - 5} more`));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
console.log();
|
|
458
|
+
console.log(c.info('To resume work in this worktree:'));
|
|
459
|
+
console.log(c.dim(` cd ${status.path}`));
|
|
460
|
+
console.log();
|
|
461
|
+
console.log(c.info('To remove the worktree and start fresh:'));
|
|
462
|
+
console.log(c.dim(` ai-sdlc worktrees remove ${status.storyId}`));
|
|
463
|
+
console.log();
|
|
464
|
+
}
|
|
465
|
+
// ANSI escape sequence patterns for sanitization
|
|
466
|
+
const ANSI_CSI_PATTERN = /\x1B\[[0-9;]*[a-zA-Z]/g;
|
|
467
|
+
const ANSI_OSC_BEL_PATTERN = /\x1B\][^\x07]*\x07/g;
|
|
468
|
+
const ANSI_OSC_ESC_PATTERN = /\x1B\][^\x1B]*\x1B\\/g;
|
|
469
|
+
const ANSI_SINGLE_CHAR_PATTERN = /\x1B./g;
|
|
470
|
+
const CONTROL_CHARS_PATTERN = /[\x00-\x1F\x7F-\x9F]/g;
|
|
471
|
+
/**
|
|
472
|
+
* Sanitize a string for safe display in the terminal.
|
|
473
|
+
* Strips ANSI escape sequences (CSI, OSC, single-char), control characters,
|
|
474
|
+
* and truncates extremely long strings to prevent DoS attacks.
|
|
475
|
+
*
|
|
476
|
+
* This uses the same comprehensive ANSI stripping patterns as sanitizeReasonText
|
|
477
|
+
* from src/core/story.ts for consistency.
|
|
478
|
+
*
|
|
479
|
+
* @param str - The string to sanitize
|
|
480
|
+
* @returns Sanitized string safe for terminal display (max 500 chars)
|
|
481
|
+
*/
|
|
482
|
+
function sanitizeForDisplay(str) {
|
|
483
|
+
const cleaned = str
|
|
484
|
+
.replace(ANSI_CSI_PATTERN, '') // CSI sequences (e.g., \x1B[31m)
|
|
485
|
+
.replace(ANSI_OSC_BEL_PATTERN, '') // OSC with BEL terminator (e.g., \x1B]...\x07)
|
|
486
|
+
.replace(ANSI_OSC_ESC_PATTERN, '') // OSC with ESC\ terminator (e.g., \x1B]...\x1B\\)
|
|
487
|
+
.replace(ANSI_SINGLE_CHAR_PATTERN, '') // Single-char escapes (e.g., \x1BH)
|
|
488
|
+
.replace(CONTROL_CHARS_PATTERN, ''); // Control characters (0x00-0x1F, 0x7F-0x9F)
|
|
489
|
+
// Truncate extremely long strings (DoS protection)
|
|
490
|
+
return cleaned.length > 500 ? cleaned.slice(0, 497) + '...' : cleaned;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Perform pre-flight conflict check before starting work on a story in a worktree.
|
|
494
|
+
* Warns about potential file conflicts with active stories and prompts for confirmation.
|
|
495
|
+
*
|
|
496
|
+
* **Race Condition (TOCTOU):** Multiple users can pass this check simultaneously
|
|
497
|
+
* before branches are created. This is an accepted risk - the window is small
|
|
498
|
+
* (~100ms) and git will catch conflicts during merge/PR creation. Adding file
|
|
499
|
+
* locks would significantly increase complexity for minimal security gain.
|
|
500
|
+
*
|
|
501
|
+
* **Security Notes:**
|
|
502
|
+
* - sdlcRoot is normalized and validated (absolute path, no null bytes, max 1024 chars)
|
|
503
|
+
* - All display output is sanitized to prevent terminal injection attacks
|
|
504
|
+
* - Story IDs are validated with sanitizeStoryId() then stripped with sanitizeForDisplay()
|
|
505
|
+
* - Error messages are generic to prevent information leakage
|
|
506
|
+
*
|
|
507
|
+
* @param targetStory - The story to check for conflicts
|
|
508
|
+
* @param sdlcRoot - Root directory of the .ai-sdlc folder (must be absolute, validated)
|
|
509
|
+
* @param options - Command options (force flag)
|
|
510
|
+
* @param options.force - Skip conflict check if true
|
|
511
|
+
* @returns PreFlightResult indicating whether to proceed and any warnings
|
|
512
|
+
* @throws Error if sdlcRoot is invalid (not absolute, null bytes, too long)
|
|
513
|
+
*/
|
|
514
|
+
export async function preFlightConflictCheck(targetStory, sdlcRoot, options) {
|
|
515
|
+
const config = loadConfig();
|
|
516
|
+
const c = getThemedChalk(config);
|
|
517
|
+
// Skip if --force flag
|
|
518
|
+
if (options.force) {
|
|
519
|
+
console.log(c.warning('⚠️ Skipping conflict check (--force)'));
|
|
520
|
+
return { proceed: true, warnings: ['Conflict check skipped'] };
|
|
521
|
+
}
|
|
522
|
+
// Validate sdlcRoot parameter (normalize first to prevent bypass attacks)
|
|
523
|
+
const normalizedPath = path.normalize(sdlcRoot);
|
|
524
|
+
if (!path.isAbsolute(normalizedPath)) {
|
|
525
|
+
throw new Error('Invalid project path');
|
|
526
|
+
}
|
|
527
|
+
if (normalizedPath.includes('\0')) {
|
|
528
|
+
throw new Error('Invalid project path');
|
|
529
|
+
}
|
|
530
|
+
if (normalizedPath.length > 1024) {
|
|
531
|
+
throw new Error('Invalid project path');
|
|
532
|
+
}
|
|
533
|
+
// Check if target story is already in-progress (allow if resuming existing worktree)
|
|
534
|
+
if (targetStory.frontmatter.status === 'in-progress' && !targetStory.frontmatter.worktree_path) {
|
|
535
|
+
console.log(c.error('❌ Story is already in-progress'));
|
|
536
|
+
return { proceed: false, warnings: ['Story already in progress'] };
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
// Query for all in-progress stories (excluding target)
|
|
540
|
+
// Use normalizedPath for all subsequent operations
|
|
541
|
+
const activeStories = findStoriesByStatus(normalizedPath, 'in-progress')
|
|
542
|
+
.filter(s => s.frontmatter.id !== targetStory.frontmatter.id);
|
|
543
|
+
if (activeStories.length === 0) {
|
|
544
|
+
console.log(c.success('✓ Conflict check: No overlapping files with active stories'));
|
|
545
|
+
return { proceed: true, warnings: [] };
|
|
546
|
+
}
|
|
547
|
+
// Run conflict detection (use normalizedPath)
|
|
548
|
+
const workingDir = path.dirname(normalizedPath);
|
|
549
|
+
const result = detectConflicts([targetStory, ...activeStories], workingDir, 'main');
|
|
550
|
+
// Filter conflicts involving target story
|
|
551
|
+
const relevantConflicts = result.conflicts.filter(conflict => conflict.storyA === targetStory.frontmatter.id || conflict.storyB === targetStory.frontmatter.id);
|
|
552
|
+
// Filter out 'none' severity conflicts (keep all displayable conflicts including low)
|
|
553
|
+
const displayableConflicts = relevantConflicts.filter(conflict => conflict.severity !== 'none');
|
|
554
|
+
if (displayableConflicts.length === 0) {
|
|
555
|
+
console.log(c.success('✓ Conflict check: No overlapping files with active stories'));
|
|
556
|
+
return { proceed: true, warnings: [] };
|
|
557
|
+
}
|
|
558
|
+
// Sort conflicts by severity (high -> medium -> low)
|
|
559
|
+
const severityOrder = { high: 0, medium: 1, low: 2, none: 3 };
|
|
560
|
+
const sortedConflicts = displayableConflicts.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
|
561
|
+
// Display conflicts
|
|
562
|
+
console.log();
|
|
563
|
+
console.log(c.warning('⚠️ Potential conflicts detected:'));
|
|
564
|
+
console.log();
|
|
565
|
+
for (const conflict of sortedConflicts) {
|
|
566
|
+
const otherStoryId = conflict.storyA === targetStory.frontmatter.id ? conflict.storyB : conflict.storyA;
|
|
567
|
+
// Two-stage sanitization: validate structure, then strip for display
|
|
568
|
+
try {
|
|
569
|
+
const validatedTargetId = sanitizeStoryId(targetStory.frontmatter.id);
|
|
570
|
+
const validatedOtherId = sanitizeStoryId(otherStoryId);
|
|
571
|
+
const sanitizedTargetId = sanitizeForDisplay(validatedTargetId);
|
|
572
|
+
const sanitizedOtherId = sanitizeForDisplay(validatedOtherId);
|
|
573
|
+
console.log(c.warning(` ${sanitizedTargetId} may conflict with ${sanitizedOtherId}:`));
|
|
574
|
+
}
|
|
575
|
+
catch (error) {
|
|
576
|
+
// If validation fails, show generic error (defensive)
|
|
577
|
+
console.log(c.warning(` Story may have conflicting changes (invalid ID format)`));
|
|
578
|
+
}
|
|
579
|
+
// Display shared files
|
|
580
|
+
for (const file of conflict.sharedFiles) {
|
|
581
|
+
const severityLabel = conflict.severity === 'high' ? c.error('High') :
|
|
582
|
+
conflict.severity === 'medium' ? c.warning('Medium') :
|
|
583
|
+
c.info('Low');
|
|
584
|
+
const sanitizedFile = sanitizeForDisplay(file);
|
|
585
|
+
console.log(` - ${severityLabel}: ${sanitizedFile} (both stories modify this file)`);
|
|
586
|
+
}
|
|
587
|
+
// Display shared directories
|
|
588
|
+
for (const dir of conflict.sharedDirectories) {
|
|
589
|
+
const severityLabel = conflict.severity === 'high' ? c.error('High') :
|
|
590
|
+
conflict.severity === 'medium' ? c.warning('Medium') :
|
|
591
|
+
c.info('Low');
|
|
592
|
+
const sanitizedDir = sanitizeForDisplay(dir);
|
|
593
|
+
console.log(` - ${severityLabel}: ${sanitizedDir} (both stories modify files in this directory)`);
|
|
594
|
+
}
|
|
595
|
+
console.log();
|
|
596
|
+
const sanitizedRecommendation = sanitizeForDisplay(conflict.recommendation);
|
|
597
|
+
console.log(c.dim(` Recommendation: ${sanitizedRecommendation}`));
|
|
598
|
+
console.log();
|
|
599
|
+
}
|
|
600
|
+
// Non-interactive mode: default to declining
|
|
601
|
+
if (!process.stdin.isTTY) {
|
|
602
|
+
console.log(c.dim('Non-interactive mode: conflicts require --force to proceed'));
|
|
603
|
+
return { proceed: false, warnings: ['Conflicts detected'] };
|
|
604
|
+
}
|
|
605
|
+
// Interactive mode: prompt user
|
|
606
|
+
const shouldContinue = await confirmRemoval('Continue anyway?');
|
|
607
|
+
return {
|
|
608
|
+
proceed: shouldContinue,
|
|
609
|
+
warnings: shouldContinue ? ['User confirmed with conflicts'] : ['Conflicts detected']
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
// Fail-open: allow proceeding if conflict detection fails
|
|
614
|
+
console.log(c.warning('⚠️ Conflict detection unavailable'));
|
|
615
|
+
console.log(c.dim('Proceeding without conflict check...'));
|
|
616
|
+
return { proceed: true, warnings: ['Conflict detection failed'] };
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Process multiple stories sequentially through full SDLC
|
|
621
|
+
* Internal function used by batch mode
|
|
622
|
+
*/
|
|
623
|
+
async function processBatchInternal(storyIds, sdlcRoot, options) {
|
|
624
|
+
const startTime = Date.now();
|
|
625
|
+
const config = loadConfig();
|
|
626
|
+
const c = getThemedChalk(config);
|
|
627
|
+
const { formatBatchProgress, formatBatchSummary, logStoryCompletion, promptContinueOnError } = await import('./batch-processor.js');
|
|
628
|
+
const result = {
|
|
629
|
+
total: storyIds.length,
|
|
630
|
+
succeeded: 0,
|
|
631
|
+
failed: 0,
|
|
632
|
+
skipped: 0,
|
|
633
|
+
errors: [],
|
|
634
|
+
duration: 0,
|
|
635
|
+
};
|
|
636
|
+
console.log();
|
|
637
|
+
console.log(c.bold('═══ Starting Batch Processing ═══'));
|
|
638
|
+
console.log(c.dim(` Stories: ${storyIds.join(', ')}`));
|
|
639
|
+
console.log(c.dim(` Dry run: ${options.dryRun ? 'yes' : 'no'}`));
|
|
640
|
+
console.log();
|
|
641
|
+
// Process each story sequentially
|
|
642
|
+
for (let i = 0; i < storyIds.length; i++) {
|
|
643
|
+
const storyId = storyIds[i];
|
|
644
|
+
// Get story and check status
|
|
645
|
+
let story;
|
|
646
|
+
try {
|
|
647
|
+
story = getStory(sdlcRoot, storyId);
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
result.failed++;
|
|
651
|
+
result.errors.push({
|
|
652
|
+
storyId,
|
|
653
|
+
error: `Story not found: ${error instanceof Error ? error.message : String(error)}`,
|
|
654
|
+
});
|
|
655
|
+
console.log(c.error(`[${i + 1}/${storyIds.length}] ✗ Story not found: ${storyId}`));
|
|
656
|
+
console.log();
|
|
657
|
+
// Ask if user wants to continue (or abort in non-interactive)
|
|
658
|
+
const shouldContinue = await promptContinueOnError(storyId, c);
|
|
659
|
+
if (!shouldContinue) {
|
|
660
|
+
console.log(c.warning('Batch processing aborted.'));
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
// Skip if already done
|
|
666
|
+
if (story.frontmatter.status === 'done') {
|
|
667
|
+
result.skipped++;
|
|
668
|
+
console.log(c.dim(`[${i + 1}/${storyIds.length}] ⊘ Skipping ${storyId} (already completed)`));
|
|
669
|
+
console.log();
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
// Show progress header
|
|
673
|
+
const progress = {
|
|
674
|
+
currentIndex: i,
|
|
675
|
+
total: storyIds.length,
|
|
676
|
+
currentStory: story,
|
|
677
|
+
};
|
|
678
|
+
console.log(c.info(formatBatchProgress(progress)));
|
|
679
|
+
console.log();
|
|
680
|
+
// Dry-run mode: just show what would be done
|
|
681
|
+
if (options.dryRun) {
|
|
682
|
+
console.log(c.dim(' Would process story through full SDLC'));
|
|
683
|
+
console.log(c.dim(` Status: ${story.frontmatter.status}`));
|
|
684
|
+
console.log();
|
|
685
|
+
result.succeeded++;
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
// Process story through full SDLC by recursively calling run()
|
|
689
|
+
// We set auto: true to ensure full SDLC execution
|
|
690
|
+
try {
|
|
691
|
+
await run({
|
|
692
|
+
auto: true,
|
|
693
|
+
story: storyId,
|
|
694
|
+
dryRun: false,
|
|
695
|
+
worktree: options.worktree,
|
|
696
|
+
force: options.force,
|
|
697
|
+
});
|
|
698
|
+
// Check if story completed successfully (moved to done)
|
|
699
|
+
const finalStory = getStory(sdlcRoot, storyId);
|
|
700
|
+
if (finalStory.frontmatter.status === 'done') {
|
|
701
|
+
result.succeeded++;
|
|
702
|
+
logStoryCompletion(storyId, true, c);
|
|
703
|
+
}
|
|
704
|
+
else {
|
|
705
|
+
// Story didn't reach done state - treat as failure
|
|
706
|
+
result.failed++;
|
|
707
|
+
result.errors.push({
|
|
708
|
+
storyId,
|
|
709
|
+
error: `Story did not complete (status: ${finalStory.frontmatter.status})`,
|
|
710
|
+
});
|
|
711
|
+
logStoryCompletion(storyId, false, c);
|
|
712
|
+
// Ask if user wants to continue (or abort in non-interactive)
|
|
713
|
+
const shouldContinue = await promptContinueOnError(storyId, c);
|
|
714
|
+
if (!shouldContinue) {
|
|
715
|
+
console.log(c.warning('Batch processing aborted.'));
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
catch (error) {
|
|
721
|
+
result.failed++;
|
|
722
|
+
result.errors.push({
|
|
723
|
+
storyId,
|
|
724
|
+
error: error instanceof Error ? error.message : String(error),
|
|
725
|
+
});
|
|
726
|
+
logStoryCompletion(storyId, false, c);
|
|
727
|
+
// Ask if user wants to continue (or abort in non-interactive)
|
|
728
|
+
const shouldContinue = await promptContinueOnError(storyId, c);
|
|
729
|
+
if (!shouldContinue) {
|
|
730
|
+
console.log(c.warning('Batch processing aborted.'));
|
|
731
|
+
break;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
console.log();
|
|
735
|
+
}
|
|
736
|
+
// Display final summary
|
|
737
|
+
result.duration = Date.now() - startTime;
|
|
738
|
+
const summaryLines = formatBatchSummary(result);
|
|
739
|
+
summaryLines.forEach((line) => {
|
|
740
|
+
if (line.includes('✓')) {
|
|
741
|
+
console.log(c.success(line));
|
|
742
|
+
}
|
|
743
|
+
else if (line.includes('✗')) {
|
|
744
|
+
console.log(c.error(line));
|
|
745
|
+
}
|
|
746
|
+
else if (line.includes('⊘')) {
|
|
747
|
+
console.log(c.warning(line));
|
|
748
|
+
}
|
|
749
|
+
else if (line.startsWith(' -')) {
|
|
750
|
+
console.log(c.dim(line));
|
|
751
|
+
}
|
|
752
|
+
else {
|
|
753
|
+
console.log(line);
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
// Return non-zero exit code if any failures occurred
|
|
757
|
+
if (result.failed > 0) {
|
|
758
|
+
process.exitCode = 1;
|
|
759
|
+
}
|
|
760
|
+
return result;
|
|
761
|
+
}
|
|
208
762
|
/**
|
|
209
763
|
* Run the workflow (process one action or all)
|
|
210
764
|
*/
|
|
@@ -214,8 +768,29 @@ export async function run(options) {
|
|
|
214
768
|
const maxIterationsOverride = options.maxIterations !== undefined
|
|
215
769
|
? parseInt(options.maxIterations, 10)
|
|
216
770
|
: undefined;
|
|
217
|
-
|
|
771
|
+
let sdlcRoot = getSdlcRoot();
|
|
218
772
|
const c = getThemedChalk(config);
|
|
773
|
+
const logger = getLogger();
|
|
774
|
+
logger.debug('workflow', 'Run command initiated', {
|
|
775
|
+
auto: options.auto,
|
|
776
|
+
dryRun: options.dryRun,
|
|
777
|
+
continue: options.continue,
|
|
778
|
+
story: options.story,
|
|
779
|
+
step: options.step,
|
|
780
|
+
watch: options.watch,
|
|
781
|
+
worktree: options.worktree,
|
|
782
|
+
clean: options.clean,
|
|
783
|
+
force: options.force,
|
|
784
|
+
});
|
|
785
|
+
// Migrate global workflow state to story-specific location if needed
|
|
786
|
+
// Only run when NOT continuing (to avoid interrupting resumed workflows)
|
|
787
|
+
if (!options.continue) {
|
|
788
|
+
const { migrateGlobalWorkflowState } = await import('../core/workflow-state.js');
|
|
789
|
+
const migrationResult = await migrateGlobalWorkflowState(sdlcRoot);
|
|
790
|
+
if (migrationResult.migrated) {
|
|
791
|
+
console.log(c.info(migrationResult.message));
|
|
792
|
+
}
|
|
793
|
+
}
|
|
219
794
|
// Handle daemon/watch mode
|
|
220
795
|
if (options.watch) {
|
|
221
796
|
console.log(c.info('🚀 Starting daemon mode...'));
|
|
@@ -223,6 +798,51 @@ export async function run(options) {
|
|
|
223
798
|
await startDaemon({ maxIterations: maxIterationsOverride });
|
|
224
799
|
return; // Daemon runs indefinitely
|
|
225
800
|
}
|
|
801
|
+
// Handle batch mode
|
|
802
|
+
if (options.batch) {
|
|
803
|
+
// Validate batch options first
|
|
804
|
+
try {
|
|
805
|
+
validateBatchOptions(options);
|
|
806
|
+
}
|
|
807
|
+
catch (error) {
|
|
808
|
+
console.log(c.error(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
// Import batch validation modules
|
|
812
|
+
const { parseStoryIdList, deduplicateStoryIds, validateStoryIds } = await import('./batch-validator.js');
|
|
813
|
+
// Parse and validate story IDs
|
|
814
|
+
const rawStoryIds = parseStoryIdList(options.batch);
|
|
815
|
+
if (rawStoryIds.length === 0) {
|
|
816
|
+
console.log(c.error('Error: Empty batch - no story IDs provided'));
|
|
817
|
+
console.log(c.dim('Usage: ai-sdlc run --batch S-001,S-002,S-003'));
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
// Deduplicate story IDs
|
|
821
|
+
const storyIds = deduplicateStoryIds(rawStoryIds);
|
|
822
|
+
if (storyIds.length < rawStoryIds.length) {
|
|
823
|
+
const duplicateCount = rawStoryIds.length - storyIds.length;
|
|
824
|
+
console.log(c.dim(`Note: Removed ${duplicateCount} duplicate story ID(s)`));
|
|
825
|
+
}
|
|
826
|
+
// Validate all stories exist before processing
|
|
827
|
+
const validation = validateStoryIds(storyIds, sdlcRoot);
|
|
828
|
+
if (!validation.valid) {
|
|
829
|
+
console.log(c.error('Error: Batch validation failed'));
|
|
830
|
+
console.log();
|
|
831
|
+
for (const error of validation.errors) {
|
|
832
|
+
console.log(c.error(` - ${error.message}`));
|
|
833
|
+
}
|
|
834
|
+
console.log();
|
|
835
|
+
console.log(c.dim('Fix the errors above and try again.'));
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
// Process the batch using internal function
|
|
839
|
+
await processBatchInternal(storyIds, sdlcRoot, {
|
|
840
|
+
dryRun: options.dryRun,
|
|
841
|
+
worktree: options.worktree,
|
|
842
|
+
force: options.force,
|
|
843
|
+
});
|
|
844
|
+
return; // Batch processing complete
|
|
845
|
+
}
|
|
226
846
|
// Valid step names for --step option
|
|
227
847
|
const validSteps = ['refine', 'research', 'plan', 'implement', 'review'];
|
|
228
848
|
// Validate --step option early
|
|
@@ -253,8 +873,15 @@ export async function run(options) {
|
|
|
253
873
|
let completedActions = [];
|
|
254
874
|
let storyContentHash;
|
|
255
875
|
if (options.continue) {
|
|
256
|
-
//
|
|
257
|
-
|
|
876
|
+
// Determine storyId for loading state
|
|
877
|
+
// If --story is provided, use it; otherwise, try to infer from existing state
|
|
878
|
+
let resumeStoryId;
|
|
879
|
+
// First try: use --story flag if provided
|
|
880
|
+
if (options.story) {
|
|
881
|
+
resumeStoryId = options.story;
|
|
882
|
+
}
|
|
883
|
+
// Try to load existing state (with or without storyId)
|
|
884
|
+
const existingState = await loadWorkflowState(sdlcRoot, resumeStoryId);
|
|
258
885
|
if (!existingState) {
|
|
259
886
|
console.log(c.error('Error: No checkpoint found.'));
|
|
260
887
|
console.log(c.dim('Remove --continue flag to start a new workflow.'));
|
|
@@ -300,15 +927,26 @@ export async function run(options) {
|
|
|
300
927
|
console.log();
|
|
301
928
|
}
|
|
302
929
|
else {
|
|
930
|
+
// Early validation of story ID format before any operations that use it
|
|
931
|
+
// This prevents sanitizeStoryId from throwing before we can show a nice error
|
|
932
|
+
if (options.story && !/^[a-z0-9_-]+$/i.test(options.story.toLowerCase().trim())) {
|
|
933
|
+
console.log(c.error('Invalid story ID format. Only letters, numbers, hyphens, and underscores are allowed.'));
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
303
936
|
// Check if there's an existing state and suggest --continue
|
|
304
|
-
|
|
937
|
+
// Check both global and story-specific state
|
|
938
|
+
const hasGlobalState = hasWorkflowState(sdlcRoot);
|
|
939
|
+
const hasStoryState = options.story ? hasWorkflowState(sdlcRoot, options.story) : false;
|
|
940
|
+
if ((hasGlobalState || hasStoryState) && !options.dryRun) {
|
|
305
941
|
console.log(c.info('Note: Found previous checkpoint. Use --continue to resume.'));
|
|
306
942
|
console.log();
|
|
307
943
|
}
|
|
308
944
|
// Start new workflow
|
|
309
945
|
workflowId = generateWorkflowId();
|
|
310
946
|
}
|
|
311
|
-
let assessment = assessState(sdlcRoot);
|
|
947
|
+
let assessment = await assessState(sdlcRoot);
|
|
948
|
+
// Hoist targetStory to outer scope so it can be reused for worktree checks
|
|
949
|
+
let targetStory = null;
|
|
312
950
|
// Filter actions by story if --story flag is provided
|
|
313
951
|
if (options.story) {
|
|
314
952
|
const normalizedInput = options.story.toLowerCase().trim();
|
|
@@ -319,7 +957,7 @@ export async function run(options) {
|
|
|
319
957
|
return;
|
|
320
958
|
}
|
|
321
959
|
// Try to find story by ID first, then by slug (case-insensitive)
|
|
322
|
-
|
|
960
|
+
targetStory = findStoryById(sdlcRoot, normalizedInput);
|
|
323
961
|
if (!targetStory) {
|
|
324
962
|
targetStory = findStoryBySlug(sdlcRoot, normalizedInput);
|
|
325
963
|
}
|
|
@@ -391,7 +1029,8 @@ export async function run(options) {
|
|
|
391
1029
|
}
|
|
392
1030
|
// Clear state if workflow is complete
|
|
393
1031
|
if (options.continue || hasWorkflowState(sdlcRoot)) {
|
|
394
|
-
|
|
1032
|
+
// Using options.story - action not yet created in early exit path
|
|
1033
|
+
await clearWorkflowState(sdlcRoot, options.story);
|
|
395
1034
|
console.log(c.dim('Checkpoint cleared.'));
|
|
396
1035
|
}
|
|
397
1036
|
return;
|
|
@@ -432,163 +1071,702 @@ export async function run(options) {
|
|
|
432
1071
|
actionsToProcess = remainingActions;
|
|
433
1072
|
if (actionsToProcess.length === 0) {
|
|
434
1073
|
console.log(c.success('All actions from checkpoint already completed!'));
|
|
435
|
-
|
|
1074
|
+
// Using options.story - action not yet created in early exit path
|
|
1075
|
+
await clearWorkflowState(sdlcRoot, options.story);
|
|
436
1076
|
console.log(c.dim('Checkpoint cleared.'));
|
|
437
1077
|
return;
|
|
438
1078
|
}
|
|
439
1079
|
}
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
console.log();
|
|
457
|
-
console.log(c.error(`✗ Phase ${action.type} failed`));
|
|
458
|
-
console.log(c.dim(`Completed ${currentActionIndex} of ${totalActions} phases`));
|
|
459
|
-
console.log(c.info('Fix the error above and use --continue to resume.'));
|
|
1080
|
+
// Handle worktree creation based on flags, config, and story frontmatter
|
|
1081
|
+
// IMPORTANT: This must happen BEFORE git validation because:
|
|
1082
|
+
// 1. Worktree mode allows running from protected branches (main/master)
|
|
1083
|
+
// 2. The worktree will be created on a feature branch
|
|
1084
|
+
let worktreePath;
|
|
1085
|
+
let originalCwd;
|
|
1086
|
+
let worktreeCreated = false;
|
|
1087
|
+
// Determine if worktree should be used
|
|
1088
|
+
// Priority: CLI flags > story frontmatter > config > default (disabled)
|
|
1089
|
+
const worktreeConfig = config.worktree ?? DEFAULT_WORKTREE_CONFIG;
|
|
1090
|
+
// Reuse targetStory from earlier lookup (DRY - avoids duplicate story lookup)
|
|
1091
|
+
const shouldUseWorktree = determineWorktreeMode(options, worktreeConfig, targetStory);
|
|
1092
|
+
// Validate that worktree mode requires --story
|
|
1093
|
+
if (shouldUseWorktree && !options.story) {
|
|
1094
|
+
if (options.worktree === true) {
|
|
1095
|
+
console.log(c.error('Error: --worktree requires --story flag'));
|
|
460
1096
|
return;
|
|
461
1097
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
1098
|
+
}
|
|
1099
|
+
if (shouldUseWorktree && options.story && targetStory) {
|
|
1100
|
+
// PRE-FLIGHT CHECK: Run conflict detection before creating worktree
|
|
1101
|
+
const preFlightResult = await preFlightConflictCheck(targetStory, sdlcRoot, options);
|
|
1102
|
+
if (!preFlightResult.proceed) {
|
|
1103
|
+
console.log(c.error('❌ Aborting. Complete active stories first or use --force.'));
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
// Log warnings if user proceeded despite conflicts (skip internal flag messages)
|
|
1107
|
+
if (preFlightResult.warnings.length > 0 && !preFlightResult.warnings.includes('Conflict check skipped')) {
|
|
1108
|
+
preFlightResult.warnings.forEach(w => console.log(c.dim(` ⚠ ${w}`)));
|
|
1109
|
+
console.log();
|
|
1110
|
+
}
|
|
1111
|
+
const workingDir = path.dirname(sdlcRoot);
|
|
1112
|
+
// Check if story already has an existing worktree (resume scenario)
|
|
1113
|
+
// Note: We check only if existingWorktreePath is set, not if it exists.
|
|
1114
|
+
// The validation logic will handle missing directories/branches.
|
|
1115
|
+
const existingWorktreePath = targetStory.frontmatter.worktree_path;
|
|
1116
|
+
if (existingWorktreePath) {
|
|
1117
|
+
// Validate worktree before resuming
|
|
1118
|
+
const resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
|
|
1119
|
+
// Security validation: ensure worktree_path is within the configured base directory
|
|
1120
|
+
const absoluteWorktreePath = path.resolve(existingWorktreePath);
|
|
1121
|
+
const absoluteBasePath = path.resolve(resolvedBasePath);
|
|
1122
|
+
if (!absoluteWorktreePath.startsWith(absoluteBasePath)) {
|
|
1123
|
+
console.log(c.error('Security Error: worktree_path is outside configured base directory'));
|
|
1124
|
+
console.log(c.dim(` Worktree path: ${absoluteWorktreePath}`));
|
|
1125
|
+
console.log(c.dim(` Expected base: ${absoluteBasePath}`));
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
// Warn if story is marked as done but has an existing worktree
|
|
1129
|
+
if (targetStory.frontmatter.status === 'done') {
|
|
1130
|
+
console.log(c.warning('⚠ Story is marked as done but has an existing worktree'));
|
|
1131
|
+
console.log(c.dim(' This may be a stale worktree that should be cleaned up.'));
|
|
1132
|
+
console.log();
|
|
1133
|
+
// Prompt user for confirmation to proceed
|
|
1134
|
+
const rl = readline.createInterface({
|
|
1135
|
+
input: process.stdin,
|
|
1136
|
+
output: process.stdout,
|
|
1137
|
+
});
|
|
1138
|
+
const answer = await new Promise((resolve) => {
|
|
1139
|
+
rl.question(c.dim('Continue with this worktree? (y/N): '), (ans) => {
|
|
1140
|
+
rl.close();
|
|
1141
|
+
resolve(ans.toLowerCase().trim());
|
|
1142
|
+
});
|
|
1143
|
+
});
|
|
1144
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
1145
|
+
console.log(c.dim('Aborted. Consider removing the worktree_path from the story frontmatter.'));
|
|
482
1146
|
return;
|
|
483
1147
|
}
|
|
484
|
-
// We can retry - reset RPIV cycle and loop back
|
|
485
|
-
const currentRetry = (story.frontmatter.retry_count || 0) + 1;
|
|
486
|
-
// Use CLI override, then story-specific, then config default
|
|
487
|
-
const effectiveMaxRetries = maxIterationsOverride !== undefined
|
|
488
|
-
? maxIterationsOverride
|
|
489
|
-
: (story.frontmatter.max_retries ?? config.reviewConfig?.maxRetries ?? Infinity);
|
|
490
|
-
const maxRetriesDisplay = Number.isFinite(effectiveMaxRetries) ? effectiveMaxRetries : '∞';
|
|
491
1148
|
console.log();
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
1149
|
+
}
|
|
1150
|
+
const worktreeService = new GitWorktreeService(workingDir, resolvedBasePath);
|
|
1151
|
+
const branchName = worktreeService.getBranchName(targetStory.frontmatter.id, targetStory.slug);
|
|
1152
|
+
const validation = worktreeService.validateWorktreeForResume(existingWorktreePath, branchName);
|
|
1153
|
+
if (!validation.canResume) {
|
|
1154
|
+
console.log(c.error('Cannot resume worktree:'));
|
|
1155
|
+
validation.issues.forEach(issue => console.log(c.dim(` ✗ ${issue}`)));
|
|
1156
|
+
if (validation.requiresRecreation) {
|
|
1157
|
+
const branchExists = !validation.issues.includes('Branch does not exist');
|
|
1158
|
+
const dirMissing = validation.issues.includes('Worktree directory does not exist');
|
|
1159
|
+
const dirExists = !dirMissing;
|
|
1160
|
+
// Case 1: Directory missing but branch exists - recreate worktree from existing branch
|
|
1161
|
+
// Case 2: Directory exists but branch missing - recreate with new branch
|
|
1162
|
+
if ((branchExists && dirMissing) || (!branchExists && dirExists)) {
|
|
1163
|
+
const reason = branchExists
|
|
1164
|
+
? 'Branch exists - automatically recreating worktree directory'
|
|
1165
|
+
: 'Directory exists - automatically recreating worktree with new branch';
|
|
1166
|
+
console.log(c.dim(`\n✓ ${reason}`));
|
|
1167
|
+
try {
|
|
1168
|
+
// Remove the old worktree reference if it exists
|
|
1169
|
+
const removeResult = spawnSync('git', ['worktree', 'remove', existingWorktreePath, '--force'], {
|
|
1170
|
+
cwd: workingDir,
|
|
1171
|
+
encoding: 'utf-8',
|
|
1172
|
+
shell: false,
|
|
1173
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1174
|
+
});
|
|
1175
|
+
// Create the worktree at the same path
|
|
1176
|
+
// If branch exists, checkout that branch; otherwise create a new branch
|
|
1177
|
+
const baseBranch = worktreeService.detectBaseBranch();
|
|
1178
|
+
const worktreeAddArgs = branchExists
|
|
1179
|
+
? ['worktree', 'add', existingWorktreePath, branchName]
|
|
1180
|
+
: ['worktree', 'add', '-b', branchName, existingWorktreePath, baseBranch];
|
|
1181
|
+
const addResult = spawnSync('git', worktreeAddArgs, {
|
|
1182
|
+
cwd: workingDir,
|
|
1183
|
+
encoding: 'utf-8',
|
|
1184
|
+
shell: false,
|
|
1185
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1186
|
+
});
|
|
1187
|
+
if (addResult.status !== 0) {
|
|
1188
|
+
throw new Error(`Failed to recreate worktree: ${addResult.stderr}`);
|
|
1189
|
+
}
|
|
1190
|
+
// Install dependencies in the recreated worktree
|
|
1191
|
+
worktreeService.installDependencies(existingWorktreePath);
|
|
1192
|
+
console.log(c.success(`✓ Worktree recreated at ${existingWorktreePath}`));
|
|
1193
|
+
getLogger().info('worktree', `Recreated worktree for ${targetStory.frontmatter.id} at ${existingWorktreePath}`);
|
|
1194
|
+
}
|
|
1195
|
+
catch (error) {
|
|
1196
|
+
console.log(c.error(`Failed to recreate worktree: ${error instanceof Error ? error.message : String(error)}`));
|
|
1197
|
+
console.log(c.dim('Please manually remove the worktree_path from the story frontmatter and try again.'));
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
else {
|
|
1202
|
+
console.log(c.dim('\nWorktree needs manual intervention. Please remove the worktree_path from the story frontmatter and try again.'));
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
513
1205
|
}
|
|
514
1206
|
else {
|
|
515
|
-
// No actions to retry (shouldn't happen but handle gracefully)
|
|
516
|
-
console.log(c.error('Error: No actions generated for retry. Manual intervention required.'));
|
|
517
1207
|
return;
|
|
518
1208
|
}
|
|
519
1209
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
story: options.story,
|
|
541
|
-
fullSDLC: isFullSDLC,
|
|
542
|
-
},
|
|
543
|
-
storyContentHash: calculateStoryHash(action.storyPath),
|
|
544
|
-
},
|
|
1210
|
+
// Reuse existing worktree
|
|
1211
|
+
originalCwd = process.cwd();
|
|
1212
|
+
worktreePath = existingWorktreePath;
|
|
1213
|
+
process.chdir(worktreePath);
|
|
1214
|
+
sdlcRoot = getSdlcRoot();
|
|
1215
|
+
worktreeCreated = true;
|
|
1216
|
+
// Re-load story from worktree context to get current state
|
|
1217
|
+
const worktreeStory = findStoryById(sdlcRoot, targetStory.frontmatter.id);
|
|
1218
|
+
if (worktreeStory) {
|
|
1219
|
+
targetStory = worktreeStory;
|
|
1220
|
+
}
|
|
1221
|
+
// Get phase information for resume context
|
|
1222
|
+
const lastPhase = getLastCompletedPhase(targetStory);
|
|
1223
|
+
const nextPhase = getNextPhase(targetStory);
|
|
1224
|
+
// Get worktree status for uncommitted changes info
|
|
1225
|
+
const worktreeInfo = {
|
|
1226
|
+
path: existingWorktreePath,
|
|
1227
|
+
branch: branchName,
|
|
1228
|
+
storyId: targetStory.frontmatter.id,
|
|
1229
|
+
exists: true,
|
|
545
1230
|
};
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
1231
|
+
const worktreeStatus = worktreeService.getWorktreeStatus(worktreeInfo);
|
|
1232
|
+
// Check branch divergence
|
|
1233
|
+
const divergence = worktreeService.checkBranchDivergence(branchName);
|
|
1234
|
+
console.log(c.success(`✓ Resuming in existing worktree: ${worktreePath}`));
|
|
1235
|
+
console.log(c.dim(` Branch: ${branchName}`));
|
|
1236
|
+
if (lastPhase) {
|
|
1237
|
+
console.log(c.dim(` Last completed phase: ${lastPhase}`));
|
|
1238
|
+
}
|
|
1239
|
+
if (nextPhase) {
|
|
1240
|
+
console.log(c.dim(` Next phase: ${nextPhase}`));
|
|
1241
|
+
}
|
|
1242
|
+
// Display uncommitted changes if present
|
|
1243
|
+
if (worktreeStatus.workingDirectoryStatus !== 'clean') {
|
|
1244
|
+
const totalChanges = worktreeStatus.modifiedFiles.length + worktreeStatus.untrackedFiles.length;
|
|
1245
|
+
console.log(c.dim(` Uncommitted changes: ${totalChanges} file(s)`));
|
|
1246
|
+
if (worktreeStatus.modifiedFiles.length > 0) {
|
|
1247
|
+
console.log(c.dim(` Modified: ${worktreeStatus.modifiedFiles.slice(0, 3).join(', ')}${worktreeStatus.modifiedFiles.length > 3 ? '...' : ''}`));
|
|
1248
|
+
}
|
|
1249
|
+
if (worktreeStatus.untrackedFiles.length > 0) {
|
|
1250
|
+
console.log(c.dim(` Untracked: ${worktreeStatus.untrackedFiles.slice(0, 3).join(', ')}${worktreeStatus.untrackedFiles.length > 3 ? '...' : ''}`));
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
// Warn if branch has diverged significantly
|
|
1254
|
+
if (divergence.diverged && (divergence.ahead > DIVERGENCE_WARNING_THRESHOLD || divergence.behind > DIVERGENCE_WARNING_THRESHOLD)) {
|
|
1255
|
+
console.log(c.warning(` ⚠ Branch has diverged from base: ${divergence.ahead} ahead, ${divergence.behind} behind`));
|
|
1256
|
+
console.log(c.dim(` Consider rebasing to sync with latest changes`));
|
|
1257
|
+
}
|
|
1258
|
+
console.log();
|
|
1259
|
+
// Log resume event
|
|
1260
|
+
getLogger().info('worktree', `Resumed worktree for ${targetStory.frontmatter.id} at ${worktreePath}`);
|
|
1261
|
+
}
|
|
1262
|
+
else {
|
|
1263
|
+
// Create new worktree
|
|
1264
|
+
// Resolve worktree base path from config
|
|
1265
|
+
let resolvedBasePath;
|
|
1266
|
+
try {
|
|
1267
|
+
resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
|
|
1268
|
+
}
|
|
1269
|
+
catch (error) {
|
|
1270
|
+
console.log(c.error(`Configuration Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
1271
|
+
console.log(c.dim('Fix worktree.basePath in .ai-sdlc.json or remove it to use default location'));
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
const worktreeService = new GitWorktreeService(workingDir, resolvedBasePath);
|
|
1275
|
+
// Check for existing worktree NOT recorded in story frontmatter
|
|
1276
|
+
// This catches scenarios where workflow was interrupted after worktree creation
|
|
1277
|
+
// but before the story file was updated
|
|
1278
|
+
const existingWorktree = worktreeService.findByStoryId(targetStory.frontmatter.id);
|
|
1279
|
+
let shouldCreateNewWorktree = !existingWorktree || !existingWorktree.exists;
|
|
1280
|
+
if (existingWorktree && existingWorktree.exists) {
|
|
1281
|
+
// Handle --clean flag: cleanup and restart
|
|
1282
|
+
if (options.clean) {
|
|
1283
|
+
console.log(c.warning('Existing worktree found - cleaning up before restart...'));
|
|
1284
|
+
console.log();
|
|
1285
|
+
const worktreeStatus = worktreeService.getWorktreeStatus(existingWorktree);
|
|
1286
|
+
const unpushedResult = worktreeService.hasUnpushedCommits(existingWorktree.path);
|
|
1287
|
+
const commitCount = worktreeService.getCommitCount(existingWorktree.path);
|
|
1288
|
+
const branchOnRemote = worktreeService.branchExistsOnRemote(existingWorktree.branch);
|
|
1289
|
+
// Display summary of what will be deleted
|
|
1290
|
+
console.log(c.bold('Cleanup Summary:'));
|
|
1291
|
+
console.log(c.dim('─'.repeat(60)));
|
|
1292
|
+
console.log(`${c.dim('Worktree Path:')} ${worktreeStatus.path}`);
|
|
1293
|
+
console.log(`${c.dim('Branch:')} ${worktreeStatus.branch}`);
|
|
1294
|
+
console.log(`${c.dim('Total Commits:')} ${commitCount}`);
|
|
1295
|
+
console.log(`${c.dim('Unpushed Commits:')} ${unpushedResult.hasUnpushed ? c.warning(unpushedResult.count.toString()) : c.success('0')}`);
|
|
1296
|
+
console.log(`${c.dim('Modified Files:')} ${worktreeStatus.modifiedFiles.length > 0 ? c.warning(worktreeStatus.modifiedFiles.length.toString()) : c.success('0')}`);
|
|
1297
|
+
console.log(`${c.dim('Untracked Files:')} ${worktreeStatus.untrackedFiles.length > 0 ? c.warning(worktreeStatus.untrackedFiles.length.toString()) : c.success('0')}`);
|
|
1298
|
+
console.log(`${c.dim('Remote Branch:')} ${branchOnRemote ? c.warning('EXISTS') : c.dim('none')}`);
|
|
1299
|
+
console.log();
|
|
1300
|
+
// Warn about data loss
|
|
1301
|
+
if (worktreeStatus.modifiedFiles.length > 0 || worktreeStatus.untrackedFiles.length > 0 || unpushedResult.hasUnpushed) {
|
|
1302
|
+
console.log(c.error('⚠ WARNING: This will DELETE all uncommitted and unpushed work!'));
|
|
559
1303
|
console.log();
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
1304
|
+
}
|
|
1305
|
+
// Check for --force flag to skip confirmation
|
|
1306
|
+
const forceCleanup = options.force;
|
|
1307
|
+
if (!forceCleanup) {
|
|
1308
|
+
// Prompt for confirmation
|
|
1309
|
+
const confirmed = await new Promise((resolve) => {
|
|
1310
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1311
|
+
rl.question(c.warning('Are you sure you want to proceed? (y/N): '), (answer) => {
|
|
1312
|
+
rl.close();
|
|
1313
|
+
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
1314
|
+
});
|
|
1315
|
+
});
|
|
1316
|
+
if (!confirmed) {
|
|
1317
|
+
console.log(c.info('Cleanup cancelled.'));
|
|
1318
|
+
return;
|
|
566
1319
|
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
1320
|
+
}
|
|
1321
|
+
console.log();
|
|
1322
|
+
const cleanupSpinner = ora('Cleaning up worktree...').start();
|
|
1323
|
+
try {
|
|
1324
|
+
// Remove worktree (force remove to handle uncommitted changes)
|
|
1325
|
+
const forceRemove = worktreeStatus.modifiedFiles.length > 0 || worktreeStatus.untrackedFiles.length > 0;
|
|
1326
|
+
worktreeService.remove(existingWorktree.path, forceRemove);
|
|
1327
|
+
cleanupSpinner.text = 'Worktree removed, deleting branch...';
|
|
1328
|
+
// Delete local branch
|
|
1329
|
+
worktreeService.deleteBranch(existingWorktree.branch, true);
|
|
1330
|
+
// Optionally delete remote branch if it exists
|
|
1331
|
+
if (branchOnRemote) {
|
|
1332
|
+
if (!forceCleanup) {
|
|
1333
|
+
cleanupSpinner.stop();
|
|
1334
|
+
const deleteRemote = await new Promise((resolve) => {
|
|
1335
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1336
|
+
rl.question(c.warning('Branch exists on remote. Delete it too? (y/N): '), (answer) => {
|
|
1337
|
+
rl.close();
|
|
1338
|
+
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
1339
|
+
});
|
|
1340
|
+
});
|
|
1341
|
+
if (deleteRemote) {
|
|
1342
|
+
cleanupSpinner.start('Deleting remote branch...');
|
|
1343
|
+
worktreeService.deleteRemoteBranch(existingWorktree.branch);
|
|
1344
|
+
}
|
|
1345
|
+
cleanupSpinner.start();
|
|
1346
|
+
}
|
|
1347
|
+
else {
|
|
1348
|
+
// --force provided, skip remote deletion by default (safer)
|
|
1349
|
+
cleanupSpinner.text = 'Skipping remote branch deletion (use manual cleanup if needed)';
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
// Reset story workflow state
|
|
1353
|
+
cleanupSpinner.text = 'Resetting story state...';
|
|
1354
|
+
const { resetWorkflowState } = await import('../core/story.js');
|
|
1355
|
+
targetStory = await resetWorkflowState(targetStory);
|
|
1356
|
+
// Clear workflow checkpoint if exists
|
|
1357
|
+
if (hasWorkflowState(sdlcRoot, targetStory.frontmatter.id)) {
|
|
1358
|
+
await clearWorkflowState(sdlcRoot, targetStory.frontmatter.id);
|
|
1359
|
+
}
|
|
1360
|
+
cleanupSpinner.succeed(c.success('✓ Cleanup complete - ready to create fresh worktree'));
|
|
1361
|
+
console.log();
|
|
1362
|
+
}
|
|
1363
|
+
catch (error) {
|
|
1364
|
+
cleanupSpinner.fail(c.error('Cleanup failed'));
|
|
1365
|
+
console.log(c.error(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
// After cleanup, create a fresh worktree
|
|
1369
|
+
shouldCreateNewWorktree = true;
|
|
1370
|
+
}
|
|
1371
|
+
else {
|
|
1372
|
+
// Not cleaning - resume in existing worktree (S-0063 feature)
|
|
1373
|
+
getLogger().info('worktree', `Detected existing worktree for ${targetStory.frontmatter.id} at ${existingWorktree.path}`);
|
|
1374
|
+
// Validate the existing worktree before resuming
|
|
1375
|
+
const branchName = worktreeService.getBranchName(targetStory.frontmatter.id, targetStory.slug);
|
|
1376
|
+
const validation = worktreeService.validateWorktreeForResume(existingWorktree.path, branchName);
|
|
1377
|
+
if (!validation.canResume) {
|
|
1378
|
+
console.log(c.error('Detected existing worktree but cannot resume:'));
|
|
1379
|
+
validation.issues.forEach(issue => console.log(c.dim(` ✗ ${issue}`)));
|
|
1380
|
+
if (validation.requiresRecreation) {
|
|
1381
|
+
const branchExists = !validation.issues.includes('Branch does not exist');
|
|
1382
|
+
const dirMissing = validation.issues.includes('Worktree directory does not exist');
|
|
1383
|
+
const dirExists = !dirMissing;
|
|
1384
|
+
// Case 1: Directory missing but branch exists - recreate worktree from existing branch
|
|
1385
|
+
// Case 2: Directory exists but branch missing - recreate with new branch
|
|
1386
|
+
if ((branchExists && dirMissing) || (!branchExists && dirExists)) {
|
|
1387
|
+
const reason = branchExists
|
|
1388
|
+
? 'Branch exists - automatically recreating worktree directory'
|
|
1389
|
+
: 'Directory exists - automatically recreating worktree with new branch';
|
|
1390
|
+
console.log(c.dim(`\n✓ ${reason}`));
|
|
1391
|
+
try {
|
|
1392
|
+
// Remove the old worktree reference if it exists
|
|
1393
|
+
const removeResult = spawnSync('git', ['worktree', 'remove', existingWorktree.path, '--force'], {
|
|
1394
|
+
cwd: workingDir,
|
|
1395
|
+
encoding: 'utf-8',
|
|
1396
|
+
shell: false,
|
|
1397
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1398
|
+
});
|
|
1399
|
+
// Create the worktree at the same path
|
|
1400
|
+
// If branch exists, checkout that branch; otherwise create a new branch
|
|
1401
|
+
const baseBranch = worktreeService.detectBaseBranch();
|
|
1402
|
+
const worktreeAddArgs = branchExists
|
|
1403
|
+
? ['worktree', 'add', existingWorktree.path, branchName]
|
|
1404
|
+
: ['worktree', 'add', '-b', branchName, existingWorktree.path, baseBranch];
|
|
1405
|
+
const addResult = spawnSync('git', worktreeAddArgs, {
|
|
1406
|
+
cwd: workingDir,
|
|
1407
|
+
encoding: 'utf-8',
|
|
1408
|
+
shell: false,
|
|
1409
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1410
|
+
});
|
|
1411
|
+
if (addResult.status !== 0) {
|
|
1412
|
+
throw new Error(`Failed to recreate worktree: ${addResult.stderr}`);
|
|
1413
|
+
}
|
|
1414
|
+
// Install dependencies in the recreated worktree
|
|
1415
|
+
worktreeService.installDependencies(existingWorktree.path);
|
|
1416
|
+
console.log(c.success(`✓ Worktree recreated at ${existingWorktree.path}`));
|
|
1417
|
+
getLogger().info('worktree', `Recreated worktree for ${targetStory.frontmatter.id} at ${existingWorktree.path}`);
|
|
1418
|
+
}
|
|
1419
|
+
catch (error) {
|
|
1420
|
+
console.log(c.error(`Failed to recreate worktree: ${error instanceof Error ? error.message : String(error)}`));
|
|
1421
|
+
console.log(c.dim('Please manually remove it with:'));
|
|
1422
|
+
console.log(c.dim(` git worktree remove ${existingWorktree.path}`));
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
else {
|
|
1427
|
+
console.log(c.dim('\nWorktree needs manual intervention. Please remove it manually with:'));
|
|
1428
|
+
console.log(c.dim(` git worktree remove ${existingWorktree.path}`));
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
else {
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
// Automatically resume in the existing worktree
|
|
1437
|
+
originalCwd = process.cwd();
|
|
1438
|
+
worktreePath = existingWorktree.path;
|
|
1439
|
+
process.chdir(worktreePath);
|
|
1440
|
+
sdlcRoot = getSdlcRoot();
|
|
1441
|
+
worktreeCreated = true;
|
|
1442
|
+
// Update story frontmatter with worktree path (sync state)
|
|
1443
|
+
const worktreeStory = findStoryById(sdlcRoot, targetStory.frontmatter.id);
|
|
1444
|
+
if (worktreeStory) {
|
|
1445
|
+
const updatedStory = await updateStoryField(worktreeStory, 'worktree_path', worktreePath);
|
|
1446
|
+
await writeStory(updatedStory);
|
|
1447
|
+
targetStory = updatedStory;
|
|
1448
|
+
}
|
|
1449
|
+
// Get phase information for resume context
|
|
1450
|
+
const lastPhase = getLastCompletedPhase(targetStory);
|
|
1451
|
+
const nextPhase = getNextPhase(targetStory);
|
|
1452
|
+
// Get worktree status for uncommitted changes info
|
|
1453
|
+
const worktreeStatus = worktreeService.getWorktreeStatus(existingWorktree);
|
|
1454
|
+
// Check branch divergence
|
|
1455
|
+
const divergence = worktreeService.checkBranchDivergence(branchName);
|
|
1456
|
+
console.log(c.success(`✓ Resuming in existing worktree: ${worktreePath}`));
|
|
1457
|
+
console.log(c.dim(` Branch: ${branchName}`));
|
|
1458
|
+
console.log(c.dim(` (Worktree path synced to story frontmatter)`));
|
|
1459
|
+
if (lastPhase) {
|
|
1460
|
+
console.log(c.dim(` Last completed phase: ${lastPhase}`));
|
|
1461
|
+
}
|
|
1462
|
+
if (nextPhase) {
|
|
1463
|
+
console.log(c.dim(` Next phase: ${nextPhase}`));
|
|
1464
|
+
}
|
|
1465
|
+
// Display uncommitted changes if present
|
|
1466
|
+
if (worktreeStatus.workingDirectoryStatus !== 'clean') {
|
|
1467
|
+
const totalChanges = worktreeStatus.modifiedFiles.length + worktreeStatus.untrackedFiles.length;
|
|
1468
|
+
console.log(c.dim(` Uncommitted changes: ${totalChanges} file(s)`));
|
|
1469
|
+
if (worktreeStatus.modifiedFiles.length > 0) {
|
|
1470
|
+
console.log(c.dim(` Modified: ${worktreeStatus.modifiedFiles.slice(0, 3).join(', ')}${worktreeStatus.modifiedFiles.length > 3 ? '...' : ''}`));
|
|
1471
|
+
}
|
|
1472
|
+
if (worktreeStatus.untrackedFiles.length > 0) {
|
|
1473
|
+
console.log(c.dim(` Untracked: ${worktreeStatus.untrackedFiles.slice(0, 3).join(', ')}${worktreeStatus.untrackedFiles.length > 3 ? '...' : ''}`));
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
// Warn if branch has diverged significantly
|
|
1477
|
+
if (divergence.diverged && (divergence.ahead > DIVERGENCE_WARNING_THRESHOLD || divergence.behind > DIVERGENCE_WARNING_THRESHOLD)) {
|
|
1478
|
+
console.log(c.warning(` ⚠ Branch has diverged from base: ${divergence.ahead} ahead, ${divergence.behind} behind`));
|
|
1479
|
+
console.log(c.dim(` Consider rebasing to sync with latest changes`));
|
|
1480
|
+
}
|
|
1481
|
+
console.log();
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
if (shouldCreateNewWorktree) {
|
|
1485
|
+
// Validate git state for worktree creation
|
|
1486
|
+
const validation = worktreeService.validateCanCreateWorktree();
|
|
1487
|
+
if (!validation.valid) {
|
|
1488
|
+
console.log(c.error(`Error: ${validation.error}`));
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
try {
|
|
1492
|
+
// Detect base branch
|
|
1493
|
+
const baseBranch = worktreeService.detectBaseBranch();
|
|
1494
|
+
// Create worktree
|
|
1495
|
+
originalCwd = process.cwd();
|
|
1496
|
+
worktreePath = worktreeService.create({
|
|
1497
|
+
storyId: targetStory.frontmatter.id,
|
|
1498
|
+
slug: targetStory.slug,
|
|
1499
|
+
baseBranch,
|
|
1500
|
+
});
|
|
1501
|
+
// Change to worktree directory BEFORE updating story
|
|
1502
|
+
// This ensures story updates happen in the worktree, not on main
|
|
1503
|
+
// (allows parallel story launches from clean main)
|
|
1504
|
+
process.chdir(worktreePath);
|
|
1505
|
+
// Recalculate sdlcRoot for the worktree context
|
|
1506
|
+
sdlcRoot = getSdlcRoot();
|
|
1507
|
+
worktreeCreated = true;
|
|
1508
|
+
// Now update story frontmatter with worktree path (writes to worktree copy)
|
|
1509
|
+
// Re-resolve target story in worktree context
|
|
1510
|
+
const worktreeStory = findStoryById(sdlcRoot, targetStory.frontmatter.id);
|
|
1511
|
+
if (worktreeStory) {
|
|
1512
|
+
const updatedStory = await updateStoryField(worktreeStory, 'worktree_path', worktreePath);
|
|
1513
|
+
await writeStory(updatedStory);
|
|
1514
|
+
// Update targetStory reference for downstream use
|
|
1515
|
+
targetStory = updatedStory;
|
|
1516
|
+
}
|
|
1517
|
+
console.log(c.success(`✓ Created worktree at: ${worktreePath}`));
|
|
1518
|
+
console.log(c.dim(` Branch: ai-sdlc/${targetStory.frontmatter.id}-${targetStory.slug}`));
|
|
1519
|
+
console.log();
|
|
1520
|
+
}
|
|
1521
|
+
catch (error) {
|
|
1522
|
+
// Restore directory on worktree creation failure
|
|
1523
|
+
if (originalCwd) {
|
|
1524
|
+
process.chdir(originalCwd);
|
|
1525
|
+
}
|
|
1526
|
+
console.log(c.error(`Failed to create worktree: ${error instanceof Error ? error.message : String(error)}`));
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
// Validate git state before processing actions that modify git
|
|
1533
|
+
// Skip protected branch check if worktree mode is active (worktree is on feature branch)
|
|
1534
|
+
// Skip clean check entirely when worktree was just created:
|
|
1535
|
+
// - The worktree starts from a clean base branch
|
|
1536
|
+
// - npm install may modify package-lock.json
|
|
1537
|
+
// - Story file was just updated with worktree_path
|
|
1538
|
+
// - There's no prior user work to protect in a fresh worktree
|
|
1539
|
+
if (!options.force && requiresGitValidation(actionsToProcess)) {
|
|
1540
|
+
const workingDir = path.dirname(sdlcRoot);
|
|
1541
|
+
const gitValidationOptions = worktreeCreated
|
|
1542
|
+
? { skipBranchCheck: true, skipCleanCheck: true }
|
|
1543
|
+
: {};
|
|
1544
|
+
const gitValidation = validateGitState(workingDir, gitValidationOptions);
|
|
1545
|
+
if (!gitValidation.valid) {
|
|
1546
|
+
displayGitValidationResult(gitValidation, c);
|
|
1547
|
+
if (worktreeCreated && originalCwd) {
|
|
1548
|
+
process.chdir(originalCwd);
|
|
1549
|
+
}
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
if (gitValidation.warnings.length > 0) {
|
|
1553
|
+
displayGitValidationResult(gitValidation, c);
|
|
1554
|
+
console.log();
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
// Process actions with retry support for Full SDLC mode
|
|
1558
|
+
let currentActions = [...actionsToProcess];
|
|
1559
|
+
let currentActionIndex = 0;
|
|
1560
|
+
let retryAttempt = 0;
|
|
1561
|
+
const MAX_DISPLAY_RETRIES = 3; // For display purposes
|
|
1562
|
+
try {
|
|
1563
|
+
while (currentActionIndex < currentActions.length) {
|
|
1564
|
+
const action = currentActions[currentActionIndex];
|
|
1565
|
+
const totalActions = currentActions.length;
|
|
1566
|
+
// Enhanced progress indicator for full SDLC mode
|
|
1567
|
+
if (isFullSDLC && totalActions > 1) {
|
|
1568
|
+
const retryIndicator = retryAttempt > 0 ? ` (retry ${retryAttempt})` : '';
|
|
1569
|
+
console.log(c.info(`\n═══ Phase ${currentActionIndex + 1}/${totalActions}: ${action.type.toUpperCase()}${retryIndicator} ═══`));
|
|
1570
|
+
}
|
|
1571
|
+
const actionResult = await executeAction(action, sdlcRoot);
|
|
1572
|
+
// Handle action failure in full SDLC mode
|
|
1573
|
+
if (!actionResult.success && isFullSDLC) {
|
|
1574
|
+
console.log();
|
|
1575
|
+
console.log(c.error(`✗ Phase ${action.type} failed`));
|
|
1576
|
+
console.log(c.dim(`Completed ${currentActionIndex} of ${totalActions} phases`));
|
|
1577
|
+
console.log(c.info('Fix the error above and use --continue to resume.'));
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
// Handle review rejection in Full SDLC mode - trigger retry loop
|
|
1581
|
+
if (isFullSDLC && action.type === 'review' && actionResult.reviewResult) {
|
|
1582
|
+
const reviewResult = actionResult.reviewResult;
|
|
1583
|
+
if (reviewResult.decision === ReviewDecision.REJECTED) {
|
|
1584
|
+
// Load fresh story state and config for retry check
|
|
1585
|
+
const story = parseStory(action.storyPath);
|
|
1586
|
+
const config = loadConfig();
|
|
1587
|
+
// Check if we're at max retries (pass CLI override if provided)
|
|
1588
|
+
if (isAtMaxRetries(story, config, maxIterationsOverride)) {
|
|
1589
|
+
console.log();
|
|
1590
|
+
console.log(c.error('═'.repeat(50)));
|
|
1591
|
+
console.log(c.error(`✗ Review failed - maximum retries reached`));
|
|
1592
|
+
console.log(c.error('═'.repeat(50)));
|
|
1593
|
+
console.log(c.dim(`Story has reached the maximum retry limit.`));
|
|
1594
|
+
console.log(c.dim(`Issues found: ${reviewResult.issues.length}`));
|
|
1595
|
+
console.log(c.warning('Manual intervention required to address the review feedback.'));
|
|
1596
|
+
console.log(c.info('You can:'));
|
|
1597
|
+
console.log(c.dim(' 1. Fix issues manually and run again'));
|
|
1598
|
+
console.log(c.dim(' 2. Reset retry count in the story frontmatter'));
|
|
1599
|
+
// Using action.storyId - available from action loop context
|
|
1600
|
+
await clearWorkflowState(sdlcRoot, action.storyId);
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
// We can retry - reset RPIV cycle and loop back
|
|
1604
|
+
const currentRetry = (story.frontmatter.retry_count || 0) + 1;
|
|
1605
|
+
// Use CLI override, then story-specific, then config default
|
|
1606
|
+
const effectiveMaxRetries = maxIterationsOverride !== undefined
|
|
1607
|
+
? maxIterationsOverride
|
|
1608
|
+
: (story.frontmatter.max_retries ?? config.reviewConfig?.maxRetries ?? Infinity);
|
|
1609
|
+
const maxRetriesDisplay = Number.isFinite(effectiveMaxRetries) ? effectiveMaxRetries : '∞';
|
|
1610
|
+
console.log();
|
|
1611
|
+
console.log(c.warning(`⟳ Review rejected with ${reviewResult.issues.length} issue(s) - initiating rework (attempt ${currentRetry}/${maxRetriesDisplay})`));
|
|
1612
|
+
// Display executive summary
|
|
1613
|
+
const summary = generateReviewSummary(reviewResult.issues, getTerminalWidth());
|
|
1614
|
+
console.log(c.dim(` Summary: ${summary}`));
|
|
1615
|
+
// Reset the RPIV cycle (this increments retry_count and resets flags)
|
|
1616
|
+
await resetRPIVCycle(story, reviewResult.feedback);
|
|
1617
|
+
// Log what's being reset
|
|
1618
|
+
console.log(c.dim(` → Reset plan_complete, implementation_complete, reviews_complete`));
|
|
1619
|
+
console.log(c.dim(` → Retry count: ${currentRetry}/${maxRetriesDisplay}`));
|
|
1620
|
+
// Regenerate actions starting from the phase that needs rework
|
|
1621
|
+
// For now, we restart from 'plan' since that's the typical flow after research
|
|
1622
|
+
const freshStory = parseStory(action.storyPath);
|
|
1623
|
+
const newActions = generateFullSDLCActions(freshStory, c);
|
|
1624
|
+
if (newActions.length > 0) {
|
|
1625
|
+
// Replace remaining actions with the new sequence
|
|
1626
|
+
currentActions = newActions;
|
|
1627
|
+
currentActionIndex = 0;
|
|
1628
|
+
retryAttempt++;
|
|
1629
|
+
console.log(c.info(` → Restarting SDLC from ${newActions[0].type} phase`));
|
|
1630
|
+
console.log();
|
|
1631
|
+
continue; // Restart the loop with new actions
|
|
570
1632
|
}
|
|
571
1633
|
else {
|
|
572
|
-
//
|
|
1634
|
+
// No actions to retry (shouldn't happen but handle gracefully)
|
|
1635
|
+
console.log(c.error('Error: No actions generated for retry. Manual intervention required.'));
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
else if (reviewResult.decision === ReviewDecision.RECOVERY) {
|
|
1640
|
+
// Implementation recovery: reset implementation_complete and increment implementation retry count
|
|
1641
|
+
// This is distinct from REJECTED which resets the entire RPIV cycle
|
|
1642
|
+
const story = parseStory(action.storyPath);
|
|
1643
|
+
const config = loadConfig();
|
|
1644
|
+
const retryCount = story.frontmatter.implementation_retry_count || 0;
|
|
1645
|
+
const maxRetries = getEffectiveMaxImplementationRetries(story, config);
|
|
1646
|
+
const maxRetriesDisplay = Number.isFinite(maxRetries) ? maxRetries : '∞';
|
|
1647
|
+
console.log();
|
|
1648
|
+
console.log(c.warning(`🔄 Implementation recovery triggered (attempt ${retryCount + 1}/${maxRetriesDisplay})`));
|
|
1649
|
+
console.log(c.dim(` Reason: ${story.frontmatter.last_restart_reason || 'No source code changes detected'}`));
|
|
1650
|
+
// Increment implementation retry count
|
|
1651
|
+
await incrementImplementationRetryCount(story);
|
|
1652
|
+
// Check if we've exceeded max implementation retries after incrementing
|
|
1653
|
+
const freshStory = parseStory(action.storyPath);
|
|
1654
|
+
if (isAtMaxImplementationRetries(freshStory, config)) {
|
|
573
1655
|
console.log();
|
|
574
|
-
console.log(c.
|
|
575
|
-
console.log(c.
|
|
1656
|
+
console.log(c.error('═'.repeat(50)));
|
|
1657
|
+
console.log(c.error(`✗ Implementation recovery failed - maximum retries reached`));
|
|
1658
|
+
console.log(c.error('═'.repeat(50)));
|
|
1659
|
+
console.log(c.dim(`Story has reached the maximum implementation retry limit (${maxRetries}).`));
|
|
1660
|
+
console.log(c.warning('Marking story as blocked. Manual intervention required.'));
|
|
1661
|
+
// Mark story as blocked
|
|
1662
|
+
await updateStoryStatus(freshStory, 'blocked');
|
|
1663
|
+
console.log(c.info('Story status updated to: blocked'));
|
|
1664
|
+
await clearWorkflowState(sdlcRoot, action.storyId);
|
|
1665
|
+
process.exit(1);
|
|
576
1666
|
}
|
|
577
|
-
|
|
1667
|
+
// Regenerate actions to restart from implementation phase
|
|
1668
|
+
const newActions = generateFullSDLCActions(freshStory, c);
|
|
1669
|
+
if (newActions.length > 0) {
|
|
1670
|
+
currentActions = newActions;
|
|
1671
|
+
currentActionIndex = 0;
|
|
1672
|
+
console.log(c.info(` → Restarting from ${newActions[0].type} phase`));
|
|
1673
|
+
console.log();
|
|
1674
|
+
continue; // Restart the loop with new actions
|
|
1675
|
+
}
|
|
1676
|
+
else {
|
|
1677
|
+
console.log(c.error('Error: No actions generated for recovery. Manual intervention required.'));
|
|
1678
|
+
process.exit(1);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
else if (reviewResult.decision === ReviewDecision.FAILED) {
|
|
1682
|
+
// Review agent failed - don't increment retry count
|
|
1683
|
+
console.log();
|
|
1684
|
+
console.log(c.error(`✗ Review process failed: ${reviewResult.error || 'Unknown error'}`));
|
|
1685
|
+
console.log(c.warning('This does not count as a retry attempt. You can retry manually.'));
|
|
1686
|
+
await clearWorkflowState(sdlcRoot, action.storyId);
|
|
1687
|
+
process.exit(1);
|
|
578
1688
|
}
|
|
579
1689
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
1690
|
+
// Save checkpoint after successful action
|
|
1691
|
+
if (actionResult.success) {
|
|
1692
|
+
completedActions.push({
|
|
1693
|
+
type: action.type,
|
|
1694
|
+
storyId: action.storyId,
|
|
1695
|
+
storyPath: action.storyPath,
|
|
1696
|
+
completedAt: new Date().toISOString(),
|
|
1697
|
+
});
|
|
1698
|
+
const state = {
|
|
1699
|
+
version: '1.0',
|
|
1700
|
+
workflowId,
|
|
1701
|
+
timestamp: new Date().toISOString(),
|
|
1702
|
+
currentAction: null,
|
|
1703
|
+
completedActions,
|
|
1704
|
+
context: {
|
|
1705
|
+
sdlcRoot,
|
|
1706
|
+
options: {
|
|
1707
|
+
auto: options.auto,
|
|
1708
|
+
dryRun: options.dryRun,
|
|
1709
|
+
story: options.story,
|
|
1710
|
+
fullSDLC: isFullSDLC,
|
|
1711
|
+
},
|
|
1712
|
+
storyContentHash: calculateStoryHash(action.storyPath),
|
|
1713
|
+
},
|
|
1714
|
+
};
|
|
1715
|
+
await saveWorkflowState(state, sdlcRoot, action.storyId);
|
|
1716
|
+
console.log(c.dim(` ✓ Progress saved (${completedActions.length} actions completed)`));
|
|
1717
|
+
}
|
|
1718
|
+
currentActionIndex++;
|
|
1719
|
+
// Re-assess after each action in auto mode
|
|
1720
|
+
if (options.auto) {
|
|
1721
|
+
// For full SDLC mode, check if all phases are complete (and review passed)
|
|
1722
|
+
if (isFullSDLC) {
|
|
1723
|
+
// Check if we've completed all actions in our sequence
|
|
1724
|
+
if (currentActionIndex >= currentActions.length) {
|
|
1725
|
+
// Verify the review actually passed (reviews_complete should be true)
|
|
1726
|
+
const finalStory = parseStory(action.storyPath);
|
|
1727
|
+
if (finalStory.frontmatter.reviews_complete) {
|
|
1728
|
+
console.log();
|
|
1729
|
+
console.log(c.success('═'.repeat(50)));
|
|
1730
|
+
console.log(c.success(`✓ Full SDLC completed successfully!`));
|
|
1731
|
+
console.log(c.success('═'.repeat(50)));
|
|
1732
|
+
console.log(c.dim(`Completed phases: ${currentActions.length}`));
|
|
1733
|
+
if (retryAttempt > 0) {
|
|
1734
|
+
console.log(c.dim(`Retry attempts: ${retryAttempt}`));
|
|
1735
|
+
}
|
|
1736
|
+
console.log(c.dim(`Story is now ready for PR creation.`));
|
|
1737
|
+
// Using action.storyId - available from action loop context
|
|
1738
|
+
await clearWorkflowState(sdlcRoot, action.storyId);
|
|
1739
|
+
console.log(c.dim('Checkpoint cleared.'));
|
|
1740
|
+
}
|
|
1741
|
+
else {
|
|
1742
|
+
// This shouldn't happen if our logic is correct, but handle it
|
|
1743
|
+
console.log();
|
|
1744
|
+
console.log(c.warning('All phases executed but reviews_complete is false.'));
|
|
1745
|
+
console.log(c.dim('This may indicate an issue with the review process.'));
|
|
1746
|
+
}
|
|
1747
|
+
break;
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
else {
|
|
1751
|
+
// Normal auto mode: re-assess state
|
|
1752
|
+
const newAssessment = await assessState(sdlcRoot);
|
|
1753
|
+
if (newAssessment.recommendedActions.length === 0) {
|
|
1754
|
+
console.log(c.success('\n✓ All actions completed!'));
|
|
1755
|
+
// Using action.storyId - available from action loop context
|
|
1756
|
+
await clearWorkflowState(sdlcRoot, action.storyId);
|
|
1757
|
+
console.log(c.dim('Checkpoint cleared.'));
|
|
1758
|
+
break;
|
|
1759
|
+
}
|
|
588
1760
|
}
|
|
589
1761
|
}
|
|
590
1762
|
}
|
|
591
1763
|
}
|
|
1764
|
+
finally {
|
|
1765
|
+
// Restore original working directory if worktree was used
|
|
1766
|
+
if (originalCwd) {
|
|
1767
|
+
process.chdir(originalCwd);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
592
1770
|
}
|
|
593
1771
|
/**
|
|
594
1772
|
* Execute a specific action
|
|
@@ -598,61 +1776,91 @@ export async function run(options) {
|
|
|
598
1776
|
async function executeAction(action, sdlcRoot) {
|
|
599
1777
|
const config = loadConfig();
|
|
600
1778
|
const c = getThemedChalk(config);
|
|
601
|
-
|
|
602
|
-
|
|
1779
|
+
const globalLogger = getLogger();
|
|
1780
|
+
const actionStartTime = Date.now();
|
|
1781
|
+
// Log action start to global logger
|
|
1782
|
+
globalLogger.info('action', `Starting action: ${action.type}`, {
|
|
1783
|
+
storyId: action.storyId,
|
|
1784
|
+
actionType: action.type,
|
|
1785
|
+
storyPath: action.storyPath,
|
|
1786
|
+
});
|
|
1787
|
+
// Initialize per-story logger
|
|
1788
|
+
const maxLogs = config.logging?.maxFiles ?? 5;
|
|
1789
|
+
let storyLogger = null;
|
|
1790
|
+
let spinner = null;
|
|
603
1791
|
try {
|
|
604
|
-
|
|
605
|
-
|
|
1792
|
+
storyLogger = new StoryLogger(action.storyId, sdlcRoot, maxLogs);
|
|
1793
|
+
storyLogger.log('INFO', `Starting action: ${action.type} for story ${action.storyId}`);
|
|
606
1794
|
}
|
|
607
1795
|
catch (error) {
|
|
608
|
-
|
|
609
|
-
console.
|
|
610
|
-
console.log(c.dim(` Original path: ${action.storyPath}`));
|
|
611
|
-
if (error instanceof Error) {
|
|
612
|
-
console.log(c.dim(` ${error.message}`));
|
|
613
|
-
}
|
|
614
|
-
return { success: false };
|
|
1796
|
+
// If logger initialization fails, continue without logging (console-only)
|
|
1797
|
+
console.warn(`Warning: Failed to initialize logger: ${error instanceof Error ? error.message : String(error)}`);
|
|
615
1798
|
}
|
|
616
|
-
// Update action path if it was stale
|
|
617
|
-
if (resolvedPath !== action.storyPath) {
|
|
618
|
-
console.log(c.warning(`Note: Story path updated (file was moved)`));
|
|
619
|
-
console.log(c.dim(` From: ${action.storyPath}`));
|
|
620
|
-
console.log(c.dim(` To: ${resolvedPath}`));
|
|
621
|
-
action.storyPath = resolvedPath;
|
|
622
|
-
}
|
|
623
|
-
// Store phase completion state BEFORE action execution (to detect transitions)
|
|
624
|
-
const storyBeforeAction = parseStory(action.storyPath);
|
|
625
|
-
const prevPhaseState = {
|
|
626
|
-
research_complete: storyBeforeAction.frontmatter.research_complete,
|
|
627
|
-
plan_complete: storyBeforeAction.frontmatter.plan_complete,
|
|
628
|
-
implementation_complete: storyBeforeAction.frontmatter.implementation_complete,
|
|
629
|
-
reviews_complete: storyBeforeAction.frontmatter.reviews_complete,
|
|
630
|
-
status: storyBeforeAction.frontmatter.status,
|
|
631
|
-
};
|
|
632
|
-
const spinner = ora(formatAction(action, true, c)).start();
|
|
633
|
-
const baseText = formatAction(action, true, c);
|
|
634
|
-
// Create agent progress callback for real-time updates
|
|
635
|
-
const onAgentProgress = (event) => {
|
|
636
|
-
switch (event.type) {
|
|
637
|
-
case 'session_start':
|
|
638
|
-
spinner.text = `${baseText} ${c.dim('(session started)')}`;
|
|
639
|
-
break;
|
|
640
|
-
case 'tool_start':
|
|
641
|
-
// Show which tool is being executed
|
|
642
|
-
const toolName = event.toolName || 'unknown';
|
|
643
|
-
const shortName = toolName.replace(/^(mcp__|Mcp)/, '').substring(0, 30);
|
|
644
|
-
spinner.text = `${baseText} ${c.dim(`→ ${shortName}`)}`;
|
|
645
|
-
break;
|
|
646
|
-
case 'tool_end':
|
|
647
|
-
// Keep showing the action, tool completed
|
|
648
|
-
spinner.text = baseText;
|
|
649
|
-
break;
|
|
650
|
-
case 'completion':
|
|
651
|
-
spinner.text = `${baseText} ${c.dim('(completing...)')}`;
|
|
652
|
-
break;
|
|
653
|
-
}
|
|
654
|
-
};
|
|
655
1799
|
try {
|
|
1800
|
+
// Resolve story by ID to get current path (handles moves between folders)
|
|
1801
|
+
let resolvedPath;
|
|
1802
|
+
try {
|
|
1803
|
+
const story = getStory(sdlcRoot, action.storyId);
|
|
1804
|
+
resolvedPath = story.path;
|
|
1805
|
+
}
|
|
1806
|
+
catch (error) {
|
|
1807
|
+
const errorMsg = `Error: Story not found for action "${action.type}"`;
|
|
1808
|
+
storyLogger?.log('ERROR', errorMsg);
|
|
1809
|
+
storyLogger?.log('ERROR', ` Story ID: ${action.storyId}`);
|
|
1810
|
+
storyLogger?.log('ERROR', ` Original path: ${action.storyPath}`);
|
|
1811
|
+
console.log(c.error(errorMsg));
|
|
1812
|
+
console.log(c.dim(` Story ID: ${action.storyId}`));
|
|
1813
|
+
console.log(c.dim(` Original path: ${action.storyPath}`));
|
|
1814
|
+
if (error instanceof Error) {
|
|
1815
|
+
storyLogger?.log('ERROR', ` ${error.message}`);
|
|
1816
|
+
console.log(c.dim(` ${error.message}`));
|
|
1817
|
+
}
|
|
1818
|
+
return { success: false };
|
|
1819
|
+
}
|
|
1820
|
+
// Update action path if it was stale
|
|
1821
|
+
if (resolvedPath !== action.storyPath) {
|
|
1822
|
+
storyLogger?.log('WARN', `Note: Story path updated (file was moved)`);
|
|
1823
|
+
storyLogger?.log('WARN', ` From: ${action.storyPath}`);
|
|
1824
|
+
storyLogger?.log('WARN', ` To: ${resolvedPath}`);
|
|
1825
|
+
console.log(c.warning(`Note: Story path updated (file was moved)`));
|
|
1826
|
+
console.log(c.dim(` From: ${action.storyPath}`));
|
|
1827
|
+
console.log(c.dim(` To: ${resolvedPath}`));
|
|
1828
|
+
action.storyPath = resolvedPath;
|
|
1829
|
+
}
|
|
1830
|
+
// Store phase completion state BEFORE action execution (to detect transitions)
|
|
1831
|
+
const storyBeforeAction = parseStory(action.storyPath);
|
|
1832
|
+
const prevPhaseState = {
|
|
1833
|
+
research_complete: storyBeforeAction.frontmatter.research_complete,
|
|
1834
|
+
plan_complete: storyBeforeAction.frontmatter.plan_complete,
|
|
1835
|
+
implementation_complete: storyBeforeAction.frontmatter.implementation_complete,
|
|
1836
|
+
reviews_complete: storyBeforeAction.frontmatter.reviews_complete,
|
|
1837
|
+
status: storyBeforeAction.frontmatter.status,
|
|
1838
|
+
};
|
|
1839
|
+
spinner = ora(formatAction(action, true, c)).start();
|
|
1840
|
+
const baseText = formatAction(action, true, c);
|
|
1841
|
+
// Create agent progress callback for real-time updates
|
|
1842
|
+
const onAgentProgress = (event) => {
|
|
1843
|
+
if (!spinner)
|
|
1844
|
+
return; // Guard against null spinner
|
|
1845
|
+
switch (event.type) {
|
|
1846
|
+
case 'session_start':
|
|
1847
|
+
spinner.text = `${baseText} ${c.dim('(session started)')}`;
|
|
1848
|
+
break;
|
|
1849
|
+
case 'tool_start':
|
|
1850
|
+
// Show which tool is being executed
|
|
1851
|
+
const toolName = event.toolName || 'unknown';
|
|
1852
|
+
const shortName = toolName.replace(/^(mcp__|Mcp)/, '').substring(0, 30);
|
|
1853
|
+
spinner.text = `${baseText} ${c.dim(`→ ${shortName}`)}`;
|
|
1854
|
+
break;
|
|
1855
|
+
case 'tool_end':
|
|
1856
|
+
// Keep showing the action, tool completed
|
|
1857
|
+
spinner.text = baseText;
|
|
1858
|
+
break;
|
|
1859
|
+
case 'completion':
|
|
1860
|
+
spinner.text = `${baseText} ${c.dim('(completing...)')}`;
|
|
1861
|
+
break;
|
|
1862
|
+
}
|
|
1863
|
+
};
|
|
656
1864
|
// Import and run the appropriate agent
|
|
657
1865
|
let result;
|
|
658
1866
|
switch (action.type) {
|
|
@@ -676,6 +1884,8 @@ async function executeAction(action, sdlcRoot) {
|
|
|
676
1884
|
const { runReviewAgent } = await import('../agents/review.js');
|
|
677
1885
|
result = await runReviewAgent(action.storyPath, sdlcRoot, {
|
|
678
1886
|
onVerificationProgress: (phase, status, message) => {
|
|
1887
|
+
if (!spinner)
|
|
1888
|
+
return; // Guard against null spinner
|
|
679
1889
|
const phaseLabel = phase === 'build' ? 'Building' : 'Testing';
|
|
680
1890
|
switch (status) {
|
|
681
1891
|
case 'starting':
|
|
@@ -693,6 +1903,53 @@ async function executeAction(action, sdlcRoot) {
|
|
|
693
1903
|
}
|
|
694
1904
|
},
|
|
695
1905
|
});
|
|
1906
|
+
// Auto-complete story if review was approved
|
|
1907
|
+
if (result && result.success) {
|
|
1908
|
+
const reviewResult = result;
|
|
1909
|
+
let story = parseStory(action.storyPath);
|
|
1910
|
+
story = await autoCompleteStoryAfterReview(story, config, reviewResult);
|
|
1911
|
+
// Log auto-completion if it occurred
|
|
1912
|
+
if (reviewResult.decision === ReviewDecision.APPROVED && config.reviewConfig.autoCompleteOnApproval) {
|
|
1913
|
+
spinner.text = c.success('Review approved - auto-completing story');
|
|
1914
|
+
storyLogger?.log('INFO', `Story auto-completed after review approval: "${story.frontmatter.title}"`);
|
|
1915
|
+
// Auto-create PR in automated mode
|
|
1916
|
+
const workflowState = await loadWorkflowState(sdlcRoot, story.frontmatter.id);
|
|
1917
|
+
const isAutoMode = workflowState?.context.options.auto ?? false;
|
|
1918
|
+
if (isAutoMode || config.reviewConfig.autoCreatePROnApproval) {
|
|
1919
|
+
try {
|
|
1920
|
+
// Create PR (this will automatically commit any uncommitted changes)
|
|
1921
|
+
spinner.text = c.dim('Creating pull request...');
|
|
1922
|
+
const { createPullRequest } = await import('../agents/review.js');
|
|
1923
|
+
const prResult = await createPullRequest(action.storyPath, sdlcRoot);
|
|
1924
|
+
if (prResult.success) {
|
|
1925
|
+
spinner.text = c.success('Review approved - PR created');
|
|
1926
|
+
storyLogger?.log('INFO', `PR created successfully for ${story.frontmatter.id}`);
|
|
1927
|
+
}
|
|
1928
|
+
else {
|
|
1929
|
+
// PR creation failed - mark as blocked
|
|
1930
|
+
const { updateStoryStatus } = await import('../core/story.js');
|
|
1931
|
+
const blockedStory = await updateStoryStatus(story, 'blocked');
|
|
1932
|
+
await writeStory(blockedStory);
|
|
1933
|
+
spinner.text = c.warning('Review approved but PR creation failed - story marked as blocked');
|
|
1934
|
+
storyLogger?.log('WARN', `PR creation failed for ${story.frontmatter.id}: ${prResult.error || 'Unknown error'}`);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
catch (error) {
|
|
1938
|
+
// Error during PR creation - mark as blocked
|
|
1939
|
+
const { updateStoryStatus } = await import('../core/story.js');
|
|
1940
|
+
const blockedStory = await updateStoryStatus(story, 'blocked');
|
|
1941
|
+
await writeStory(blockedStory);
|
|
1942
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1943
|
+
spinner.text = c.warning(`Review approved but auto-PR failed: ${errorMsg}`);
|
|
1944
|
+
storyLogger?.log('ERROR', `Auto-PR failed for ${story.frontmatter.id}: ${errorMsg}`);
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
// Handle worktree cleanup if story has a worktree
|
|
1948
|
+
if (story.frontmatter.worktree_path) {
|
|
1949
|
+
await handleWorktreeCleanup(story, config, c);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
696
1953
|
break;
|
|
697
1954
|
case 'rework':
|
|
698
1955
|
const { runReworkAgent } = await import('../agents/rework.js');
|
|
@@ -709,28 +1966,49 @@ async function executeAction(action, sdlcRoot) {
|
|
|
709
1966
|
// Update story status to done (no file move in new architecture)
|
|
710
1967
|
const { updateStoryStatus } = await import('../core/story.js');
|
|
711
1968
|
const storyToMove = parseStory(action.storyPath);
|
|
712
|
-
const updatedStory = updateStoryStatus(storyToMove, 'done');
|
|
1969
|
+
const updatedStory = await updateStoryStatus(storyToMove, 'done');
|
|
713
1970
|
result = {
|
|
714
1971
|
success: true,
|
|
715
1972
|
story: updatedStory,
|
|
716
1973
|
changesMade: ['Updated story status to done'],
|
|
717
1974
|
};
|
|
1975
|
+
// Worktree cleanup prompt (if story has a worktree)
|
|
1976
|
+
if (storyToMove.frontmatter.worktree_path) {
|
|
1977
|
+
await handleWorktreeCleanup(storyToMove, config, c);
|
|
1978
|
+
}
|
|
718
1979
|
break;
|
|
719
1980
|
default:
|
|
720
1981
|
throw new Error(`Unknown action type: ${action.type}`);
|
|
721
1982
|
}
|
|
722
1983
|
// Check if agent succeeded
|
|
1984
|
+
const actionDuration = Date.now() - actionStartTime;
|
|
723
1985
|
if (result && !result.success) {
|
|
724
1986
|
spinner.fail(c.error(`Failed: ${formatAction(action, true, c)}`));
|
|
1987
|
+
storyLogger?.log('ERROR', `Action failed: ${formatAction(action, false, c)}`);
|
|
1988
|
+
globalLogger.warn('action', `Action failed: ${action.type}`, {
|
|
1989
|
+
storyId: action.storyId,
|
|
1990
|
+
actionType: action.type,
|
|
1991
|
+
durationMs: actionDuration,
|
|
1992
|
+
error: result.error,
|
|
1993
|
+
});
|
|
725
1994
|
if (result.error) {
|
|
1995
|
+
storyLogger?.log('ERROR', ` Error: ${result.error}`);
|
|
726
1996
|
console.error(c.error(` Error: ${result.error}`));
|
|
727
1997
|
}
|
|
728
1998
|
return { success: false };
|
|
729
1999
|
}
|
|
730
2000
|
spinner.succeed(c.success(formatAction(action, true, c)));
|
|
2001
|
+
storyLogger?.log('INFO', `Action completed successfully: ${formatAction(action, false, c)}`);
|
|
2002
|
+
globalLogger.info('action', `Action completed: ${action.type}`, {
|
|
2003
|
+
storyId: action.storyId,
|
|
2004
|
+
actionType: action.type,
|
|
2005
|
+
durationMs: actionDuration,
|
|
2006
|
+
changesCount: result?.changesMade?.length ?? 0,
|
|
2007
|
+
});
|
|
731
2008
|
// Show changes made
|
|
732
2009
|
if (result && result.changesMade.length > 0) {
|
|
733
2010
|
for (const change of result.changesMade) {
|
|
2011
|
+
storyLogger?.log('INFO', ` → ${change}`);
|
|
734
2012
|
console.log(c.dim(` → ${change}`));
|
|
735
2013
|
}
|
|
736
2014
|
}
|
|
@@ -786,14 +2064,28 @@ async function executeAction(action, sdlcRoot) {
|
|
|
786
2064
|
return { success: true };
|
|
787
2065
|
}
|
|
788
2066
|
catch (error) {
|
|
789
|
-
|
|
2067
|
+
const exceptionDuration = Date.now() - actionStartTime;
|
|
2068
|
+
if (spinner) {
|
|
2069
|
+
spinner.fail(c.error(`Failed: ${formatAction(action, true, c)}`));
|
|
2070
|
+
}
|
|
2071
|
+
else {
|
|
2072
|
+
console.error(c.error(`Failed: ${formatAction(action, true, c)}`));
|
|
2073
|
+
}
|
|
2074
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2075
|
+
storyLogger?.log('ERROR', `Exception during action execution: ${errorMessage}`);
|
|
2076
|
+
globalLogger.error('action', `Action exception: ${action.type}`, {
|
|
2077
|
+
storyId: action.storyId,
|
|
2078
|
+
actionType: action.type,
|
|
2079
|
+
durationMs: exceptionDuration,
|
|
2080
|
+
error: errorMessage,
|
|
2081
|
+
});
|
|
790
2082
|
console.error(error);
|
|
791
2083
|
// Show phase checklist with error indication (if file still exists)
|
|
792
2084
|
try {
|
|
793
2085
|
const story = parseStory(action.storyPath);
|
|
794
2086
|
console.log(c.dim(` Progress: ${renderPhaseChecklist(story, c)}`));
|
|
795
2087
|
// Update story with error
|
|
796
|
-
story.frontmatter.last_error =
|
|
2088
|
+
story.frontmatter.last_error = errorMessage;
|
|
797
2089
|
}
|
|
798
2090
|
catch {
|
|
799
2091
|
// File may have been moved - skip progress display
|
|
@@ -801,6 +2093,10 @@ async function executeAction(action, sdlcRoot) {
|
|
|
801
2093
|
// Don't throw - let the workflow continue if in auto mode
|
|
802
2094
|
return { success: false };
|
|
803
2095
|
}
|
|
2096
|
+
finally {
|
|
2097
|
+
// Always close logger, even if action fails or throws
|
|
2098
|
+
storyLogger?.close();
|
|
2099
|
+
}
|
|
804
2100
|
}
|
|
805
2101
|
/**
|
|
806
2102
|
* Get phase information for an action type
|
|
@@ -1208,7 +2504,7 @@ function isEmptySection(content) {
|
|
|
1208
2504
|
/**
|
|
1209
2505
|
* Unblock a story from the blocked folder and move it back to the workflow
|
|
1210
2506
|
*/
|
|
1211
|
-
export function unblock(storyId, options) {
|
|
2507
|
+
export async function unblock(storyId, options) {
|
|
1212
2508
|
const spinner = ora('Unblocking story...').start();
|
|
1213
2509
|
const config = loadConfig();
|
|
1214
2510
|
const c = getThemedChalk(config);
|
|
@@ -1219,7 +2515,7 @@ export function unblock(storyId, options) {
|
|
|
1219
2515
|
return;
|
|
1220
2516
|
}
|
|
1221
2517
|
// Unblock the story (using renamed import to avoid naming conflict)
|
|
1222
|
-
const unblockedStory = unblockStory(storyId, sdlcRoot, options);
|
|
2518
|
+
const unblockedStory = await unblockStory(storyId, sdlcRoot, options);
|
|
1223
2519
|
// Determine destination folder from updated path
|
|
1224
2520
|
const destinationFolder = unblockedStory.path.match(/\/([^/]+)\/[^/]+\.md$/)?.[1] || 'unknown';
|
|
1225
2521
|
spinner.succeed(c.success(`Unblocked story ${storyId}, moved to ${destinationFolder}/`));
|
|
@@ -1333,4 +2629,275 @@ export async function migrate(options) {
|
|
|
1333
2629
|
process.exit(1);
|
|
1334
2630
|
}
|
|
1335
2631
|
}
|
|
2632
|
+
/**
|
|
2633
|
+
* Helper function to prompt for removal confirmation
|
|
2634
|
+
*/
|
|
2635
|
+
async function confirmRemoval(message) {
|
|
2636
|
+
// Sanitize message to prevent terminal injection attacks
|
|
2637
|
+
// Use consistent sanitizeForDisplay() for all terminal output
|
|
2638
|
+
const sanitizedMessage = sanitizeForDisplay(message);
|
|
2639
|
+
const rl = readline.createInterface({
|
|
2640
|
+
input: process.stdin,
|
|
2641
|
+
output: process.stdout,
|
|
2642
|
+
});
|
|
2643
|
+
return new Promise((resolve) => {
|
|
2644
|
+
rl.question(sanitizedMessage + ' (y/N): ', (answer) => {
|
|
2645
|
+
rl.close();
|
|
2646
|
+
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
2647
|
+
});
|
|
2648
|
+
});
|
|
2649
|
+
}
|
|
2650
|
+
/**
|
|
2651
|
+
* Handle worktree cleanup when story moves to done
|
|
2652
|
+
* Prompts user in interactive mode to remove worktree
|
|
2653
|
+
*/
|
|
2654
|
+
async function handleWorktreeCleanup(story, config, c) {
|
|
2655
|
+
const worktreePath = story.frontmatter.worktree_path;
|
|
2656
|
+
if (!worktreePath)
|
|
2657
|
+
return;
|
|
2658
|
+
const sdlcRoot = getSdlcRoot();
|
|
2659
|
+
const workingDir = path.dirname(sdlcRoot);
|
|
2660
|
+
const worktreeConfig = config.worktree ?? DEFAULT_WORKTREE_CONFIG;
|
|
2661
|
+
// Check if worktree exists
|
|
2662
|
+
if (!fs.existsSync(worktreePath)) {
|
|
2663
|
+
console.log(c.warning(` Note: Worktree path no longer exists: ${worktreePath}`));
|
|
2664
|
+
const updated = await updateStoryField(story, 'worktree_path', undefined);
|
|
2665
|
+
await writeStory(updated);
|
|
2666
|
+
console.log(c.dim(' Cleared worktree_path from frontmatter'));
|
|
2667
|
+
return;
|
|
2668
|
+
}
|
|
2669
|
+
// Only prompt in interactive mode
|
|
2670
|
+
if (!process.stdin.isTTY) {
|
|
2671
|
+
console.log(c.dim(` Worktree preserved (non-interactive mode): ${worktreePath}`));
|
|
2672
|
+
return;
|
|
2673
|
+
}
|
|
2674
|
+
// Prompt for cleanup
|
|
2675
|
+
console.log();
|
|
2676
|
+
console.log(c.info(` Story has a worktree at: ${worktreePath}`));
|
|
2677
|
+
const shouldRemove = await confirmRemoval(' Remove worktree?');
|
|
2678
|
+
if (!shouldRemove) {
|
|
2679
|
+
console.log(c.dim(' Worktree preserved'));
|
|
2680
|
+
return;
|
|
2681
|
+
}
|
|
2682
|
+
// Remove worktree
|
|
2683
|
+
try {
|
|
2684
|
+
let resolvedBasePath;
|
|
2685
|
+
try {
|
|
2686
|
+
resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
|
|
2687
|
+
}
|
|
2688
|
+
catch {
|
|
2689
|
+
resolvedBasePath = path.dirname(worktreePath);
|
|
2690
|
+
}
|
|
2691
|
+
const service = new GitWorktreeService(workingDir, resolvedBasePath);
|
|
2692
|
+
service.remove(worktreePath);
|
|
2693
|
+
const updated = await updateStoryField(story, 'worktree_path', undefined);
|
|
2694
|
+
await writeStory(updated);
|
|
2695
|
+
console.log(c.success(' ✓ Worktree removed'));
|
|
2696
|
+
}
|
|
2697
|
+
catch (error) {
|
|
2698
|
+
console.log(c.warning(` Failed to remove worktree: ${error instanceof Error ? error.message : String(error)}`));
|
|
2699
|
+
// Clear frontmatter anyway (user may have manually deleted)
|
|
2700
|
+
const updated = await updateStoryField(story, 'worktree_path', undefined);
|
|
2701
|
+
await writeStory(updated);
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
/**
|
|
2705
|
+
* Security: Escape shell arguments for safe use in commands
|
|
2706
|
+
* For use with execSync when shell execution is required
|
|
2707
|
+
* @internal Exported for testing
|
|
2708
|
+
*/
|
|
2709
|
+
export function escapeShellArg(arg) {
|
|
2710
|
+
// Replace single quotes with '\'' and wrap in single quotes
|
|
2711
|
+
return `'${arg.replace(/'/g, "'\\''")}'`;
|
|
2712
|
+
}
|
|
2713
|
+
/**
|
|
2714
|
+
* List all ai-sdlc managed worktrees
|
|
2715
|
+
*/
|
|
2716
|
+
export async function listWorktrees() {
|
|
2717
|
+
const config = loadConfig();
|
|
2718
|
+
const c = getThemedChalk(config);
|
|
2719
|
+
try {
|
|
2720
|
+
const sdlcRoot = getSdlcRoot();
|
|
2721
|
+
const workingDir = path.dirname(sdlcRoot);
|
|
2722
|
+
const worktreeConfig = config.worktree ?? DEFAULT_WORKTREE_CONFIG;
|
|
2723
|
+
// Resolve worktree base path
|
|
2724
|
+
let resolvedBasePath;
|
|
2725
|
+
try {
|
|
2726
|
+
resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
|
|
2727
|
+
}
|
|
2728
|
+
catch (error) {
|
|
2729
|
+
// If basePath doesn't exist yet, create an empty list response
|
|
2730
|
+
console.log();
|
|
2731
|
+
console.log(c.bold('═══ Worktrees ═══'));
|
|
2732
|
+
console.log();
|
|
2733
|
+
console.log(c.dim('No worktrees found.'));
|
|
2734
|
+
console.log(c.dim('Use `ai-sdlc worktrees add <story-id>` to create one.'));
|
|
2735
|
+
console.log();
|
|
2736
|
+
return;
|
|
2737
|
+
}
|
|
2738
|
+
const service = new GitWorktreeService(workingDir, resolvedBasePath);
|
|
2739
|
+
const worktrees = service.list();
|
|
2740
|
+
console.log();
|
|
2741
|
+
console.log(c.bold('═══ Worktrees ═══'));
|
|
2742
|
+
console.log();
|
|
2743
|
+
if (worktrees.length === 0) {
|
|
2744
|
+
console.log(c.dim('No worktrees found.'));
|
|
2745
|
+
console.log(c.dim('Use `ai-sdlc worktrees add <story-id>` to create one.'));
|
|
2746
|
+
}
|
|
2747
|
+
else {
|
|
2748
|
+
// Table header
|
|
2749
|
+
console.log(c.dim('Story ID'.padEnd(12) + 'Branch'.padEnd(40) + 'Status'.padEnd(10) + 'Path'));
|
|
2750
|
+
console.log(c.dim('─'.repeat(80)));
|
|
2751
|
+
for (const wt of worktrees) {
|
|
2752
|
+
const storyId = wt.storyId || 'unknown';
|
|
2753
|
+
const branch = wt.branch.length > 38 ? wt.branch.substring(0, 35) + '...' : wt.branch;
|
|
2754
|
+
const status = wt.exists ? c.success('exists') : c.error('missing');
|
|
2755
|
+
const displayPath = wt.path.length > 50 ? '...' + wt.path.slice(-47) : wt.path;
|
|
2756
|
+
console.log(storyId.padEnd(12) +
|
|
2757
|
+
branch.padEnd(40) +
|
|
2758
|
+
(wt.exists ? 'exists ' : 'missing ') +
|
|
2759
|
+
displayPath);
|
|
2760
|
+
}
|
|
2761
|
+
console.log();
|
|
2762
|
+
console.log(c.dim(`Total: ${worktrees.length} worktree(s)`));
|
|
2763
|
+
}
|
|
2764
|
+
console.log();
|
|
2765
|
+
}
|
|
2766
|
+
catch (error) {
|
|
2767
|
+
console.log(c.error(`Error listing worktrees: ${error instanceof Error ? error.message : String(error)}`));
|
|
2768
|
+
process.exit(1);
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
/**
|
|
2772
|
+
* Create a worktree for a specific story
|
|
2773
|
+
*/
|
|
2774
|
+
export async function addWorktree(storyId) {
|
|
2775
|
+
const spinner = ora('Creating worktree...').start();
|
|
2776
|
+
const config = loadConfig();
|
|
2777
|
+
const c = getThemedChalk(config);
|
|
2778
|
+
try {
|
|
2779
|
+
const sdlcRoot = getSdlcRoot();
|
|
2780
|
+
const workingDir = path.dirname(sdlcRoot);
|
|
2781
|
+
if (!kanbanExists(sdlcRoot)) {
|
|
2782
|
+
spinner.fail('ai-sdlc not initialized. Run `ai-sdlc init` first.');
|
|
2783
|
+
return;
|
|
2784
|
+
}
|
|
2785
|
+
// Find the story
|
|
2786
|
+
const story = findStoryById(sdlcRoot, storyId) || findStoryBySlug(sdlcRoot, storyId);
|
|
2787
|
+
if (!story) {
|
|
2788
|
+
spinner.fail(c.error(`Story not found: "${storyId}"`));
|
|
2789
|
+
console.log(c.dim('Use `ai-sdlc status` to see available stories.'));
|
|
2790
|
+
return;
|
|
2791
|
+
}
|
|
2792
|
+
// Check if story already has a worktree
|
|
2793
|
+
if (story.frontmatter.worktree_path) {
|
|
2794
|
+
spinner.fail(c.error(`Story already has a worktree: ${story.frontmatter.worktree_path}`));
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
// Resolve worktree base path
|
|
2798
|
+
const worktreeConfig = config.worktree ?? DEFAULT_WORKTREE_CONFIG;
|
|
2799
|
+
let resolvedBasePath;
|
|
2800
|
+
try {
|
|
2801
|
+
resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
|
|
2802
|
+
}
|
|
2803
|
+
catch (error) {
|
|
2804
|
+
spinner.fail(c.error(`Configuration Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
2805
|
+
console.log(c.dim('Fix worktree.basePath in .ai-sdlc.json or remove it to use default location'));
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
const service = new GitWorktreeService(workingDir, resolvedBasePath);
|
|
2809
|
+
// Validate git state
|
|
2810
|
+
const validation = service.validateCanCreateWorktree();
|
|
2811
|
+
if (!validation.valid) {
|
|
2812
|
+
spinner.fail(c.error(validation.error || 'Cannot create worktree'));
|
|
2813
|
+
return;
|
|
2814
|
+
}
|
|
2815
|
+
// Detect base branch
|
|
2816
|
+
const baseBranch = service.detectBaseBranch();
|
|
2817
|
+
// Create the worktree
|
|
2818
|
+
const worktreePath = service.create({
|
|
2819
|
+
storyId: story.frontmatter.id,
|
|
2820
|
+
slug: story.slug,
|
|
2821
|
+
baseBranch,
|
|
2822
|
+
});
|
|
2823
|
+
// Update story frontmatter
|
|
2824
|
+
const updatedStory = await updateStoryField(story, 'worktree_path', worktreePath);
|
|
2825
|
+
const branchName = service.getBranchName(story.frontmatter.id, story.slug);
|
|
2826
|
+
const storyWithBranch = await updateStoryField(updatedStory, 'branch', branchName);
|
|
2827
|
+
await writeStory(storyWithBranch);
|
|
2828
|
+
spinner.succeed(c.success(`Created worktree for ${story.frontmatter.id}`));
|
|
2829
|
+
console.log(c.dim(` Path: ${worktreePath}`));
|
|
2830
|
+
console.log(c.dim(` Branch: ${branchName}`));
|
|
2831
|
+
console.log(c.dim(` Base: ${baseBranch}`));
|
|
2832
|
+
}
|
|
2833
|
+
catch (error) {
|
|
2834
|
+
spinner.fail(c.error('Failed to create worktree'));
|
|
2835
|
+
console.error(c.error(` ${error instanceof Error ? error.message : String(error)}`));
|
|
2836
|
+
process.exit(1);
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
/**
|
|
2840
|
+
* Remove a worktree for a specific story
|
|
2841
|
+
*/
|
|
2842
|
+
export async function removeWorktree(storyId, options) {
|
|
2843
|
+
const config = loadConfig();
|
|
2844
|
+
const c = getThemedChalk(config);
|
|
2845
|
+
try {
|
|
2846
|
+
const sdlcRoot = getSdlcRoot();
|
|
2847
|
+
const workingDir = path.dirname(sdlcRoot);
|
|
2848
|
+
if (!kanbanExists(sdlcRoot)) {
|
|
2849
|
+
console.log(c.warning('ai-sdlc not initialized. Run `ai-sdlc init` first.'));
|
|
2850
|
+
return;
|
|
2851
|
+
}
|
|
2852
|
+
// Find the story
|
|
2853
|
+
const story = findStoryById(sdlcRoot, storyId) || findStoryBySlug(sdlcRoot, storyId);
|
|
2854
|
+
if (!story) {
|
|
2855
|
+
console.log(c.error(`Story not found: "${storyId}"`));
|
|
2856
|
+
console.log(c.dim('Use `ai-sdlc status` to see available stories.'));
|
|
2857
|
+
return;
|
|
2858
|
+
}
|
|
2859
|
+
// Check if story has a worktree
|
|
2860
|
+
if (!story.frontmatter.worktree_path) {
|
|
2861
|
+
console.log(c.warning(`Story ${storyId} does not have a worktree.`));
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
const worktreePath = story.frontmatter.worktree_path;
|
|
2865
|
+
// Confirm removal (unless --force)
|
|
2866
|
+
if (!options?.force) {
|
|
2867
|
+
console.log();
|
|
2868
|
+
console.log(c.warning('About to remove worktree:'));
|
|
2869
|
+
console.log(c.dim(` Story: ${story.frontmatter.title}`));
|
|
2870
|
+
console.log(c.dim(` Path: ${worktreePath}`));
|
|
2871
|
+
console.log();
|
|
2872
|
+
const confirmed = await confirmRemoval('Are you sure you want to remove this worktree?');
|
|
2873
|
+
if (!confirmed) {
|
|
2874
|
+
console.log(c.dim('Cancelled.'));
|
|
2875
|
+
return;
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
const spinner = ora('Removing worktree...').start();
|
|
2879
|
+
// Resolve worktree base path
|
|
2880
|
+
const worktreeConfig = config.worktree ?? DEFAULT_WORKTREE_CONFIG;
|
|
2881
|
+
let resolvedBasePath;
|
|
2882
|
+
try {
|
|
2883
|
+
resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
|
|
2884
|
+
}
|
|
2885
|
+
catch {
|
|
2886
|
+
// If basePath doesn't exist, use the worktree path's parent
|
|
2887
|
+
resolvedBasePath = path.dirname(worktreePath);
|
|
2888
|
+
}
|
|
2889
|
+
const service = new GitWorktreeService(workingDir, resolvedBasePath);
|
|
2890
|
+
// Remove the worktree
|
|
2891
|
+
service.remove(worktreePath);
|
|
2892
|
+
// Clear worktree_path from frontmatter
|
|
2893
|
+
const updatedStory = await updateStoryField(story, 'worktree_path', undefined);
|
|
2894
|
+
await writeStory(updatedStory);
|
|
2895
|
+
spinner.succeed(c.success(`Removed worktree for ${story.frontmatter.id}`));
|
|
2896
|
+
console.log(c.dim(` Path: ${worktreePath}`));
|
|
2897
|
+
}
|
|
2898
|
+
catch (error) {
|
|
2899
|
+
console.log(c.error(`Failed to remove worktree: ${error instanceof Error ? error.message : String(error)}`));
|
|
2900
|
+
process.exit(1);
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
1336
2903
|
//# sourceMappingURL=commands.js.map
|