ai-sdlc 0.2.0-alpha.6 → 0.2.0-alpha.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +53 -1058
  2. package/dist/agents/implementation.d.ts +36 -1
  3. package/dist/agents/implementation.d.ts.map +1 -1
  4. package/dist/agents/implementation.js +259 -30
  5. package/dist/agents/implementation.js.map +1 -1
  6. package/dist/agents/index.d.ts +2 -0
  7. package/dist/agents/index.d.ts.map +1 -1
  8. package/dist/agents/index.js +2 -0
  9. package/dist/agents/index.js.map +1 -1
  10. package/dist/agents/orchestrator.d.ts +61 -0
  11. package/dist/agents/orchestrator.d.ts.map +1 -0
  12. package/dist/agents/orchestrator.js +443 -0
  13. package/dist/agents/orchestrator.js.map +1 -0
  14. package/dist/agents/planning.d.ts +1 -1
  15. package/dist/agents/planning.d.ts.map +1 -1
  16. package/dist/agents/planning.js +55 -4
  17. package/dist/agents/planning.js.map +1 -1
  18. package/dist/agents/refinement.d.ts.map +1 -1
  19. package/dist/agents/refinement.js +22 -3
  20. package/dist/agents/refinement.js.map +1 -1
  21. package/dist/agents/research.d.ts +85 -1
  22. package/dist/agents/research.d.ts.map +1 -1
  23. package/dist/agents/research.js +506 -16
  24. package/dist/agents/research.js.map +1 -1
  25. package/dist/agents/review.d.ts +103 -2
  26. package/dist/agents/review.d.ts.map +1 -1
  27. package/dist/agents/review.js +777 -93
  28. package/dist/agents/review.js.map +1 -1
  29. package/dist/agents/rework.d.ts.map +1 -1
  30. package/dist/agents/rework.js +25 -4
  31. package/dist/agents/rework.js.map +1 -1
  32. package/dist/agents/single-task.d.ts +41 -0
  33. package/dist/agents/single-task.d.ts.map +1 -0
  34. package/dist/agents/single-task.js +357 -0
  35. package/dist/agents/single-task.js.map +1 -0
  36. package/dist/agents/state-assessor.d.ts +3 -3
  37. package/dist/agents/state-assessor.d.ts.map +1 -1
  38. package/dist/agents/state-assessor.js +6 -6
  39. package/dist/agents/state-assessor.js.map +1 -1
  40. package/dist/agents/test-pattern-detector.d.ts +49 -0
  41. package/dist/agents/test-pattern-detector.d.ts.map +1 -0
  42. package/dist/agents/test-pattern-detector.js +273 -0
  43. package/dist/agents/test-pattern-detector.js.map +1 -0
  44. package/dist/agents/verification.d.ts +11 -0
  45. package/dist/agents/verification.d.ts.map +1 -1
  46. package/dist/agents/verification.js +99 -12
  47. package/dist/agents/verification.js.map +1 -1
  48. package/dist/cli/commands/migrate.js +1 -1
  49. package/dist/cli/commands/migrate.js.map +1 -1
  50. package/dist/cli/commands.d.ts +66 -3
  51. package/dist/cli/commands.d.ts.map +1 -1
  52. package/dist/cli/commands.js +1548 -198
  53. package/dist/cli/commands.js.map +1 -1
  54. package/dist/cli/daemon.d.ts.map +1 -1
  55. package/dist/cli/daemon.js +25 -3
  56. package/dist/cli/daemon.js.map +1 -1
  57. package/dist/cli/runner.d.ts.map +1 -1
  58. package/dist/cli/runner.js +35 -12
  59. package/dist/cli/runner.js.map +1 -1
  60. package/dist/core/auth.d.ts +43 -0
  61. package/dist/core/auth.d.ts.map +1 -1
  62. package/dist/core/auth.js +105 -1
  63. package/dist/core/auth.js.map +1 -1
  64. package/dist/core/client.d.ts +25 -1
  65. package/dist/core/client.d.ts.map +1 -1
  66. package/dist/core/client.js +247 -7
  67. package/dist/core/client.js.map +1 -1
  68. package/dist/core/config.d.ts +32 -1
  69. package/dist/core/config.d.ts.map +1 -1
  70. package/dist/core/config.js +146 -3
  71. package/dist/core/config.js.map +1 -1
  72. package/dist/core/conflict-detector.d.ts +108 -0
  73. package/dist/core/conflict-detector.d.ts.map +1 -0
  74. package/dist/core/conflict-detector.js +413 -0
  75. package/dist/core/conflict-detector.js.map +1 -0
  76. package/dist/core/git-utils.d.ts +28 -0
  77. package/dist/core/git-utils.d.ts.map +1 -0
  78. package/dist/core/git-utils.js +146 -0
  79. package/dist/core/git-utils.js.map +1 -0
  80. package/dist/core/index.d.ts +19 -0
  81. package/dist/core/index.d.ts.map +1 -0
  82. package/dist/core/index.js +19 -0
  83. package/dist/core/index.js.map +1 -0
  84. package/dist/core/kanban.d.ts +1 -1
  85. package/dist/core/kanban.d.ts.map +1 -1
  86. package/dist/core/kanban.js +3 -3
  87. package/dist/core/kanban.js.map +1 -1
  88. package/dist/core/llm-utils.d.ts +103 -0
  89. package/dist/core/llm-utils.d.ts.map +1 -0
  90. package/dist/core/llm-utils.js +368 -0
  91. package/dist/core/llm-utils.js.map +1 -0
  92. package/dist/core/logger.d.ts +92 -0
  93. package/dist/core/logger.d.ts.map +1 -0
  94. package/dist/core/logger.js +221 -0
  95. package/dist/core/logger.js.map +1 -0
  96. package/dist/core/process-manager.d.ts +15 -0
  97. package/dist/core/process-manager.d.ts.map +1 -0
  98. package/dist/core/process-manager.js +132 -0
  99. package/dist/core/process-manager.js.map +1 -0
  100. package/dist/core/story-logger.d.ts +102 -0
  101. package/dist/core/story-logger.d.ts.map +1 -0
  102. package/dist/core/story-logger.js +265 -0
  103. package/dist/core/story-logger.js.map +1 -0
  104. package/dist/core/story.d.ts +113 -20
  105. package/dist/core/story.d.ts.map +1 -1
  106. package/dist/core/story.js +328 -40
  107. package/dist/core/story.js.map +1 -1
  108. package/dist/core/task-parser.d.ts +59 -0
  109. package/dist/core/task-parser.d.ts.map +1 -0
  110. package/dist/core/task-parser.js +235 -0
  111. package/dist/core/task-parser.js.map +1 -0
  112. package/dist/core/task-progress.d.ts +92 -0
  113. package/dist/core/task-progress.d.ts.map +1 -0
  114. package/dist/core/task-progress.js +280 -0
  115. package/dist/core/task-progress.js.map +1 -0
  116. package/dist/core/workflow-state.d.ts +45 -6
  117. package/dist/core/workflow-state.d.ts.map +1 -1
  118. package/dist/core/workflow-state.js +201 -12
  119. package/dist/core/workflow-state.js.map +1 -1
  120. package/dist/core/worktree.d.ts +186 -0
  121. package/dist/core/worktree.d.ts.map +1 -0
  122. package/dist/core/worktree.js +554 -0
  123. package/dist/core/worktree.js.map +1 -0
  124. package/dist/index.js +145 -5
  125. package/dist/index.js.map +1 -1
  126. package/dist/services/error-classifier.d.ts +119 -0
  127. package/dist/services/error-classifier.d.ts.map +1 -0
  128. package/dist/services/error-classifier.js +182 -0
  129. package/dist/services/error-classifier.js.map +1 -0
  130. package/dist/types/index.d.ts +381 -1
  131. package/dist/types/index.d.ts.map +1 -1
  132. package/dist/types/index.js +1 -0
  133. package/dist/types/index.js.map +1 -1
  134. package/package.json +5 -2
  135. package/templates/story.md +5 -0
@@ -1,9 +1,12 @@
1
1
  import ora from 'ora';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
- import { getSdlcRoot, loadConfig, initConfig } from '../core/config.js';
5
- import { initializeKanban, kanbanExists, assessState, getBoardStats, findStoryBySlug } from '../core/kanban.js';
6
- import { createStory, parseStory, resetRPIVCycle, isAtMaxRetries, unblockStory, getStory, findStoryById } from '../core/story.js';
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
- const story = createStory(title, sdlcRoot);
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
  }
@@ -205,6 +340,253 @@ function generateFullSDLCActions(story, c) {
205
340
  }
206
341
  return actions;
207
342
  }
343
+ /**
344
+ * Actions that modify git and require validation
345
+ */
346
+ const GIT_MODIFYING_ACTIONS = ['implement', 'review', 'create_pr'];
347
+ /**
348
+ * Check if any actions in the list require git validation
349
+ */
350
+ function requiresGitValidation(actions) {
351
+ return actions.some(action => GIT_MODIFYING_ACTIONS.includes(action.type));
352
+ }
353
+ /**
354
+ * Determine if worktree mode should be used based on CLI flags, story frontmatter, and config.
355
+ * Priority order:
356
+ * 1. CLI --no-worktree flag (explicit disable)
357
+ * 2. CLI --worktree flag (explicit enable)
358
+ * 3. Story frontmatter.worktree_path exists (auto-enable for resuming)
359
+ * 4. Config worktree.enabled (default behavior)
360
+ */
361
+ export function determineWorktreeMode(options, worktreeConfig, targetStory) {
362
+ if (options.worktree === false)
363
+ return false;
364
+ if (options.worktree === true)
365
+ return true;
366
+ if (targetStory?.frontmatter.worktree_path)
367
+ return true;
368
+ return worktreeConfig.enabled;
369
+ }
370
+ /**
371
+ * Display git validation errors and warnings
372
+ */
373
+ function displayGitValidationResult(result, c) {
374
+ if (result.errors.length > 0) {
375
+ console.log();
376
+ console.log(c.error('Git validation failed:'));
377
+ for (const error of result.errors) {
378
+ console.log(c.error(` - ${error}`));
379
+ }
380
+ console.log();
381
+ console.log(c.info('To override this check, use --force (at your own risk)'));
382
+ }
383
+ if (result.warnings.length > 0) {
384
+ console.log();
385
+ console.log(c.warning('Git validation warnings:'));
386
+ for (const warning of result.warnings) {
387
+ console.log(c.warning(` - ${warning}`));
388
+ }
389
+ }
390
+ }
391
+ /**
392
+ * Display detailed information about an existing worktree
393
+ */
394
+ function displayExistingWorktreeInfo(status, c) {
395
+ console.log();
396
+ console.log(c.warning('A worktree already exists for this story:'));
397
+ console.log();
398
+ console.log(c.bold(' Worktree Path:'), status.path);
399
+ console.log(c.bold(' Branch: '), status.branch);
400
+ if (status.lastCommit) {
401
+ console.log(c.bold(' Last Commit: '), `${status.lastCommit.hash.substring(0, 7)} - ${status.lastCommit.message}`);
402
+ console.log(c.bold(' Committed: '), status.lastCommit.timestamp);
403
+ }
404
+ const statusLabel = status.workingDirectoryStatus === 'clean'
405
+ ? c.success('clean')
406
+ : c.warning(status.workingDirectoryStatus);
407
+ console.log(c.bold(' Working Dir: '), statusLabel);
408
+ if (status.modifiedFiles.length > 0) {
409
+ console.log();
410
+ console.log(c.warning(' Modified files:'));
411
+ for (const file of status.modifiedFiles.slice(0, 5)) {
412
+ console.log(c.dim(` M ${file}`));
413
+ }
414
+ if (status.modifiedFiles.length > 5) {
415
+ console.log(c.dim(` ... and ${status.modifiedFiles.length - 5} more`));
416
+ }
417
+ }
418
+ if (status.untrackedFiles.length > 0) {
419
+ console.log();
420
+ console.log(c.warning(' Untracked files:'));
421
+ for (const file of status.untrackedFiles.slice(0, 5)) {
422
+ console.log(c.dim(` ? ${file}`));
423
+ }
424
+ if (status.untrackedFiles.length > 5) {
425
+ console.log(c.dim(` ... and ${status.untrackedFiles.length - 5} more`));
426
+ }
427
+ }
428
+ console.log();
429
+ console.log(c.info('To resume work in this worktree:'));
430
+ console.log(c.dim(` cd ${status.path}`));
431
+ console.log();
432
+ console.log(c.info('To remove the worktree and start fresh:'));
433
+ console.log(c.dim(` ai-sdlc worktrees remove ${status.storyId}`));
434
+ console.log();
435
+ }
436
+ // ANSI escape sequence patterns for sanitization
437
+ const ANSI_CSI_PATTERN = /\x1B\[[0-9;]*[a-zA-Z]/g;
438
+ const ANSI_OSC_BEL_PATTERN = /\x1B\][^\x07]*\x07/g;
439
+ const ANSI_OSC_ESC_PATTERN = /\x1B\][^\x1B]*\x1B\\/g;
440
+ const ANSI_SINGLE_CHAR_PATTERN = /\x1B./g;
441
+ const CONTROL_CHARS_PATTERN = /[\x00-\x1F\x7F-\x9F]/g;
442
+ /**
443
+ * Sanitize a string for safe display in the terminal.
444
+ * Strips ANSI escape sequences (CSI, OSC, single-char), control characters,
445
+ * and truncates extremely long strings to prevent DoS attacks.
446
+ *
447
+ * This uses the same comprehensive ANSI stripping patterns as sanitizeReasonText
448
+ * from src/core/story.ts for consistency.
449
+ *
450
+ * @param str - The string to sanitize
451
+ * @returns Sanitized string safe for terminal display (max 500 chars)
452
+ */
453
+ function sanitizeForDisplay(str) {
454
+ const cleaned = str
455
+ .replace(ANSI_CSI_PATTERN, '') // CSI sequences (e.g., \x1B[31m)
456
+ .replace(ANSI_OSC_BEL_PATTERN, '') // OSC with BEL terminator (e.g., \x1B]...\x07)
457
+ .replace(ANSI_OSC_ESC_PATTERN, '') // OSC with ESC\ terminator (e.g., \x1B]...\x1B\\)
458
+ .replace(ANSI_SINGLE_CHAR_PATTERN, '') // Single-char escapes (e.g., \x1BH)
459
+ .replace(CONTROL_CHARS_PATTERN, ''); // Control characters (0x00-0x1F, 0x7F-0x9F)
460
+ // Truncate extremely long strings (DoS protection)
461
+ return cleaned.length > 500 ? cleaned.slice(0, 497) + '...' : cleaned;
462
+ }
463
+ /**
464
+ * Perform pre-flight conflict check before starting work on a story in a worktree.
465
+ * Warns about potential file conflicts with active stories and prompts for confirmation.
466
+ *
467
+ * **Race Condition (TOCTOU):** Multiple users can pass this check simultaneously
468
+ * before branches are created. This is an accepted risk - the window is small
469
+ * (~100ms) and git will catch conflicts during merge/PR creation. Adding file
470
+ * locks would significantly increase complexity for minimal security gain.
471
+ *
472
+ * **Security Notes:**
473
+ * - sdlcRoot is normalized and validated (absolute path, no null bytes, max 1024 chars)
474
+ * - All display output is sanitized to prevent terminal injection attacks
475
+ * - Story IDs are validated with sanitizeStoryId() then stripped with sanitizeForDisplay()
476
+ * - Error messages are generic to prevent information leakage
477
+ *
478
+ * @param targetStory - The story to check for conflicts
479
+ * @param sdlcRoot - Root directory of the .ai-sdlc folder (must be absolute, validated)
480
+ * @param options - Command options (force flag)
481
+ * @param options.force - Skip conflict check if true
482
+ * @returns PreFlightResult indicating whether to proceed and any warnings
483
+ * @throws Error if sdlcRoot is invalid (not absolute, null bytes, too long)
484
+ */
485
+ export async function preFlightConflictCheck(targetStory, sdlcRoot, options) {
486
+ const config = loadConfig();
487
+ const c = getThemedChalk(config);
488
+ // Skip if --force flag
489
+ if (options.force) {
490
+ console.log(c.warning('⚠️ Skipping conflict check (--force)'));
491
+ return { proceed: true, warnings: ['Conflict check skipped'] };
492
+ }
493
+ // Validate sdlcRoot parameter (normalize first to prevent bypass attacks)
494
+ const normalizedPath = path.normalize(sdlcRoot);
495
+ if (!path.isAbsolute(normalizedPath)) {
496
+ throw new Error('Invalid project path');
497
+ }
498
+ if (normalizedPath.includes('\0')) {
499
+ throw new Error('Invalid project path');
500
+ }
501
+ if (normalizedPath.length > 1024) {
502
+ throw new Error('Invalid project path');
503
+ }
504
+ // Check if target story is already in-progress (allow if resuming existing worktree)
505
+ if (targetStory.frontmatter.status === 'in-progress' && !targetStory.frontmatter.worktree_path) {
506
+ console.log(c.error('❌ Story is already in-progress'));
507
+ return { proceed: false, warnings: ['Story already in progress'] };
508
+ }
509
+ try {
510
+ // Query for all in-progress stories (excluding target)
511
+ // Use normalizedPath for all subsequent operations
512
+ const activeStories = findStoriesByStatus(normalizedPath, 'in-progress')
513
+ .filter(s => s.frontmatter.id !== targetStory.frontmatter.id);
514
+ if (activeStories.length === 0) {
515
+ console.log(c.success('✓ Conflict check: No overlapping files with active stories'));
516
+ return { proceed: true, warnings: [] };
517
+ }
518
+ // Run conflict detection (use normalizedPath)
519
+ const workingDir = path.dirname(normalizedPath);
520
+ const result = detectConflicts([targetStory, ...activeStories], workingDir, 'main');
521
+ // Filter conflicts involving target story
522
+ const relevantConflicts = result.conflicts.filter(conflict => conflict.storyA === targetStory.frontmatter.id || conflict.storyB === targetStory.frontmatter.id);
523
+ // Filter out 'none' severity conflicts (keep all displayable conflicts including low)
524
+ const displayableConflicts = relevantConflicts.filter(conflict => conflict.severity !== 'none');
525
+ if (displayableConflicts.length === 0) {
526
+ console.log(c.success('✓ Conflict check: No overlapping files with active stories'));
527
+ return { proceed: true, warnings: [] };
528
+ }
529
+ // Sort conflicts by severity (high -> medium -> low)
530
+ const severityOrder = { high: 0, medium: 1, low: 2, none: 3 };
531
+ const sortedConflicts = displayableConflicts.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
532
+ // Display conflicts
533
+ console.log();
534
+ console.log(c.warning('⚠️ Potential conflicts detected:'));
535
+ console.log();
536
+ for (const conflict of sortedConflicts) {
537
+ const otherStoryId = conflict.storyA === targetStory.frontmatter.id ? conflict.storyB : conflict.storyA;
538
+ // Two-stage sanitization: validate structure, then strip for display
539
+ try {
540
+ const validatedTargetId = sanitizeStoryId(targetStory.frontmatter.id);
541
+ const validatedOtherId = sanitizeStoryId(otherStoryId);
542
+ const sanitizedTargetId = sanitizeForDisplay(validatedTargetId);
543
+ const sanitizedOtherId = sanitizeForDisplay(validatedOtherId);
544
+ console.log(c.warning(` ${sanitizedTargetId} may conflict with ${sanitizedOtherId}:`));
545
+ }
546
+ catch (error) {
547
+ // If validation fails, show generic error (defensive)
548
+ console.log(c.warning(` Story may have conflicting changes (invalid ID format)`));
549
+ }
550
+ // Display shared files
551
+ for (const file of conflict.sharedFiles) {
552
+ const severityLabel = conflict.severity === 'high' ? c.error('High') :
553
+ conflict.severity === 'medium' ? c.warning('Medium') :
554
+ c.info('Low');
555
+ const sanitizedFile = sanitizeForDisplay(file);
556
+ console.log(` - ${severityLabel}: ${sanitizedFile} (both stories modify this file)`);
557
+ }
558
+ // Display shared directories
559
+ for (const dir of conflict.sharedDirectories) {
560
+ const severityLabel = conflict.severity === 'high' ? c.error('High') :
561
+ conflict.severity === 'medium' ? c.warning('Medium') :
562
+ c.info('Low');
563
+ const sanitizedDir = sanitizeForDisplay(dir);
564
+ console.log(` - ${severityLabel}: ${sanitizedDir} (both stories modify files in this directory)`);
565
+ }
566
+ console.log();
567
+ const sanitizedRecommendation = sanitizeForDisplay(conflict.recommendation);
568
+ console.log(c.dim(` Recommendation: ${sanitizedRecommendation}`));
569
+ console.log();
570
+ }
571
+ // Non-interactive mode: default to declining
572
+ if (!process.stdin.isTTY) {
573
+ console.log(c.dim('Non-interactive mode: conflicts require --force to proceed'));
574
+ return { proceed: false, warnings: ['Conflicts detected'] };
575
+ }
576
+ // Interactive mode: prompt user
577
+ const shouldContinue = await confirmRemoval('Continue anyway?');
578
+ return {
579
+ proceed: shouldContinue,
580
+ warnings: shouldContinue ? ['User confirmed with conflicts'] : ['Conflicts detected']
581
+ };
582
+ }
583
+ catch (error) {
584
+ // Fail-open: allow proceeding if conflict detection fails
585
+ console.log(c.warning('⚠️ Conflict detection unavailable'));
586
+ console.log(c.dim('Proceeding without conflict check...'));
587
+ return { proceed: true, warnings: ['Conflict detection failed'] };
588
+ }
589
+ }
208
590
  /**
209
591
  * Run the workflow (process one action or all)
210
592
  */
@@ -214,8 +596,29 @@ export async function run(options) {
214
596
  const maxIterationsOverride = options.maxIterations !== undefined
215
597
  ? parseInt(options.maxIterations, 10)
216
598
  : undefined;
217
- const sdlcRoot = getSdlcRoot();
599
+ let sdlcRoot = getSdlcRoot();
218
600
  const c = getThemedChalk(config);
601
+ const logger = getLogger();
602
+ logger.debug('workflow', 'Run command initiated', {
603
+ auto: options.auto,
604
+ dryRun: options.dryRun,
605
+ continue: options.continue,
606
+ story: options.story,
607
+ step: options.step,
608
+ watch: options.watch,
609
+ worktree: options.worktree,
610
+ clean: options.clean,
611
+ force: options.force,
612
+ });
613
+ // Migrate global workflow state to story-specific location if needed
614
+ // Only run when NOT continuing (to avoid interrupting resumed workflows)
615
+ if (!options.continue) {
616
+ const { migrateGlobalWorkflowState } = await import('../core/workflow-state.js');
617
+ const migrationResult = await migrateGlobalWorkflowState(sdlcRoot);
618
+ if (migrationResult.migrated) {
619
+ console.log(c.info(migrationResult.message));
620
+ }
621
+ }
219
622
  // Handle daemon/watch mode
220
623
  if (options.watch) {
221
624
  console.log(c.info('🚀 Starting daemon mode...'));
@@ -253,8 +656,15 @@ export async function run(options) {
253
656
  let completedActions = [];
254
657
  let storyContentHash;
255
658
  if (options.continue) {
256
- // Try to load existing state
257
- const existingState = await loadWorkflowState(sdlcRoot);
659
+ // Determine storyId for loading state
660
+ // If --story is provided, use it; otherwise, try to infer from existing state
661
+ let resumeStoryId;
662
+ // First try: use --story flag if provided
663
+ if (options.story) {
664
+ resumeStoryId = options.story;
665
+ }
666
+ // Try to load existing state (with or without storyId)
667
+ const existingState = await loadWorkflowState(sdlcRoot, resumeStoryId);
258
668
  if (!existingState) {
259
669
  console.log(c.error('Error: No checkpoint found.'));
260
670
  console.log(c.dim('Remove --continue flag to start a new workflow.'));
@@ -300,15 +710,26 @@ export async function run(options) {
300
710
  console.log();
301
711
  }
302
712
  else {
713
+ // Early validation of story ID format before any operations that use it
714
+ // This prevents sanitizeStoryId from throwing before we can show a nice error
715
+ if (options.story && !/^[a-z0-9_-]+$/i.test(options.story.toLowerCase().trim())) {
716
+ console.log(c.error('Invalid story ID format. Only letters, numbers, hyphens, and underscores are allowed.'));
717
+ return;
718
+ }
303
719
  // Check if there's an existing state and suggest --continue
304
- if (hasWorkflowState(sdlcRoot) && !options.dryRun) {
720
+ // Check both global and story-specific state
721
+ const hasGlobalState = hasWorkflowState(sdlcRoot);
722
+ const hasStoryState = options.story ? hasWorkflowState(sdlcRoot, options.story) : false;
723
+ if ((hasGlobalState || hasStoryState) && !options.dryRun) {
305
724
  console.log(c.info('Note: Found previous checkpoint. Use --continue to resume.'));
306
725
  console.log();
307
726
  }
308
727
  // Start new workflow
309
728
  workflowId = generateWorkflowId();
310
729
  }
311
- let assessment = assessState(sdlcRoot);
730
+ let assessment = await assessState(sdlcRoot);
731
+ // Hoist targetStory to outer scope so it can be reused for worktree checks
732
+ let targetStory = null;
312
733
  // Filter actions by story if --story flag is provided
313
734
  if (options.story) {
314
735
  const normalizedInput = options.story.toLowerCase().trim();
@@ -319,7 +740,7 @@ export async function run(options) {
319
740
  return;
320
741
  }
321
742
  // Try to find story by ID first, then by slug (case-insensitive)
322
- let targetStory = findStoryById(sdlcRoot, normalizedInput);
743
+ targetStory = findStoryById(sdlcRoot, normalizedInput);
323
744
  if (!targetStory) {
324
745
  targetStory = findStoryBySlug(sdlcRoot, normalizedInput);
325
746
  }
@@ -391,7 +812,8 @@ export async function run(options) {
391
812
  }
392
813
  // Clear state if workflow is complete
393
814
  if (options.continue || hasWorkflowState(sdlcRoot)) {
394
- await clearWorkflowState(sdlcRoot);
815
+ // Using options.story - action not yet created in early exit path
816
+ await clearWorkflowState(sdlcRoot, options.story);
395
817
  console.log(c.dim('Checkpoint cleared.'));
396
818
  }
397
819
  return;
@@ -432,163 +854,702 @@ export async function run(options) {
432
854
  actionsToProcess = remainingActions;
433
855
  if (actionsToProcess.length === 0) {
434
856
  console.log(c.success('All actions from checkpoint already completed!'));
435
- await clearWorkflowState(sdlcRoot);
857
+ // Using options.story - action not yet created in early exit path
858
+ await clearWorkflowState(sdlcRoot, options.story);
436
859
  console.log(c.dim('Checkpoint cleared.'));
437
860
  return;
438
861
  }
439
862
  }
440
- // Process actions with retry support for Full SDLC mode
441
- let currentActions = [...actionsToProcess];
442
- let currentActionIndex = 0;
443
- let retryAttempt = 0;
444
- const MAX_DISPLAY_RETRIES = 3; // For display purposes
445
- while (currentActionIndex < currentActions.length) {
446
- const action = currentActions[currentActionIndex];
447
- const totalActions = currentActions.length;
448
- // Enhanced progress indicator for full SDLC mode
449
- if (isFullSDLC && totalActions > 1) {
450
- const retryIndicator = retryAttempt > 0 ? ` (retry ${retryAttempt})` : '';
451
- console.log(c.info(`\n═══ Phase ${currentActionIndex + 1}/${totalActions}: ${action.type.toUpperCase()}${retryIndicator} ═══`));
452
- }
453
- const actionResult = await executeAction(action, sdlcRoot);
454
- // Handle action failure in full SDLC mode
455
- if (!actionResult.success && isFullSDLC) {
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.'));
863
+ // Handle worktree creation based on flags, config, and story frontmatter
864
+ // IMPORTANT: This must happen BEFORE git validation because:
865
+ // 1. Worktree mode allows running from protected branches (main/master)
866
+ // 2. The worktree will be created on a feature branch
867
+ let worktreePath;
868
+ let originalCwd;
869
+ let worktreeCreated = false;
870
+ // Determine if worktree should be used
871
+ // Priority: CLI flags > story frontmatter > config > default (disabled)
872
+ const worktreeConfig = config.worktree ?? DEFAULT_WORKTREE_CONFIG;
873
+ // Reuse targetStory from earlier lookup (DRY - avoids duplicate story lookup)
874
+ const shouldUseWorktree = determineWorktreeMode(options, worktreeConfig, targetStory);
875
+ // Validate that worktree mode requires --story
876
+ if (shouldUseWorktree && !options.story) {
877
+ if (options.worktree === true) {
878
+ console.log(c.error('Error: --worktree requires --story flag'));
460
879
  return;
461
880
  }
462
- // Handle review rejection in Full SDLC mode - trigger retry loop
463
- if (isFullSDLC && action.type === 'review' && actionResult.reviewResult) {
464
- const reviewResult = actionResult.reviewResult;
465
- if (reviewResult.decision === ReviewDecision.REJECTED) {
466
- // Load fresh story state and config for retry check
467
- const story = parseStory(action.storyPath);
468
- const config = loadConfig();
469
- // Check if we're at max retries (pass CLI override if provided)
470
- if (isAtMaxRetries(story, config, maxIterationsOverride)) {
471
- console.log();
472
- console.log(c.error('═'.repeat(50)));
473
- console.log(c.error(`✗ Review failed - maximum retries reached`));
474
- console.log(c.error('═'.repeat(50)));
475
- console.log(c.dim(`Story has reached the maximum retry limit.`));
476
- console.log(c.dim(`Issues found: ${reviewResult.issues.length}`));
477
- console.log(c.warning('Manual intervention required to address the review feedback.'));
478
- console.log(c.info('You can:'));
479
- console.log(c.dim(' 1. Fix issues manually and run again'));
480
- console.log(c.dim(' 2. Reset retry count in the story frontmatter'));
481
- await clearWorkflowState(sdlcRoot);
881
+ }
882
+ if (shouldUseWorktree && options.story && targetStory) {
883
+ // PRE-FLIGHT CHECK: Run conflict detection before creating worktree
884
+ const preFlightResult = await preFlightConflictCheck(targetStory, sdlcRoot, options);
885
+ if (!preFlightResult.proceed) {
886
+ console.log(c.error('❌ Aborting. Complete active stories first or use --force.'));
887
+ return;
888
+ }
889
+ // Log warnings if user proceeded despite conflicts (skip internal flag messages)
890
+ if (preFlightResult.warnings.length > 0 && !preFlightResult.warnings.includes('Conflict check skipped')) {
891
+ preFlightResult.warnings.forEach(w => console.log(c.dim(` ⚠ ${w}`)));
892
+ console.log();
893
+ }
894
+ const workingDir = path.dirname(sdlcRoot);
895
+ // Check if story already has an existing worktree (resume scenario)
896
+ // Note: We check only if existingWorktreePath is set, not if it exists.
897
+ // The validation logic will handle missing directories/branches.
898
+ const existingWorktreePath = targetStory.frontmatter.worktree_path;
899
+ if (existingWorktreePath) {
900
+ // Validate worktree before resuming
901
+ const resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
902
+ // Security validation: ensure worktree_path is within the configured base directory
903
+ const absoluteWorktreePath = path.resolve(existingWorktreePath);
904
+ const absoluteBasePath = path.resolve(resolvedBasePath);
905
+ if (!absoluteWorktreePath.startsWith(absoluteBasePath)) {
906
+ console.log(c.error('Security Error: worktree_path is outside configured base directory'));
907
+ console.log(c.dim(` Worktree path: ${absoluteWorktreePath}`));
908
+ console.log(c.dim(` Expected base: ${absoluteBasePath}`));
909
+ return;
910
+ }
911
+ // Warn if story is marked as done but has an existing worktree
912
+ if (targetStory.frontmatter.status === 'done') {
913
+ console.log(c.warning('⚠ Story is marked as done but has an existing worktree'));
914
+ console.log(c.dim(' This may be a stale worktree that should be cleaned up.'));
915
+ console.log();
916
+ // Prompt user for confirmation to proceed
917
+ const rl = readline.createInterface({
918
+ input: process.stdin,
919
+ output: process.stdout,
920
+ });
921
+ const answer = await new Promise((resolve) => {
922
+ rl.question(c.dim('Continue with this worktree? (y/N): '), (ans) => {
923
+ rl.close();
924
+ resolve(ans.toLowerCase().trim());
925
+ });
926
+ });
927
+ if (answer !== 'y' && answer !== 'yes') {
928
+ console.log(c.dim('Aborted. Consider removing the worktree_path from the story frontmatter.'));
482
929
  return;
483
930
  }
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
931
  console.log();
492
- console.log(c.warning(`⟳ Review rejected with ${reviewResult.issues.length} issue(s) - initiating rework (attempt ${currentRetry}/${maxRetriesDisplay})`));
493
- // Display executive summary
494
- const summary = generateReviewSummary(reviewResult.issues, getTerminalWidth());
495
- console.log(c.dim(` Summary: ${summary}`));
496
- // Reset the RPIV cycle (this increments retry_count and resets flags)
497
- resetRPIVCycle(story, reviewResult.feedback);
498
- // Log what's being reset
499
- console.log(c.dim(` → Reset plan_complete, implementation_complete, reviews_complete`));
500
- console.log(c.dim(` → Retry count: ${currentRetry}/${maxRetriesDisplay}`));
501
- // Regenerate actions starting from the phase that needs rework
502
- // For now, we restart from 'plan' since that's the typical flow after research
503
- const freshStory = parseStory(action.storyPath);
504
- const newActions = generateFullSDLCActions(freshStory, c);
505
- if (newActions.length > 0) {
506
- // Replace remaining actions with the new sequence
507
- currentActions = newActions;
508
- currentActionIndex = 0;
509
- retryAttempt++;
510
- console.log(c.info(` → Restarting SDLC from ${newActions[0].type} phase`));
511
- console.log();
512
- continue; // Restart the loop with new actions
932
+ }
933
+ const worktreeService = new GitWorktreeService(workingDir, resolvedBasePath);
934
+ const branchName = worktreeService.getBranchName(targetStory.frontmatter.id, targetStory.slug);
935
+ const validation = worktreeService.validateWorktreeForResume(existingWorktreePath, branchName);
936
+ if (!validation.canResume) {
937
+ console.log(c.error('Cannot resume worktree:'));
938
+ validation.issues.forEach(issue => console.log(c.dim(` ✗ ${issue}`)));
939
+ if (validation.requiresRecreation) {
940
+ const branchExists = !validation.issues.includes('Branch does not exist');
941
+ const dirMissing = validation.issues.includes('Worktree directory does not exist');
942
+ const dirExists = !dirMissing;
943
+ // Case 1: Directory missing but branch exists - recreate worktree from existing branch
944
+ // Case 2: Directory exists but branch missing - recreate with new branch
945
+ if ((branchExists && dirMissing) || (!branchExists && dirExists)) {
946
+ const reason = branchExists
947
+ ? 'Branch exists - automatically recreating worktree directory'
948
+ : 'Directory exists - automatically recreating worktree with new branch';
949
+ console.log(c.dim(`\n✓ ${reason}`));
950
+ try {
951
+ // Remove the old worktree reference if it exists
952
+ const removeResult = spawnSync('git', ['worktree', 'remove', existingWorktreePath, '--force'], {
953
+ cwd: workingDir,
954
+ encoding: 'utf-8',
955
+ shell: false,
956
+ stdio: ['ignore', 'pipe', 'pipe'],
957
+ });
958
+ // Create the worktree at the same path
959
+ // If branch exists, checkout that branch; otherwise create a new branch
960
+ const baseBranch = worktreeService.detectBaseBranch();
961
+ const worktreeAddArgs = branchExists
962
+ ? ['worktree', 'add', existingWorktreePath, branchName]
963
+ : ['worktree', 'add', '-b', branchName, existingWorktreePath, baseBranch];
964
+ const addResult = spawnSync('git', worktreeAddArgs, {
965
+ cwd: workingDir,
966
+ encoding: 'utf-8',
967
+ shell: false,
968
+ stdio: ['ignore', 'pipe', 'pipe'],
969
+ });
970
+ if (addResult.status !== 0) {
971
+ throw new Error(`Failed to recreate worktree: ${addResult.stderr}`);
972
+ }
973
+ // Install dependencies in the recreated worktree
974
+ worktreeService.installDependencies(existingWorktreePath);
975
+ console.log(c.success(`✓ Worktree recreated at ${existingWorktreePath}`));
976
+ getLogger().info('worktree', `Recreated worktree for ${targetStory.frontmatter.id} at ${existingWorktreePath}`);
977
+ }
978
+ catch (error) {
979
+ console.log(c.error(`Failed to recreate worktree: ${error instanceof Error ? error.message : String(error)}`));
980
+ console.log(c.dim('Please manually remove the worktree_path from the story frontmatter and try again.'));
981
+ return;
982
+ }
983
+ }
984
+ else {
985
+ console.log(c.dim('\nWorktree needs manual intervention. Please remove the worktree_path from the story frontmatter and try again.'));
986
+ return;
987
+ }
513
988
  }
514
989
  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
990
  return;
518
991
  }
519
992
  }
520
- }
521
- // Save checkpoint after successful action
522
- if (actionResult.success) {
523
- completedActions.push({
524
- type: action.type,
525
- storyId: action.storyId,
526
- storyPath: action.storyPath,
527
- completedAt: new Date().toISOString(),
528
- });
529
- const state = {
530
- version: '1.0',
531
- workflowId,
532
- timestamp: new Date().toISOString(),
533
- currentAction: null,
534
- completedActions,
535
- context: {
536
- sdlcRoot,
537
- options: {
538
- auto: options.auto,
539
- dryRun: options.dryRun,
540
- story: options.story,
541
- fullSDLC: isFullSDLC,
542
- },
543
- storyContentHash: calculateStoryHash(action.storyPath),
544
- },
993
+ // Reuse existing worktree
994
+ originalCwd = process.cwd();
995
+ worktreePath = existingWorktreePath;
996
+ process.chdir(worktreePath);
997
+ sdlcRoot = getSdlcRoot();
998
+ worktreeCreated = true;
999
+ // Re-load story from worktree context to get current state
1000
+ const worktreeStory = findStoryById(sdlcRoot, targetStory.frontmatter.id);
1001
+ if (worktreeStory) {
1002
+ targetStory = worktreeStory;
1003
+ }
1004
+ // Get phase information for resume context
1005
+ const lastPhase = getLastCompletedPhase(targetStory);
1006
+ const nextPhase = getNextPhase(targetStory);
1007
+ // Get worktree status for uncommitted changes info
1008
+ const worktreeInfo = {
1009
+ path: existingWorktreePath,
1010
+ branch: branchName,
1011
+ storyId: targetStory.frontmatter.id,
1012
+ exists: true,
545
1013
  };
546
- await saveWorkflowState(state, sdlcRoot);
547
- console.log(c.dim(` ✓ Progress saved (${completedActions.length} actions completed)`));
548
- }
549
- currentActionIndex++;
550
- // Re-assess after each action in auto mode
551
- if (options.auto) {
552
- // For full SDLC mode, check if all phases are complete (and review passed)
553
- if (isFullSDLC) {
554
- // Check if we've completed all actions in our sequence
555
- if (currentActionIndex >= currentActions.length) {
556
- // Verify the review actually passed (reviews_complete should be true)
557
- const finalStory = parseStory(action.storyPath);
558
- if (finalStory.frontmatter.reviews_complete) {
1014
+ const worktreeStatus = worktreeService.getWorktreeStatus(worktreeInfo);
1015
+ // Check branch divergence
1016
+ const divergence = worktreeService.checkBranchDivergence(branchName);
1017
+ console.log(c.success(`✓ Resuming in existing worktree: ${worktreePath}`));
1018
+ console.log(c.dim(` Branch: ${branchName}`));
1019
+ if (lastPhase) {
1020
+ console.log(c.dim(` Last completed phase: ${lastPhase}`));
1021
+ }
1022
+ if (nextPhase) {
1023
+ console.log(c.dim(` Next phase: ${nextPhase}`));
1024
+ }
1025
+ // Display uncommitted changes if present
1026
+ if (worktreeStatus.workingDirectoryStatus !== 'clean') {
1027
+ const totalChanges = worktreeStatus.modifiedFiles.length + worktreeStatus.untrackedFiles.length;
1028
+ console.log(c.dim(` Uncommitted changes: ${totalChanges} file(s)`));
1029
+ if (worktreeStatus.modifiedFiles.length > 0) {
1030
+ console.log(c.dim(` Modified: ${worktreeStatus.modifiedFiles.slice(0, 3).join(', ')}${worktreeStatus.modifiedFiles.length > 3 ? '...' : ''}`));
1031
+ }
1032
+ if (worktreeStatus.untrackedFiles.length > 0) {
1033
+ console.log(c.dim(` Untracked: ${worktreeStatus.untrackedFiles.slice(0, 3).join(', ')}${worktreeStatus.untrackedFiles.length > 3 ? '...' : ''}`));
1034
+ }
1035
+ }
1036
+ // Warn if branch has diverged significantly
1037
+ if (divergence.diverged && (divergence.ahead > DIVERGENCE_WARNING_THRESHOLD || divergence.behind > DIVERGENCE_WARNING_THRESHOLD)) {
1038
+ console.log(c.warning(` ⚠ Branch has diverged from base: ${divergence.ahead} ahead, ${divergence.behind} behind`));
1039
+ console.log(c.dim(` Consider rebasing to sync with latest changes`));
1040
+ }
1041
+ console.log();
1042
+ // Log resume event
1043
+ getLogger().info('worktree', `Resumed worktree for ${targetStory.frontmatter.id} at ${worktreePath}`);
1044
+ }
1045
+ else {
1046
+ // Create new worktree
1047
+ // Resolve worktree base path from config
1048
+ let resolvedBasePath;
1049
+ try {
1050
+ resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
1051
+ }
1052
+ catch (error) {
1053
+ console.log(c.error(`Configuration Error: ${error instanceof Error ? error.message : String(error)}`));
1054
+ console.log(c.dim('Fix worktree.basePath in .ai-sdlc.json or remove it to use default location'));
1055
+ return;
1056
+ }
1057
+ const worktreeService = new GitWorktreeService(workingDir, resolvedBasePath);
1058
+ // Check for existing worktree NOT recorded in story frontmatter
1059
+ // This catches scenarios where workflow was interrupted after worktree creation
1060
+ // but before the story file was updated
1061
+ const existingWorktree = worktreeService.findByStoryId(targetStory.frontmatter.id);
1062
+ let shouldCreateNewWorktree = !existingWorktree || !existingWorktree.exists;
1063
+ if (existingWorktree && existingWorktree.exists) {
1064
+ // Handle --clean flag: cleanup and restart
1065
+ if (options.clean) {
1066
+ console.log(c.warning('Existing worktree found - cleaning up before restart...'));
1067
+ console.log();
1068
+ const worktreeStatus = worktreeService.getWorktreeStatus(existingWorktree);
1069
+ const unpushedResult = worktreeService.hasUnpushedCommits(existingWorktree.path);
1070
+ const commitCount = worktreeService.getCommitCount(existingWorktree.path);
1071
+ const branchOnRemote = worktreeService.branchExistsOnRemote(existingWorktree.branch);
1072
+ // Display summary of what will be deleted
1073
+ console.log(c.bold('Cleanup Summary:'));
1074
+ console.log(c.dim('─'.repeat(60)));
1075
+ console.log(`${c.dim('Worktree Path:')} ${worktreeStatus.path}`);
1076
+ console.log(`${c.dim('Branch:')} ${worktreeStatus.branch}`);
1077
+ console.log(`${c.dim('Total Commits:')} ${commitCount}`);
1078
+ console.log(`${c.dim('Unpushed Commits:')} ${unpushedResult.hasUnpushed ? c.warning(unpushedResult.count.toString()) : c.success('0')}`);
1079
+ console.log(`${c.dim('Modified Files:')} ${worktreeStatus.modifiedFiles.length > 0 ? c.warning(worktreeStatus.modifiedFiles.length.toString()) : c.success('0')}`);
1080
+ console.log(`${c.dim('Untracked Files:')} ${worktreeStatus.untrackedFiles.length > 0 ? c.warning(worktreeStatus.untrackedFiles.length.toString()) : c.success('0')}`);
1081
+ console.log(`${c.dim('Remote Branch:')} ${branchOnRemote ? c.warning('EXISTS') : c.dim('none')}`);
1082
+ console.log();
1083
+ // Warn about data loss
1084
+ if (worktreeStatus.modifiedFiles.length > 0 || worktreeStatus.untrackedFiles.length > 0 || unpushedResult.hasUnpushed) {
1085
+ console.log(c.error('⚠ WARNING: This will DELETE all uncommitted and unpushed work!'));
559
1086
  console.log();
560
- console.log(c.success('═'.repeat(50)));
561
- console.log(c.success(`✓ Full SDLC completed successfully!`));
562
- console.log(c.success('═'.repeat(50)));
563
- console.log(c.dim(`Completed phases: ${currentActions.length}`));
564
- if (retryAttempt > 0) {
565
- console.log(c.dim(`Retry attempts: ${retryAttempt}`));
1087
+ }
1088
+ // Check for --force flag to skip confirmation
1089
+ const forceCleanup = options.force;
1090
+ if (!forceCleanup) {
1091
+ // Prompt for confirmation
1092
+ const confirmed = await new Promise((resolve) => {
1093
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1094
+ rl.question(c.warning('Are you sure you want to proceed? (y/N): '), (answer) => {
1095
+ rl.close();
1096
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
1097
+ });
1098
+ });
1099
+ if (!confirmed) {
1100
+ console.log(c.info('Cleanup cancelled.'));
1101
+ return;
566
1102
  }
567
- console.log(c.dim(`Story is now ready for PR creation.`));
568
- await clearWorkflowState(sdlcRoot);
569
- console.log(c.dim('Checkpoint cleared.'));
1103
+ }
1104
+ console.log();
1105
+ const cleanupSpinner = ora('Cleaning up worktree...').start();
1106
+ try {
1107
+ // Remove worktree (force remove to handle uncommitted changes)
1108
+ const forceRemove = worktreeStatus.modifiedFiles.length > 0 || worktreeStatus.untrackedFiles.length > 0;
1109
+ worktreeService.remove(existingWorktree.path, forceRemove);
1110
+ cleanupSpinner.text = 'Worktree removed, deleting branch...';
1111
+ // Delete local branch
1112
+ worktreeService.deleteBranch(existingWorktree.branch, true);
1113
+ // Optionally delete remote branch if it exists
1114
+ if (branchOnRemote) {
1115
+ if (!forceCleanup) {
1116
+ cleanupSpinner.stop();
1117
+ const deleteRemote = await new Promise((resolve) => {
1118
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1119
+ rl.question(c.warning('Branch exists on remote. Delete it too? (y/N): '), (answer) => {
1120
+ rl.close();
1121
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
1122
+ });
1123
+ });
1124
+ if (deleteRemote) {
1125
+ cleanupSpinner.start('Deleting remote branch...');
1126
+ worktreeService.deleteRemoteBranch(existingWorktree.branch);
1127
+ }
1128
+ cleanupSpinner.start();
1129
+ }
1130
+ else {
1131
+ // --force provided, skip remote deletion by default (safer)
1132
+ cleanupSpinner.text = 'Skipping remote branch deletion (use manual cleanup if needed)';
1133
+ }
1134
+ }
1135
+ // Reset story workflow state
1136
+ cleanupSpinner.text = 'Resetting story state...';
1137
+ const { resetWorkflowState } = await import('../core/story.js');
1138
+ targetStory = await resetWorkflowState(targetStory);
1139
+ // Clear workflow checkpoint if exists
1140
+ if (hasWorkflowState(sdlcRoot, targetStory.frontmatter.id)) {
1141
+ await clearWorkflowState(sdlcRoot, targetStory.frontmatter.id);
1142
+ }
1143
+ cleanupSpinner.succeed(c.success('✓ Cleanup complete - ready to create fresh worktree'));
1144
+ console.log();
1145
+ }
1146
+ catch (error) {
1147
+ cleanupSpinner.fail(c.error('Cleanup failed'));
1148
+ console.log(c.error(`Error: ${error instanceof Error ? error.message : String(error)}`));
1149
+ return;
1150
+ }
1151
+ // After cleanup, create a fresh worktree
1152
+ shouldCreateNewWorktree = true;
1153
+ }
1154
+ else {
1155
+ // Not cleaning - resume in existing worktree (S-0063 feature)
1156
+ getLogger().info('worktree', `Detected existing worktree for ${targetStory.frontmatter.id} at ${existingWorktree.path}`);
1157
+ // Validate the existing worktree before resuming
1158
+ const branchName = worktreeService.getBranchName(targetStory.frontmatter.id, targetStory.slug);
1159
+ const validation = worktreeService.validateWorktreeForResume(existingWorktree.path, branchName);
1160
+ if (!validation.canResume) {
1161
+ console.log(c.error('Detected existing worktree but cannot resume:'));
1162
+ validation.issues.forEach(issue => console.log(c.dim(` ✗ ${issue}`)));
1163
+ if (validation.requiresRecreation) {
1164
+ const branchExists = !validation.issues.includes('Branch does not exist');
1165
+ const dirMissing = validation.issues.includes('Worktree directory does not exist');
1166
+ const dirExists = !dirMissing;
1167
+ // Case 1: Directory missing but branch exists - recreate worktree from existing branch
1168
+ // Case 2: Directory exists but branch missing - recreate with new branch
1169
+ if ((branchExists && dirMissing) || (!branchExists && dirExists)) {
1170
+ const reason = branchExists
1171
+ ? 'Branch exists - automatically recreating worktree directory'
1172
+ : 'Directory exists - automatically recreating worktree with new branch';
1173
+ console.log(c.dim(`\n✓ ${reason}`));
1174
+ try {
1175
+ // Remove the old worktree reference if it exists
1176
+ const removeResult = spawnSync('git', ['worktree', 'remove', existingWorktree.path, '--force'], {
1177
+ cwd: workingDir,
1178
+ encoding: 'utf-8',
1179
+ shell: false,
1180
+ stdio: ['ignore', 'pipe', 'pipe'],
1181
+ });
1182
+ // Create the worktree at the same path
1183
+ // If branch exists, checkout that branch; otherwise create a new branch
1184
+ const baseBranch = worktreeService.detectBaseBranch();
1185
+ const worktreeAddArgs = branchExists
1186
+ ? ['worktree', 'add', existingWorktree.path, branchName]
1187
+ : ['worktree', 'add', '-b', branchName, existingWorktree.path, baseBranch];
1188
+ const addResult = spawnSync('git', worktreeAddArgs, {
1189
+ cwd: workingDir,
1190
+ encoding: 'utf-8',
1191
+ shell: false,
1192
+ stdio: ['ignore', 'pipe', 'pipe'],
1193
+ });
1194
+ if (addResult.status !== 0) {
1195
+ throw new Error(`Failed to recreate worktree: ${addResult.stderr}`);
1196
+ }
1197
+ // Install dependencies in the recreated worktree
1198
+ worktreeService.installDependencies(existingWorktree.path);
1199
+ console.log(c.success(`✓ Worktree recreated at ${existingWorktree.path}`));
1200
+ getLogger().info('worktree', `Recreated worktree for ${targetStory.frontmatter.id} at ${existingWorktree.path}`);
1201
+ }
1202
+ catch (error) {
1203
+ console.log(c.error(`Failed to recreate worktree: ${error instanceof Error ? error.message : String(error)}`));
1204
+ console.log(c.dim('Please manually remove it with:'));
1205
+ console.log(c.dim(` git worktree remove ${existingWorktree.path}`));
1206
+ return;
1207
+ }
1208
+ }
1209
+ else {
1210
+ console.log(c.dim('\nWorktree needs manual intervention. Please remove it manually with:'));
1211
+ console.log(c.dim(` git worktree remove ${existingWorktree.path}`));
1212
+ return;
1213
+ }
1214
+ }
1215
+ else {
1216
+ return;
1217
+ }
1218
+ }
1219
+ // Automatically resume in the existing worktree
1220
+ originalCwd = process.cwd();
1221
+ worktreePath = existingWorktree.path;
1222
+ process.chdir(worktreePath);
1223
+ sdlcRoot = getSdlcRoot();
1224
+ worktreeCreated = true;
1225
+ // Update story frontmatter with worktree path (sync state)
1226
+ const worktreeStory = findStoryById(sdlcRoot, targetStory.frontmatter.id);
1227
+ if (worktreeStory) {
1228
+ const updatedStory = await updateStoryField(worktreeStory, 'worktree_path', worktreePath);
1229
+ await writeStory(updatedStory);
1230
+ targetStory = updatedStory;
1231
+ }
1232
+ // Get phase information for resume context
1233
+ const lastPhase = getLastCompletedPhase(targetStory);
1234
+ const nextPhase = getNextPhase(targetStory);
1235
+ // Get worktree status for uncommitted changes info
1236
+ const worktreeStatus = worktreeService.getWorktreeStatus(existingWorktree);
1237
+ // Check branch divergence
1238
+ const divergence = worktreeService.checkBranchDivergence(branchName);
1239
+ console.log(c.success(`✓ Resuming in existing worktree: ${worktreePath}`));
1240
+ console.log(c.dim(` Branch: ${branchName}`));
1241
+ console.log(c.dim(` (Worktree path synced to story frontmatter)`));
1242
+ if (lastPhase) {
1243
+ console.log(c.dim(` Last completed phase: ${lastPhase}`));
1244
+ }
1245
+ if (nextPhase) {
1246
+ console.log(c.dim(` Next phase: ${nextPhase}`));
1247
+ }
1248
+ // Display uncommitted changes if present
1249
+ if (worktreeStatus.workingDirectoryStatus !== 'clean') {
1250
+ const totalChanges = worktreeStatus.modifiedFiles.length + worktreeStatus.untrackedFiles.length;
1251
+ console.log(c.dim(` Uncommitted changes: ${totalChanges} file(s)`));
1252
+ if (worktreeStatus.modifiedFiles.length > 0) {
1253
+ console.log(c.dim(` Modified: ${worktreeStatus.modifiedFiles.slice(0, 3).join(', ')}${worktreeStatus.modifiedFiles.length > 3 ? '...' : ''}`));
1254
+ }
1255
+ if (worktreeStatus.untrackedFiles.length > 0) {
1256
+ console.log(c.dim(` Untracked: ${worktreeStatus.untrackedFiles.slice(0, 3).join(', ')}${worktreeStatus.untrackedFiles.length > 3 ? '...' : ''}`));
1257
+ }
1258
+ }
1259
+ // Warn if branch has diverged significantly
1260
+ if (divergence.diverged && (divergence.ahead > DIVERGENCE_WARNING_THRESHOLD || divergence.behind > DIVERGENCE_WARNING_THRESHOLD)) {
1261
+ console.log(c.warning(` ⚠ Branch has diverged from base: ${divergence.ahead} ahead, ${divergence.behind} behind`));
1262
+ console.log(c.dim(` Consider rebasing to sync with latest changes`));
1263
+ }
1264
+ console.log();
1265
+ }
1266
+ }
1267
+ if (shouldCreateNewWorktree) {
1268
+ // Validate git state for worktree creation
1269
+ const validation = worktreeService.validateCanCreateWorktree();
1270
+ if (!validation.valid) {
1271
+ console.log(c.error(`Error: ${validation.error}`));
1272
+ return;
1273
+ }
1274
+ try {
1275
+ // Detect base branch
1276
+ const baseBranch = worktreeService.detectBaseBranch();
1277
+ // Create worktree
1278
+ originalCwd = process.cwd();
1279
+ worktreePath = worktreeService.create({
1280
+ storyId: targetStory.frontmatter.id,
1281
+ slug: targetStory.slug,
1282
+ baseBranch,
1283
+ });
1284
+ // Change to worktree directory BEFORE updating story
1285
+ // This ensures story updates happen in the worktree, not on main
1286
+ // (allows parallel story launches from clean main)
1287
+ process.chdir(worktreePath);
1288
+ // Recalculate sdlcRoot for the worktree context
1289
+ sdlcRoot = getSdlcRoot();
1290
+ worktreeCreated = true;
1291
+ // Now update story frontmatter with worktree path (writes to worktree copy)
1292
+ // Re-resolve target story in worktree context
1293
+ const worktreeStory = findStoryById(sdlcRoot, targetStory.frontmatter.id);
1294
+ if (worktreeStory) {
1295
+ const updatedStory = await updateStoryField(worktreeStory, 'worktree_path', worktreePath);
1296
+ await writeStory(updatedStory);
1297
+ // Update targetStory reference for downstream use
1298
+ targetStory = updatedStory;
1299
+ }
1300
+ console.log(c.success(`✓ Created worktree at: ${worktreePath}`));
1301
+ console.log(c.dim(` Branch: ai-sdlc/${targetStory.frontmatter.id}-${targetStory.slug}`));
1302
+ console.log();
1303
+ }
1304
+ catch (error) {
1305
+ // Restore directory on worktree creation failure
1306
+ if (originalCwd) {
1307
+ process.chdir(originalCwd);
1308
+ }
1309
+ console.log(c.error(`Failed to create worktree: ${error instanceof Error ? error.message : String(error)}`));
1310
+ return;
1311
+ }
1312
+ }
1313
+ }
1314
+ }
1315
+ // Validate git state before processing actions that modify git
1316
+ // Skip protected branch check if worktree mode is active (worktree is on feature branch)
1317
+ // Skip clean check entirely when worktree was just created:
1318
+ // - The worktree starts from a clean base branch
1319
+ // - npm install may modify package-lock.json
1320
+ // - Story file was just updated with worktree_path
1321
+ // - There's no prior user work to protect in a fresh worktree
1322
+ if (!options.force && requiresGitValidation(actionsToProcess)) {
1323
+ const workingDir = path.dirname(sdlcRoot);
1324
+ const gitValidationOptions = worktreeCreated
1325
+ ? { skipBranchCheck: true, skipCleanCheck: true }
1326
+ : {};
1327
+ const gitValidation = validateGitState(workingDir, gitValidationOptions);
1328
+ if (!gitValidation.valid) {
1329
+ displayGitValidationResult(gitValidation, c);
1330
+ if (worktreeCreated && originalCwd) {
1331
+ process.chdir(originalCwd);
1332
+ }
1333
+ return;
1334
+ }
1335
+ if (gitValidation.warnings.length > 0) {
1336
+ displayGitValidationResult(gitValidation, c);
1337
+ console.log();
1338
+ }
1339
+ }
1340
+ // Process actions with retry support for Full SDLC mode
1341
+ let currentActions = [...actionsToProcess];
1342
+ let currentActionIndex = 0;
1343
+ let retryAttempt = 0;
1344
+ const MAX_DISPLAY_RETRIES = 3; // For display purposes
1345
+ try {
1346
+ while (currentActionIndex < currentActions.length) {
1347
+ const action = currentActions[currentActionIndex];
1348
+ const totalActions = currentActions.length;
1349
+ // Enhanced progress indicator for full SDLC mode
1350
+ if (isFullSDLC && totalActions > 1) {
1351
+ const retryIndicator = retryAttempt > 0 ? ` (retry ${retryAttempt})` : '';
1352
+ console.log(c.info(`\n═══ Phase ${currentActionIndex + 1}/${totalActions}: ${action.type.toUpperCase()}${retryIndicator} ═══`));
1353
+ }
1354
+ const actionResult = await executeAction(action, sdlcRoot);
1355
+ // Handle action failure in full SDLC mode
1356
+ if (!actionResult.success && isFullSDLC) {
1357
+ console.log();
1358
+ console.log(c.error(`✗ Phase ${action.type} failed`));
1359
+ console.log(c.dim(`Completed ${currentActionIndex} of ${totalActions} phases`));
1360
+ console.log(c.info('Fix the error above and use --continue to resume.'));
1361
+ return;
1362
+ }
1363
+ // Handle review rejection in Full SDLC mode - trigger retry loop
1364
+ if (isFullSDLC && action.type === 'review' && actionResult.reviewResult) {
1365
+ const reviewResult = actionResult.reviewResult;
1366
+ if (reviewResult.decision === ReviewDecision.REJECTED) {
1367
+ // Load fresh story state and config for retry check
1368
+ const story = parseStory(action.storyPath);
1369
+ const config = loadConfig();
1370
+ // Check if we're at max retries (pass CLI override if provided)
1371
+ if (isAtMaxRetries(story, config, maxIterationsOverride)) {
1372
+ console.log();
1373
+ console.log(c.error('═'.repeat(50)));
1374
+ console.log(c.error(`✗ Review failed - maximum retries reached`));
1375
+ console.log(c.error('═'.repeat(50)));
1376
+ console.log(c.dim(`Story has reached the maximum retry limit.`));
1377
+ console.log(c.dim(`Issues found: ${reviewResult.issues.length}`));
1378
+ console.log(c.warning('Manual intervention required to address the review feedback.'));
1379
+ console.log(c.info('You can:'));
1380
+ console.log(c.dim(' 1. Fix issues manually and run again'));
1381
+ console.log(c.dim(' 2. Reset retry count in the story frontmatter'));
1382
+ // Using action.storyId - available from action loop context
1383
+ await clearWorkflowState(sdlcRoot, action.storyId);
1384
+ return;
1385
+ }
1386
+ // We can retry - reset RPIV cycle and loop back
1387
+ const currentRetry = (story.frontmatter.retry_count || 0) + 1;
1388
+ // Use CLI override, then story-specific, then config default
1389
+ const effectiveMaxRetries = maxIterationsOverride !== undefined
1390
+ ? maxIterationsOverride
1391
+ : (story.frontmatter.max_retries ?? config.reviewConfig?.maxRetries ?? Infinity);
1392
+ const maxRetriesDisplay = Number.isFinite(effectiveMaxRetries) ? effectiveMaxRetries : '∞';
1393
+ console.log();
1394
+ console.log(c.warning(`⟳ Review rejected with ${reviewResult.issues.length} issue(s) - initiating rework (attempt ${currentRetry}/${maxRetriesDisplay})`));
1395
+ // Display executive summary
1396
+ const summary = generateReviewSummary(reviewResult.issues, getTerminalWidth());
1397
+ console.log(c.dim(` Summary: ${summary}`));
1398
+ // Reset the RPIV cycle (this increments retry_count and resets flags)
1399
+ await resetRPIVCycle(story, reviewResult.feedback);
1400
+ // Log what's being reset
1401
+ console.log(c.dim(` → Reset plan_complete, implementation_complete, reviews_complete`));
1402
+ console.log(c.dim(` → Retry count: ${currentRetry}/${maxRetriesDisplay}`));
1403
+ // Regenerate actions starting from the phase that needs rework
1404
+ // For now, we restart from 'plan' since that's the typical flow after research
1405
+ const freshStory = parseStory(action.storyPath);
1406
+ const newActions = generateFullSDLCActions(freshStory, c);
1407
+ if (newActions.length > 0) {
1408
+ // Replace remaining actions with the new sequence
1409
+ currentActions = newActions;
1410
+ currentActionIndex = 0;
1411
+ retryAttempt++;
1412
+ console.log(c.info(` → Restarting SDLC from ${newActions[0].type} phase`));
1413
+ console.log();
1414
+ continue; // Restart the loop with new actions
570
1415
  }
571
1416
  else {
572
- // This shouldn't happen if our logic is correct, but handle it
1417
+ // No actions to retry (shouldn't happen but handle gracefully)
1418
+ console.log(c.error('Error: No actions generated for retry. Manual intervention required.'));
1419
+ return;
1420
+ }
1421
+ }
1422
+ else if (reviewResult.decision === ReviewDecision.RECOVERY) {
1423
+ // Implementation recovery: reset implementation_complete and increment implementation retry count
1424
+ // This is distinct from REJECTED which resets the entire RPIV cycle
1425
+ const story = parseStory(action.storyPath);
1426
+ const config = loadConfig();
1427
+ const retryCount = story.frontmatter.implementation_retry_count || 0;
1428
+ const maxRetries = getEffectiveMaxImplementationRetries(story, config);
1429
+ const maxRetriesDisplay = Number.isFinite(maxRetries) ? maxRetries : '∞';
1430
+ console.log();
1431
+ console.log(c.warning(`🔄 Implementation recovery triggered (attempt ${retryCount + 1}/${maxRetriesDisplay})`));
1432
+ console.log(c.dim(` Reason: ${story.frontmatter.last_restart_reason || 'No source code changes detected'}`));
1433
+ // Increment implementation retry count
1434
+ await incrementImplementationRetryCount(story);
1435
+ // Check if we've exceeded max implementation retries after incrementing
1436
+ const freshStory = parseStory(action.storyPath);
1437
+ if (isAtMaxImplementationRetries(freshStory, config)) {
573
1438
  console.log();
574
- console.log(c.warning('All phases executed but reviews_complete is false.'));
575
- console.log(c.dim('This may indicate an issue with the review process.'));
1439
+ console.log(c.error('═'.repeat(50)));
1440
+ console.log(c.error(`✗ Implementation recovery failed - maximum retries reached`));
1441
+ console.log(c.error('═'.repeat(50)));
1442
+ console.log(c.dim(`Story has reached the maximum implementation retry limit (${maxRetries}).`));
1443
+ console.log(c.warning('Marking story as blocked. Manual intervention required.'));
1444
+ // Mark story as blocked
1445
+ await updateStoryStatus(freshStory, 'blocked');
1446
+ console.log(c.info('Story status updated to: blocked'));
1447
+ await clearWorkflowState(sdlcRoot, action.storyId);
1448
+ process.exit(1);
576
1449
  }
577
- break;
1450
+ // Regenerate actions to restart from implementation phase
1451
+ const newActions = generateFullSDLCActions(freshStory, c);
1452
+ if (newActions.length > 0) {
1453
+ currentActions = newActions;
1454
+ currentActionIndex = 0;
1455
+ console.log(c.info(` → Restarting from ${newActions[0].type} phase`));
1456
+ console.log();
1457
+ continue; // Restart the loop with new actions
1458
+ }
1459
+ else {
1460
+ console.log(c.error('Error: No actions generated for recovery. Manual intervention required.'));
1461
+ process.exit(1);
1462
+ }
1463
+ }
1464
+ else if (reviewResult.decision === ReviewDecision.FAILED) {
1465
+ // Review agent failed - don't increment retry count
1466
+ console.log();
1467
+ console.log(c.error(`✗ Review process failed: ${reviewResult.error || 'Unknown error'}`));
1468
+ console.log(c.warning('This does not count as a retry attempt. You can retry manually.'));
1469
+ await clearWorkflowState(sdlcRoot, action.storyId);
1470
+ process.exit(1);
578
1471
  }
579
1472
  }
580
- else {
581
- // Normal auto mode: re-assess state
582
- const newAssessment = assessState(sdlcRoot);
583
- if (newAssessment.recommendedActions.length === 0) {
584
- console.log(c.success('\n✓ All actions completed!'));
585
- await clearWorkflowState(sdlcRoot);
586
- console.log(c.dim('Checkpoint cleared.'));
587
- break;
1473
+ // Save checkpoint after successful action
1474
+ if (actionResult.success) {
1475
+ completedActions.push({
1476
+ type: action.type,
1477
+ storyId: action.storyId,
1478
+ storyPath: action.storyPath,
1479
+ completedAt: new Date().toISOString(),
1480
+ });
1481
+ const state = {
1482
+ version: '1.0',
1483
+ workflowId,
1484
+ timestamp: new Date().toISOString(),
1485
+ currentAction: null,
1486
+ completedActions,
1487
+ context: {
1488
+ sdlcRoot,
1489
+ options: {
1490
+ auto: options.auto,
1491
+ dryRun: options.dryRun,
1492
+ story: options.story,
1493
+ fullSDLC: isFullSDLC,
1494
+ },
1495
+ storyContentHash: calculateStoryHash(action.storyPath),
1496
+ },
1497
+ };
1498
+ await saveWorkflowState(state, sdlcRoot, action.storyId);
1499
+ console.log(c.dim(` ✓ Progress saved (${completedActions.length} actions completed)`));
1500
+ }
1501
+ currentActionIndex++;
1502
+ // Re-assess after each action in auto mode
1503
+ if (options.auto) {
1504
+ // For full SDLC mode, check if all phases are complete (and review passed)
1505
+ if (isFullSDLC) {
1506
+ // Check if we've completed all actions in our sequence
1507
+ if (currentActionIndex >= currentActions.length) {
1508
+ // Verify the review actually passed (reviews_complete should be true)
1509
+ const finalStory = parseStory(action.storyPath);
1510
+ if (finalStory.frontmatter.reviews_complete) {
1511
+ console.log();
1512
+ console.log(c.success('═'.repeat(50)));
1513
+ console.log(c.success(`✓ Full SDLC completed successfully!`));
1514
+ console.log(c.success('═'.repeat(50)));
1515
+ console.log(c.dim(`Completed phases: ${currentActions.length}`));
1516
+ if (retryAttempt > 0) {
1517
+ console.log(c.dim(`Retry attempts: ${retryAttempt}`));
1518
+ }
1519
+ console.log(c.dim(`Story is now ready for PR creation.`));
1520
+ // Using action.storyId - available from action loop context
1521
+ await clearWorkflowState(sdlcRoot, action.storyId);
1522
+ console.log(c.dim('Checkpoint cleared.'));
1523
+ }
1524
+ else {
1525
+ // This shouldn't happen if our logic is correct, but handle it
1526
+ console.log();
1527
+ console.log(c.warning('All phases executed but reviews_complete is false.'));
1528
+ console.log(c.dim('This may indicate an issue with the review process.'));
1529
+ }
1530
+ break;
1531
+ }
1532
+ }
1533
+ else {
1534
+ // Normal auto mode: re-assess state
1535
+ const newAssessment = await assessState(sdlcRoot);
1536
+ if (newAssessment.recommendedActions.length === 0) {
1537
+ console.log(c.success('\n✓ All actions completed!'));
1538
+ // Using action.storyId - available from action loop context
1539
+ await clearWorkflowState(sdlcRoot, action.storyId);
1540
+ console.log(c.dim('Checkpoint cleared.'));
1541
+ break;
1542
+ }
588
1543
  }
589
1544
  }
590
1545
  }
591
1546
  }
1547
+ finally {
1548
+ // Restore original working directory if worktree was used
1549
+ if (originalCwd) {
1550
+ process.chdir(originalCwd);
1551
+ }
1552
+ }
592
1553
  }
593
1554
  /**
594
1555
  * Execute a specific action
@@ -598,61 +1559,91 @@ export async function run(options) {
598
1559
  async function executeAction(action, sdlcRoot) {
599
1560
  const config = loadConfig();
600
1561
  const c = getThemedChalk(config);
601
- // Resolve story by ID to get current path (handles moves between folders)
602
- let resolvedPath;
1562
+ const globalLogger = getLogger();
1563
+ const actionStartTime = Date.now();
1564
+ // Log action start to global logger
1565
+ globalLogger.info('action', `Starting action: ${action.type}`, {
1566
+ storyId: action.storyId,
1567
+ actionType: action.type,
1568
+ storyPath: action.storyPath,
1569
+ });
1570
+ // Initialize per-story logger
1571
+ const maxLogs = config.logging?.maxFiles ?? 5;
1572
+ let storyLogger = null;
1573
+ let spinner = null;
603
1574
  try {
604
- const story = getStory(sdlcRoot, action.storyId);
605
- resolvedPath = story.path;
1575
+ storyLogger = new StoryLogger(action.storyId, sdlcRoot, maxLogs);
1576
+ storyLogger.log('INFO', `Starting action: ${action.type} for story ${action.storyId}`);
606
1577
  }
607
1578
  catch (error) {
608
- console.log(c.error(`Error: Story not found for action "${action.type}"`));
609
- console.log(c.dim(` Story ID: ${action.storyId}`));
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 };
1579
+ // If logger initialization fails, continue without logging (console-only)
1580
+ console.warn(`Warning: Failed to initialize logger: ${error instanceof Error ? error.message : String(error)}`);
615
1581
  }
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
1582
  try {
1583
+ // Resolve story by ID to get current path (handles moves between folders)
1584
+ let resolvedPath;
1585
+ try {
1586
+ const story = getStory(sdlcRoot, action.storyId);
1587
+ resolvedPath = story.path;
1588
+ }
1589
+ catch (error) {
1590
+ const errorMsg = `Error: Story not found for action "${action.type}"`;
1591
+ storyLogger?.log('ERROR', errorMsg);
1592
+ storyLogger?.log('ERROR', ` Story ID: ${action.storyId}`);
1593
+ storyLogger?.log('ERROR', ` Original path: ${action.storyPath}`);
1594
+ console.log(c.error(errorMsg));
1595
+ console.log(c.dim(` Story ID: ${action.storyId}`));
1596
+ console.log(c.dim(` Original path: ${action.storyPath}`));
1597
+ if (error instanceof Error) {
1598
+ storyLogger?.log('ERROR', ` ${error.message}`);
1599
+ console.log(c.dim(` ${error.message}`));
1600
+ }
1601
+ return { success: false };
1602
+ }
1603
+ // Update action path if it was stale
1604
+ if (resolvedPath !== action.storyPath) {
1605
+ storyLogger?.log('WARN', `Note: Story path updated (file was moved)`);
1606
+ storyLogger?.log('WARN', ` From: ${action.storyPath}`);
1607
+ storyLogger?.log('WARN', ` To: ${resolvedPath}`);
1608
+ console.log(c.warning(`Note: Story path updated (file was moved)`));
1609
+ console.log(c.dim(` From: ${action.storyPath}`));
1610
+ console.log(c.dim(` To: ${resolvedPath}`));
1611
+ action.storyPath = resolvedPath;
1612
+ }
1613
+ // Store phase completion state BEFORE action execution (to detect transitions)
1614
+ const storyBeforeAction = parseStory(action.storyPath);
1615
+ const prevPhaseState = {
1616
+ research_complete: storyBeforeAction.frontmatter.research_complete,
1617
+ plan_complete: storyBeforeAction.frontmatter.plan_complete,
1618
+ implementation_complete: storyBeforeAction.frontmatter.implementation_complete,
1619
+ reviews_complete: storyBeforeAction.frontmatter.reviews_complete,
1620
+ status: storyBeforeAction.frontmatter.status,
1621
+ };
1622
+ spinner = ora(formatAction(action, true, c)).start();
1623
+ const baseText = formatAction(action, true, c);
1624
+ // Create agent progress callback for real-time updates
1625
+ const onAgentProgress = (event) => {
1626
+ if (!spinner)
1627
+ return; // Guard against null spinner
1628
+ switch (event.type) {
1629
+ case 'session_start':
1630
+ spinner.text = `${baseText} ${c.dim('(session started)')}`;
1631
+ break;
1632
+ case 'tool_start':
1633
+ // Show which tool is being executed
1634
+ const toolName = event.toolName || 'unknown';
1635
+ const shortName = toolName.replace(/^(mcp__|Mcp)/, '').substring(0, 30);
1636
+ spinner.text = `${baseText} ${c.dim(`→ ${shortName}`)}`;
1637
+ break;
1638
+ case 'tool_end':
1639
+ // Keep showing the action, tool completed
1640
+ spinner.text = baseText;
1641
+ break;
1642
+ case 'completion':
1643
+ spinner.text = `${baseText} ${c.dim('(completing...)')}`;
1644
+ break;
1645
+ }
1646
+ };
656
1647
  // Import and run the appropriate agent
657
1648
  let result;
658
1649
  switch (action.type) {
@@ -676,6 +1667,8 @@ async function executeAction(action, sdlcRoot) {
676
1667
  const { runReviewAgent } = await import('../agents/review.js');
677
1668
  result = await runReviewAgent(action.storyPath, sdlcRoot, {
678
1669
  onVerificationProgress: (phase, status, message) => {
1670
+ if (!spinner)
1671
+ return; // Guard against null spinner
679
1672
  const phaseLabel = phase === 'build' ? 'Building' : 'Testing';
680
1673
  switch (status) {
681
1674
  case 'starting':
@@ -693,6 +1686,53 @@ async function executeAction(action, sdlcRoot) {
693
1686
  }
694
1687
  },
695
1688
  });
1689
+ // Auto-complete story if review was approved
1690
+ if (result && result.success) {
1691
+ const reviewResult = result;
1692
+ let story = parseStory(action.storyPath);
1693
+ story = await autoCompleteStoryAfterReview(story, config, reviewResult);
1694
+ // Log auto-completion if it occurred
1695
+ if (reviewResult.decision === ReviewDecision.APPROVED && config.reviewConfig.autoCompleteOnApproval) {
1696
+ spinner.text = c.success('Review approved - auto-completing story');
1697
+ storyLogger?.log('INFO', `Story auto-completed after review approval: "${story.frontmatter.title}"`);
1698
+ // Auto-create PR in automated mode
1699
+ const workflowState = await loadWorkflowState(sdlcRoot, story.frontmatter.id);
1700
+ const isAutoMode = workflowState?.context.options.auto ?? false;
1701
+ if (isAutoMode || config.reviewConfig.autoCreatePROnApproval) {
1702
+ try {
1703
+ // Create PR (this will automatically commit any uncommitted changes)
1704
+ spinner.text = c.dim('Creating pull request...');
1705
+ const { createPullRequest } = await import('../agents/review.js');
1706
+ const prResult = await createPullRequest(action.storyPath, sdlcRoot);
1707
+ if (prResult.success) {
1708
+ spinner.text = c.success('Review approved - PR created');
1709
+ storyLogger?.log('INFO', `PR created successfully for ${story.frontmatter.id}`);
1710
+ }
1711
+ else {
1712
+ // PR creation failed - mark as blocked
1713
+ const { updateStoryStatus } = await import('../core/story.js');
1714
+ const blockedStory = await updateStoryStatus(story, 'blocked');
1715
+ await writeStory(blockedStory);
1716
+ spinner.text = c.warning('Review approved but PR creation failed - story marked as blocked');
1717
+ storyLogger?.log('WARN', `PR creation failed for ${story.frontmatter.id}: ${prResult.error || 'Unknown error'}`);
1718
+ }
1719
+ }
1720
+ catch (error) {
1721
+ // Error during PR creation - mark as blocked
1722
+ const { updateStoryStatus } = await import('../core/story.js');
1723
+ const blockedStory = await updateStoryStatus(story, 'blocked');
1724
+ await writeStory(blockedStory);
1725
+ const errorMsg = error instanceof Error ? error.message : String(error);
1726
+ spinner.text = c.warning(`Review approved but auto-PR failed: ${errorMsg}`);
1727
+ storyLogger?.log('ERROR', `Auto-PR failed for ${story.frontmatter.id}: ${errorMsg}`);
1728
+ }
1729
+ }
1730
+ // Handle worktree cleanup if story has a worktree
1731
+ if (story.frontmatter.worktree_path) {
1732
+ await handleWorktreeCleanup(story, config, c);
1733
+ }
1734
+ }
1735
+ }
696
1736
  break;
697
1737
  case 'rework':
698
1738
  const { runReworkAgent } = await import('../agents/rework.js');
@@ -709,28 +1749,49 @@ async function executeAction(action, sdlcRoot) {
709
1749
  // Update story status to done (no file move in new architecture)
710
1750
  const { updateStoryStatus } = await import('../core/story.js');
711
1751
  const storyToMove = parseStory(action.storyPath);
712
- const updatedStory = updateStoryStatus(storyToMove, 'done');
1752
+ const updatedStory = await updateStoryStatus(storyToMove, 'done');
713
1753
  result = {
714
1754
  success: true,
715
1755
  story: updatedStory,
716
1756
  changesMade: ['Updated story status to done'],
717
1757
  };
1758
+ // Worktree cleanup prompt (if story has a worktree)
1759
+ if (storyToMove.frontmatter.worktree_path) {
1760
+ await handleWorktreeCleanup(storyToMove, config, c);
1761
+ }
718
1762
  break;
719
1763
  default:
720
1764
  throw new Error(`Unknown action type: ${action.type}`);
721
1765
  }
722
1766
  // Check if agent succeeded
1767
+ const actionDuration = Date.now() - actionStartTime;
723
1768
  if (result && !result.success) {
724
1769
  spinner.fail(c.error(`Failed: ${formatAction(action, true, c)}`));
1770
+ storyLogger?.log('ERROR', `Action failed: ${formatAction(action, false, c)}`);
1771
+ globalLogger.warn('action', `Action failed: ${action.type}`, {
1772
+ storyId: action.storyId,
1773
+ actionType: action.type,
1774
+ durationMs: actionDuration,
1775
+ error: result.error,
1776
+ });
725
1777
  if (result.error) {
1778
+ storyLogger?.log('ERROR', ` Error: ${result.error}`);
726
1779
  console.error(c.error(` Error: ${result.error}`));
727
1780
  }
728
1781
  return { success: false };
729
1782
  }
730
1783
  spinner.succeed(c.success(formatAction(action, true, c)));
1784
+ storyLogger?.log('INFO', `Action completed successfully: ${formatAction(action, false, c)}`);
1785
+ globalLogger.info('action', `Action completed: ${action.type}`, {
1786
+ storyId: action.storyId,
1787
+ actionType: action.type,
1788
+ durationMs: actionDuration,
1789
+ changesCount: result?.changesMade?.length ?? 0,
1790
+ });
731
1791
  // Show changes made
732
1792
  if (result && result.changesMade.length > 0) {
733
1793
  for (const change of result.changesMade) {
1794
+ storyLogger?.log('INFO', ` → ${change}`);
734
1795
  console.log(c.dim(` → ${change}`));
735
1796
  }
736
1797
  }
@@ -786,14 +1847,28 @@ async function executeAction(action, sdlcRoot) {
786
1847
  return { success: true };
787
1848
  }
788
1849
  catch (error) {
789
- spinner.fail(c.error(`Failed: ${formatAction(action, true, c)}`));
1850
+ const exceptionDuration = Date.now() - actionStartTime;
1851
+ if (spinner) {
1852
+ spinner.fail(c.error(`Failed: ${formatAction(action, true, c)}`));
1853
+ }
1854
+ else {
1855
+ console.error(c.error(`Failed: ${formatAction(action, true, c)}`));
1856
+ }
1857
+ const errorMessage = error instanceof Error ? error.message : String(error);
1858
+ storyLogger?.log('ERROR', `Exception during action execution: ${errorMessage}`);
1859
+ globalLogger.error('action', `Action exception: ${action.type}`, {
1860
+ storyId: action.storyId,
1861
+ actionType: action.type,
1862
+ durationMs: exceptionDuration,
1863
+ error: errorMessage,
1864
+ });
790
1865
  console.error(error);
791
1866
  // Show phase checklist with error indication (if file still exists)
792
1867
  try {
793
1868
  const story = parseStory(action.storyPath);
794
1869
  console.log(c.dim(` Progress: ${renderPhaseChecklist(story, c)}`));
795
1870
  // Update story with error
796
- story.frontmatter.last_error = error instanceof Error ? error.message : String(error);
1871
+ story.frontmatter.last_error = errorMessage;
797
1872
  }
798
1873
  catch {
799
1874
  // File may have been moved - skip progress display
@@ -801,6 +1876,10 @@ async function executeAction(action, sdlcRoot) {
801
1876
  // Don't throw - let the workflow continue if in auto mode
802
1877
  return { success: false };
803
1878
  }
1879
+ finally {
1880
+ // Always close logger, even if action fails or throws
1881
+ storyLogger?.close();
1882
+ }
804
1883
  }
805
1884
  /**
806
1885
  * Get phase information for an action type
@@ -1208,7 +2287,7 @@ function isEmptySection(content) {
1208
2287
  /**
1209
2288
  * Unblock a story from the blocked folder and move it back to the workflow
1210
2289
  */
1211
- export function unblock(storyId, options) {
2290
+ export async function unblock(storyId, options) {
1212
2291
  const spinner = ora('Unblocking story...').start();
1213
2292
  const config = loadConfig();
1214
2293
  const c = getThemedChalk(config);
@@ -1219,7 +2298,7 @@ export function unblock(storyId, options) {
1219
2298
  return;
1220
2299
  }
1221
2300
  // Unblock the story (using renamed import to avoid naming conflict)
1222
- const unblockedStory = unblockStory(storyId, sdlcRoot, options);
2301
+ const unblockedStory = await unblockStory(storyId, sdlcRoot, options);
1223
2302
  // Determine destination folder from updated path
1224
2303
  const destinationFolder = unblockedStory.path.match(/\/([^/]+)\/[^/]+\.md$/)?.[1] || 'unknown';
1225
2304
  spinner.succeed(c.success(`Unblocked story ${storyId}, moved to ${destinationFolder}/`));
@@ -1333,4 +2412,275 @@ export async function migrate(options) {
1333
2412
  process.exit(1);
1334
2413
  }
1335
2414
  }
2415
+ /**
2416
+ * Helper function to prompt for removal confirmation
2417
+ */
2418
+ async function confirmRemoval(message) {
2419
+ // Sanitize message to prevent terminal injection attacks
2420
+ // Use consistent sanitizeForDisplay() for all terminal output
2421
+ const sanitizedMessage = sanitizeForDisplay(message);
2422
+ const rl = readline.createInterface({
2423
+ input: process.stdin,
2424
+ output: process.stdout,
2425
+ });
2426
+ return new Promise((resolve) => {
2427
+ rl.question(sanitizedMessage + ' (y/N): ', (answer) => {
2428
+ rl.close();
2429
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
2430
+ });
2431
+ });
2432
+ }
2433
+ /**
2434
+ * Handle worktree cleanup when story moves to done
2435
+ * Prompts user in interactive mode to remove worktree
2436
+ */
2437
+ async function handleWorktreeCleanup(story, config, c) {
2438
+ const worktreePath = story.frontmatter.worktree_path;
2439
+ if (!worktreePath)
2440
+ return;
2441
+ const sdlcRoot = getSdlcRoot();
2442
+ const workingDir = path.dirname(sdlcRoot);
2443
+ const worktreeConfig = config.worktree ?? DEFAULT_WORKTREE_CONFIG;
2444
+ // Check if worktree exists
2445
+ if (!fs.existsSync(worktreePath)) {
2446
+ console.log(c.warning(` Note: Worktree path no longer exists: ${worktreePath}`));
2447
+ const updated = await updateStoryField(story, 'worktree_path', undefined);
2448
+ await writeStory(updated);
2449
+ console.log(c.dim(' Cleared worktree_path from frontmatter'));
2450
+ return;
2451
+ }
2452
+ // Only prompt in interactive mode
2453
+ if (!process.stdin.isTTY) {
2454
+ console.log(c.dim(` Worktree preserved (non-interactive mode): ${worktreePath}`));
2455
+ return;
2456
+ }
2457
+ // Prompt for cleanup
2458
+ console.log();
2459
+ console.log(c.info(` Story has a worktree at: ${worktreePath}`));
2460
+ const shouldRemove = await confirmRemoval(' Remove worktree?');
2461
+ if (!shouldRemove) {
2462
+ console.log(c.dim(' Worktree preserved'));
2463
+ return;
2464
+ }
2465
+ // Remove worktree
2466
+ try {
2467
+ let resolvedBasePath;
2468
+ try {
2469
+ resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
2470
+ }
2471
+ catch {
2472
+ resolvedBasePath = path.dirname(worktreePath);
2473
+ }
2474
+ const service = new GitWorktreeService(workingDir, resolvedBasePath);
2475
+ service.remove(worktreePath);
2476
+ const updated = await updateStoryField(story, 'worktree_path', undefined);
2477
+ await writeStory(updated);
2478
+ console.log(c.success(' ✓ Worktree removed'));
2479
+ }
2480
+ catch (error) {
2481
+ console.log(c.warning(` Failed to remove worktree: ${error instanceof Error ? error.message : String(error)}`));
2482
+ // Clear frontmatter anyway (user may have manually deleted)
2483
+ const updated = await updateStoryField(story, 'worktree_path', undefined);
2484
+ await writeStory(updated);
2485
+ }
2486
+ }
2487
+ /**
2488
+ * Security: Escape shell arguments for safe use in commands
2489
+ * For use with execSync when shell execution is required
2490
+ * @internal Exported for testing
2491
+ */
2492
+ export function escapeShellArg(arg) {
2493
+ // Replace single quotes with '\'' and wrap in single quotes
2494
+ return `'${arg.replace(/'/g, "'\\''")}'`;
2495
+ }
2496
+ /**
2497
+ * List all ai-sdlc managed worktrees
2498
+ */
2499
+ export async function listWorktrees() {
2500
+ const config = loadConfig();
2501
+ const c = getThemedChalk(config);
2502
+ try {
2503
+ const sdlcRoot = getSdlcRoot();
2504
+ const workingDir = path.dirname(sdlcRoot);
2505
+ const worktreeConfig = config.worktree ?? DEFAULT_WORKTREE_CONFIG;
2506
+ // Resolve worktree base path
2507
+ let resolvedBasePath;
2508
+ try {
2509
+ resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
2510
+ }
2511
+ catch (error) {
2512
+ // If basePath doesn't exist yet, create an empty list response
2513
+ console.log();
2514
+ console.log(c.bold('═══ Worktrees ═══'));
2515
+ console.log();
2516
+ console.log(c.dim('No worktrees found.'));
2517
+ console.log(c.dim('Use `ai-sdlc worktrees add <story-id>` to create one.'));
2518
+ console.log();
2519
+ return;
2520
+ }
2521
+ const service = new GitWorktreeService(workingDir, resolvedBasePath);
2522
+ const worktrees = service.list();
2523
+ console.log();
2524
+ console.log(c.bold('═══ Worktrees ═══'));
2525
+ console.log();
2526
+ if (worktrees.length === 0) {
2527
+ console.log(c.dim('No worktrees found.'));
2528
+ console.log(c.dim('Use `ai-sdlc worktrees add <story-id>` to create one.'));
2529
+ }
2530
+ else {
2531
+ // Table header
2532
+ console.log(c.dim('Story ID'.padEnd(12) + 'Branch'.padEnd(40) + 'Status'.padEnd(10) + 'Path'));
2533
+ console.log(c.dim('─'.repeat(80)));
2534
+ for (const wt of worktrees) {
2535
+ const storyId = wt.storyId || 'unknown';
2536
+ const branch = wt.branch.length > 38 ? wt.branch.substring(0, 35) + '...' : wt.branch;
2537
+ const status = wt.exists ? c.success('exists') : c.error('missing');
2538
+ const displayPath = wt.path.length > 50 ? '...' + wt.path.slice(-47) : wt.path;
2539
+ console.log(storyId.padEnd(12) +
2540
+ branch.padEnd(40) +
2541
+ (wt.exists ? 'exists ' : 'missing ') +
2542
+ displayPath);
2543
+ }
2544
+ console.log();
2545
+ console.log(c.dim(`Total: ${worktrees.length} worktree(s)`));
2546
+ }
2547
+ console.log();
2548
+ }
2549
+ catch (error) {
2550
+ console.log(c.error(`Error listing worktrees: ${error instanceof Error ? error.message : String(error)}`));
2551
+ process.exit(1);
2552
+ }
2553
+ }
2554
+ /**
2555
+ * Create a worktree for a specific story
2556
+ */
2557
+ export async function addWorktree(storyId) {
2558
+ const spinner = ora('Creating worktree...').start();
2559
+ const config = loadConfig();
2560
+ const c = getThemedChalk(config);
2561
+ try {
2562
+ const sdlcRoot = getSdlcRoot();
2563
+ const workingDir = path.dirname(sdlcRoot);
2564
+ if (!kanbanExists(sdlcRoot)) {
2565
+ spinner.fail('ai-sdlc not initialized. Run `ai-sdlc init` first.');
2566
+ return;
2567
+ }
2568
+ // Find the story
2569
+ const story = findStoryById(sdlcRoot, storyId) || findStoryBySlug(sdlcRoot, storyId);
2570
+ if (!story) {
2571
+ spinner.fail(c.error(`Story not found: "${storyId}"`));
2572
+ console.log(c.dim('Use `ai-sdlc status` to see available stories.'));
2573
+ return;
2574
+ }
2575
+ // Check if story already has a worktree
2576
+ if (story.frontmatter.worktree_path) {
2577
+ spinner.fail(c.error(`Story already has a worktree: ${story.frontmatter.worktree_path}`));
2578
+ return;
2579
+ }
2580
+ // Resolve worktree base path
2581
+ const worktreeConfig = config.worktree ?? DEFAULT_WORKTREE_CONFIG;
2582
+ let resolvedBasePath;
2583
+ try {
2584
+ resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
2585
+ }
2586
+ catch (error) {
2587
+ spinner.fail(c.error(`Configuration Error: ${error instanceof Error ? error.message : String(error)}`));
2588
+ console.log(c.dim('Fix worktree.basePath in .ai-sdlc.json or remove it to use default location'));
2589
+ return;
2590
+ }
2591
+ const service = new GitWorktreeService(workingDir, resolvedBasePath);
2592
+ // Validate git state
2593
+ const validation = service.validateCanCreateWorktree();
2594
+ if (!validation.valid) {
2595
+ spinner.fail(c.error(validation.error || 'Cannot create worktree'));
2596
+ return;
2597
+ }
2598
+ // Detect base branch
2599
+ const baseBranch = service.detectBaseBranch();
2600
+ // Create the worktree
2601
+ const worktreePath = service.create({
2602
+ storyId: story.frontmatter.id,
2603
+ slug: story.slug,
2604
+ baseBranch,
2605
+ });
2606
+ // Update story frontmatter
2607
+ const updatedStory = await updateStoryField(story, 'worktree_path', worktreePath);
2608
+ const branchName = service.getBranchName(story.frontmatter.id, story.slug);
2609
+ const storyWithBranch = await updateStoryField(updatedStory, 'branch', branchName);
2610
+ await writeStory(storyWithBranch);
2611
+ spinner.succeed(c.success(`Created worktree for ${story.frontmatter.id}`));
2612
+ console.log(c.dim(` Path: ${worktreePath}`));
2613
+ console.log(c.dim(` Branch: ${branchName}`));
2614
+ console.log(c.dim(` Base: ${baseBranch}`));
2615
+ }
2616
+ catch (error) {
2617
+ spinner.fail(c.error('Failed to create worktree'));
2618
+ console.error(c.error(` ${error instanceof Error ? error.message : String(error)}`));
2619
+ process.exit(1);
2620
+ }
2621
+ }
2622
+ /**
2623
+ * Remove a worktree for a specific story
2624
+ */
2625
+ export async function removeWorktree(storyId, options) {
2626
+ const config = loadConfig();
2627
+ const c = getThemedChalk(config);
2628
+ try {
2629
+ const sdlcRoot = getSdlcRoot();
2630
+ const workingDir = path.dirname(sdlcRoot);
2631
+ if (!kanbanExists(sdlcRoot)) {
2632
+ console.log(c.warning('ai-sdlc not initialized. Run `ai-sdlc init` first.'));
2633
+ return;
2634
+ }
2635
+ // Find the story
2636
+ const story = findStoryById(sdlcRoot, storyId) || findStoryBySlug(sdlcRoot, storyId);
2637
+ if (!story) {
2638
+ console.log(c.error(`Story not found: "${storyId}"`));
2639
+ console.log(c.dim('Use `ai-sdlc status` to see available stories.'));
2640
+ return;
2641
+ }
2642
+ // Check if story has a worktree
2643
+ if (!story.frontmatter.worktree_path) {
2644
+ console.log(c.warning(`Story ${storyId} does not have a worktree.`));
2645
+ return;
2646
+ }
2647
+ const worktreePath = story.frontmatter.worktree_path;
2648
+ // Confirm removal (unless --force)
2649
+ if (!options?.force) {
2650
+ console.log();
2651
+ console.log(c.warning('About to remove worktree:'));
2652
+ console.log(c.dim(` Story: ${story.frontmatter.title}`));
2653
+ console.log(c.dim(` Path: ${worktreePath}`));
2654
+ console.log();
2655
+ const confirmed = await confirmRemoval('Are you sure you want to remove this worktree?');
2656
+ if (!confirmed) {
2657
+ console.log(c.dim('Cancelled.'));
2658
+ return;
2659
+ }
2660
+ }
2661
+ const spinner = ora('Removing worktree...').start();
2662
+ // Resolve worktree base path
2663
+ const worktreeConfig = config.worktree ?? DEFAULT_WORKTREE_CONFIG;
2664
+ let resolvedBasePath;
2665
+ try {
2666
+ resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
2667
+ }
2668
+ catch {
2669
+ // If basePath doesn't exist, use the worktree path's parent
2670
+ resolvedBasePath = path.dirname(worktreePath);
2671
+ }
2672
+ const service = new GitWorktreeService(workingDir, resolvedBasePath);
2673
+ // Remove the worktree
2674
+ service.remove(worktreePath);
2675
+ // Clear worktree_path from frontmatter
2676
+ const updatedStory = await updateStoryField(story, 'worktree_path', undefined);
2677
+ await writeStory(updatedStory);
2678
+ spinner.succeed(c.success(`Removed worktree for ${story.frontmatter.id}`));
2679
+ console.log(c.dim(` Path: ${worktreePath}`));
2680
+ }
2681
+ catch (error) {
2682
+ console.log(c.error(`Failed to remove worktree: ${error instanceof Error ? error.message : String(error)}`));
2683
+ process.exit(1);
2684
+ }
2685
+ }
1336
2686
  //# sourceMappingURL=commands.js.map