ai-sdlc 0.2.0-alpha.5 → 0.2.0-alpha.50

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 (130) hide show
  1. package/README.md +53 -1058
  2. package/dist/agents/implementation.d.ts +6 -0
  3. package/dist/agents/implementation.d.ts.map +1 -1
  4. package/dist/agents/implementation.js +151 -13
  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 +77 -2
  26. package/dist/agents/review.d.ts.map +1 -1
  27. package/dist/agents/review.js +615 -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 +22 -3
  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 +97 -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 +65 -3
  51. package/dist/cli/commands.d.ts.map +1 -1
  52. package/dist/cli/commands.js +1108 -204
  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 +20 -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 +19 -11
  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 +6 -0
  65. package/dist/core/client.d.ts.map +1 -1
  66. package/dist/core/client.js +57 -3
  67. package/dist/core/client.js.map +1 -1
  68. package/dist/core/config.d.ts +24 -1
  69. package/dist/core/config.d.ts.map +1 -1
  70. package/dist/core/config.js +100 -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 +7 -6
  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/story-logger.d.ts +102 -0
  97. package/dist/core/story-logger.d.ts.map +1 -0
  98. package/dist/core/story-logger.js +265 -0
  99. package/dist/core/story-logger.js.map +1 -0
  100. package/dist/core/story.d.ts +89 -20
  101. package/dist/core/story.d.ts.map +1 -1
  102. package/dist/core/story.js +297 -52
  103. package/dist/core/story.js.map +1 -1
  104. package/dist/core/task-parser.d.ts +59 -0
  105. package/dist/core/task-parser.d.ts.map +1 -0
  106. package/dist/core/task-parser.js +235 -0
  107. package/dist/core/task-parser.js.map +1 -0
  108. package/dist/core/task-progress.d.ts +92 -0
  109. package/dist/core/task-progress.d.ts.map +1 -0
  110. package/dist/core/task-progress.js +280 -0
  111. package/dist/core/task-progress.js.map +1 -0
  112. package/dist/core/workflow-state.d.ts +45 -6
  113. package/dist/core/workflow-state.d.ts.map +1 -1
  114. package/dist/core/workflow-state.js +201 -12
  115. package/dist/core/workflow-state.js.map +1 -1
  116. package/dist/core/worktree.d.ts +77 -0
  117. package/dist/core/worktree.d.ts.map +1 -0
  118. package/dist/core/worktree.js +246 -0
  119. package/dist/core/worktree.js.map +1 -0
  120. package/dist/index.js +135 -5
  121. package/dist/index.js.map +1 -1
  122. package/dist/services/error-classifier.d.ts +119 -0
  123. package/dist/services/error-classifier.d.ts.map +1 -0
  124. package/dist/services/error-classifier.js +182 -0
  125. package/dist/services/error-classifier.js.map +1 -0
  126. package/dist/types/index.d.ts +336 -1
  127. package/dist/types/index.d.ts.map +1 -1
  128. package/dist/types/index.js +1 -0
  129. package/dist/types/index.js.map +1 -1
  130. package/package.json +4 -1
@@ -1,9 +1,11 @@
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 { getSdlcRoot, loadConfig, initConfig, validateWorktreeBasePath, DEFAULT_WORKTREE_CONFIG } from '../core/config.js';
6
+ import { initializeKanban, kanbanExists, assessState, getBoardStats, findStoryBySlug, findStoriesByStatus } from '../core/kanban.js';
7
+ import { createStory, parseStory, resetRPIVCycle, isAtMaxRetries, unblockStory, getStory, findStoryById, updateStoryField, writeStory, sanitizeStoryId, autoCompleteStoryAfterReview } from '../core/story.js';
8
+ import { GitWorktreeService } from '../core/worktree.js';
7
9
  import { ReviewDecision } from '../types/index.js';
8
10
  import { getThemedChalk } from '../core/theme.js';
9
11
  import { saveWorkflowState, loadWorkflowState, clearWorkflowState, generateWorkflowId, calculateStoryHash, hasWorkflowState, } from '../core/workflow-state.js';
@@ -12,6 +14,10 @@ import { getStoryFlags as getStoryFlagsUtil, formatStatus as formatStatusUtil }
12
14
  import { migrateToFolderPerStory } from './commands/migrate.js';
13
15
  import { generateReviewSummary } from '../agents/review.js';
14
16
  import { getTerminalWidth } from './formatting.js';
17
+ import { validateGitState } from '../core/git-utils.js';
18
+ import { StoryLogger } from '../core/story-logger.js';
19
+ import { detectConflicts } from '../core/conflict-detector.js';
20
+ import { getLogger } from '../core/logger.js';
15
21
  /**
16
22
  * Initialize the .ai-sdlc folder structure
17
23
  */
@@ -49,7 +55,7 @@ export async function status(options) {
49
55
  console.log(c.warning('ai-sdlc not initialized. Run `ai-sdlc init` first.'));
50
56
  return;
51
57
  }
52
- const assessment = assessState(sdlcRoot);
58
+ const assessment = await assessState(sdlcRoot);
53
59
  const stats = getBoardStats(sdlcRoot);
54
60
  console.log();
55
61
  console.log(c.bold('═══ AI SDLC Board ═══'));
@@ -114,10 +120,58 @@ export async function status(options) {
114
120
  console.log(c.success('No pending actions. Board is up to date!'));
115
121
  }
116
122
  }
123
+ /**
124
+ * Validate file path for security (path traversal, symlinks, allowed directories)
125
+ */
126
+ function validateFilePath(filePath) {
127
+ const resolvedPath = path.resolve(filePath);
128
+ const allowedDir = path.resolve(process.cwd());
129
+ // Check path traversal: resolved path must be within current directory
130
+ if (!resolvedPath.startsWith(allowedDir + path.sep) && resolvedPath !== allowedDir) {
131
+ throw new Error('Security: File path must be within current directory (path traversal detected)');
132
+ }
133
+ // Check if file exists before checking if it's a symlink
134
+ if (!fs.existsSync(resolvedPath)) {
135
+ throw new Error(`File not found: ${path.basename(filePath)}`);
136
+ }
137
+ // Check for symbolic links (security risk)
138
+ const stats = fs.lstatSync(resolvedPath);
139
+ if (stats.isSymbolicLink()) {
140
+ throw new Error('Security: Symbolic links are not allowed');
141
+ }
142
+ }
143
+ /**
144
+ * Validate file extension against whitelist
145
+ */
146
+ function validateFileExtension(filePath) {
147
+ const allowedExtensions = ['.md', '.txt', '.markdown'];
148
+ const ext = path.extname(filePath).toLowerCase();
149
+ if (!allowedExtensions.includes(ext)) {
150
+ throw new Error(`Invalid file type: only ${allowedExtensions.join(', ')} files are allowed`);
151
+ }
152
+ }
153
+ /**
154
+ * Validate file size (10MB maximum)
155
+ */
156
+ function validateFileSize(filePath) {
157
+ const maxSize = 10 * 1024 * 1024; // 10MB
158
+ const stats = fs.statSync(filePath);
159
+ if (stats.size > maxSize) {
160
+ const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
161
+ throw new Error(`File too large: ${sizeMB}MB (maximum 10MB)`);
162
+ }
163
+ }
164
+ /**
165
+ * Sanitize file content (strip null bytes, validate UTF-8)
166
+ */
167
+ function sanitizeFileContent(content) {
168
+ // Strip null bytes that could truncate strings
169
+ return content.replace(/\0/g, '');
170
+ }
117
171
  /**
118
172
  * Add a new story to the backlog
119
173
  */
120
- export async function add(title) {
174
+ export async function add(title, options) {
121
175
  const spinner = ora('Creating story...').start();
122
176
  try {
123
177
  const config = loadConfig();
@@ -127,10 +181,83 @@ export async function add(title) {
127
181
  spinner.fail('ai-sdlc not initialized. Run `ai-sdlc init` first.');
128
182
  return;
129
183
  }
130
- const story = createStory(title, sdlcRoot);
184
+ // Validate that either title or file is provided (not both, not neither)
185
+ if (!title && !options?.file) {
186
+ spinner.fail('Error: Must provide either a title or --file option');
187
+ console.log(c.dim('Usage:'));
188
+ console.log(c.dim(' ai-sdlc add "Story Title"'));
189
+ console.log(c.dim(' ai-sdlc add --file story.md'));
190
+ process.exit(1);
191
+ }
192
+ if (title && options?.file) {
193
+ spinner.fail('Error: Cannot provide both title and --file option');
194
+ console.log(c.dim('Use either:'));
195
+ console.log(c.dim(' ai-sdlc add "Story Title"'));
196
+ console.log(c.dim(' ai-sdlc add --file story.md'));
197
+ process.exit(1);
198
+ }
199
+ let storyTitle;
200
+ let storyContent;
201
+ // Handle file input with security validation
202
+ if (options?.file) {
203
+ spinner.text = 'Reading file...';
204
+ const filePath = options.file;
205
+ try {
206
+ // Security validations
207
+ validateFilePath(filePath);
208
+ validateFileExtension(filePath);
209
+ // Read file (includes existence check via fs.readFileSync)
210
+ const resolvedPath = path.resolve(filePath);
211
+ // Validate file size before reading
212
+ validateFileSize(resolvedPath);
213
+ // Read and sanitize content
214
+ const rawContent = fs.readFileSync(resolvedPath, 'utf-8');
215
+ storyContent = sanitizeFileContent(rawContent);
216
+ // Extract title from content or use filename
217
+ const { extractTitleFromContent } = await import('../core/story.js');
218
+ const extractedTitle = extractTitleFromContent(storyContent);
219
+ if (extractedTitle) {
220
+ storyTitle = extractedTitle;
221
+ }
222
+ else {
223
+ // Fall back to filename without extension
224
+ storyTitle = path.basename(filePath, path.extname(filePath));
225
+ }
226
+ spinner.text = `Creating story from ${path.basename(filePath)}...`;
227
+ }
228
+ catch (error) {
229
+ spinner.fail('Failed to read file');
230
+ if (error instanceof Error) {
231
+ // Sanitize error messages to avoid leaking system paths
232
+ if (error.message.startsWith('Security:') || error.message.startsWith('Invalid file type:') || error.message.startsWith('File too large:')) {
233
+ console.log(c.error(error.message));
234
+ }
235
+ else if (error.message.includes('ENOENT')) {
236
+ console.log(c.error(`File not found: ${path.basename(filePath)}`));
237
+ }
238
+ else if (error.message.includes('EACCES') || error.message.includes('EPERM')) {
239
+ console.log(c.error(`Permission denied: ${path.basename(filePath)}`));
240
+ }
241
+ else {
242
+ console.log(c.error(`Unable to read file: ${path.basename(filePath)}`));
243
+ }
244
+ }
245
+ process.exit(1);
246
+ }
247
+ }
248
+ else {
249
+ // Traditional title-only input
250
+ storyTitle = title;
251
+ }
252
+ // Create the story
253
+ const story = await createStory(storyTitle, sdlcRoot, {}, storyContent);
131
254
  spinner.succeed(c.success(`Created: ${story.path}`));
132
255
  console.log(c.dim(` ID: ${story.frontmatter.id}`));
256
+ console.log(c.dim(` Title: ${story.frontmatter.title}`));
133
257
  console.log(c.dim(` Slug: ${story.slug}`));
258
+ if (options?.file) {
259
+ console.log(c.dim(` Source: ${path.basename(options.file)}`));
260
+ }
134
261
  console.log();
135
262
  console.log(c.info('Next step:'), `ai-sdlc run`);
136
263
  }
@@ -205,6 +332,208 @@ function generateFullSDLCActions(story, c) {
205
332
  }
206
333
  return actions;
207
334
  }
335
+ /**
336
+ * Actions that modify git and require validation
337
+ */
338
+ const GIT_MODIFYING_ACTIONS = ['implement', 'review', 'create_pr'];
339
+ /**
340
+ * Check if any actions in the list require git validation
341
+ */
342
+ function requiresGitValidation(actions) {
343
+ return actions.some(action => GIT_MODIFYING_ACTIONS.includes(action.type));
344
+ }
345
+ /**
346
+ * Determine if worktree mode should be used based on CLI flags, story frontmatter, and config.
347
+ * Priority order:
348
+ * 1. CLI --no-worktree flag (explicit disable)
349
+ * 2. CLI --worktree flag (explicit enable)
350
+ * 3. Story frontmatter.worktree_path exists (auto-enable for resuming)
351
+ * 4. Config worktree.enabled (default behavior)
352
+ */
353
+ export function determineWorktreeMode(options, worktreeConfig, targetStory) {
354
+ if (options.worktree === false)
355
+ return false;
356
+ if (options.worktree === true)
357
+ return true;
358
+ if (targetStory?.frontmatter.worktree_path)
359
+ return true;
360
+ return worktreeConfig.enabled;
361
+ }
362
+ /**
363
+ * Display git validation errors and warnings
364
+ */
365
+ function displayGitValidationResult(result, c) {
366
+ if (result.errors.length > 0) {
367
+ console.log();
368
+ console.log(c.error('Git validation failed:'));
369
+ for (const error of result.errors) {
370
+ console.log(c.error(` - ${error}`));
371
+ }
372
+ console.log();
373
+ console.log(c.info('To override this check, use --force (at your own risk)'));
374
+ }
375
+ if (result.warnings.length > 0) {
376
+ console.log();
377
+ console.log(c.warning('Git validation warnings:'));
378
+ for (const warning of result.warnings) {
379
+ console.log(c.warning(` - ${warning}`));
380
+ }
381
+ }
382
+ }
383
+ // ANSI escape sequence patterns for sanitization
384
+ const ANSI_CSI_PATTERN = /\x1B\[[0-9;]*[a-zA-Z]/g;
385
+ const ANSI_OSC_BEL_PATTERN = /\x1B\][^\x07]*\x07/g;
386
+ const ANSI_OSC_ESC_PATTERN = /\x1B\][^\x1B]*\x1B\\/g;
387
+ const ANSI_SINGLE_CHAR_PATTERN = /\x1B./g;
388
+ const CONTROL_CHARS_PATTERN = /[\x00-\x1F\x7F-\x9F]/g;
389
+ /**
390
+ * Sanitize a string for safe display in the terminal.
391
+ * Strips ANSI escape sequences (CSI, OSC, single-char), control characters,
392
+ * and truncates extremely long strings to prevent DoS attacks.
393
+ *
394
+ * This uses the same comprehensive ANSI stripping patterns as sanitizeReasonText
395
+ * from src/core/story.ts for consistency.
396
+ *
397
+ * @param str - The string to sanitize
398
+ * @returns Sanitized string safe for terminal display (max 500 chars)
399
+ */
400
+ function sanitizeForDisplay(str) {
401
+ const cleaned = str
402
+ .replace(ANSI_CSI_PATTERN, '') // CSI sequences (e.g., \x1B[31m)
403
+ .replace(ANSI_OSC_BEL_PATTERN, '') // OSC with BEL terminator (e.g., \x1B]...\x07)
404
+ .replace(ANSI_OSC_ESC_PATTERN, '') // OSC with ESC\ terminator (e.g., \x1B]...\x1B\\)
405
+ .replace(ANSI_SINGLE_CHAR_PATTERN, '') // Single-char escapes (e.g., \x1BH)
406
+ .replace(CONTROL_CHARS_PATTERN, ''); // Control characters (0x00-0x1F, 0x7F-0x9F)
407
+ // Truncate extremely long strings (DoS protection)
408
+ return cleaned.length > 500 ? cleaned.slice(0, 497) + '...' : cleaned;
409
+ }
410
+ /**
411
+ * Perform pre-flight conflict check before starting work on a story in a worktree.
412
+ * Warns about potential file conflicts with active stories and prompts for confirmation.
413
+ *
414
+ * **Race Condition (TOCTOU):** Multiple users can pass this check simultaneously
415
+ * before branches are created. This is an accepted risk - the window is small
416
+ * (~100ms) and git will catch conflicts during merge/PR creation. Adding file
417
+ * locks would significantly increase complexity for minimal security gain.
418
+ *
419
+ * **Security Notes:**
420
+ * - sdlcRoot is normalized and validated (absolute path, no null bytes, max 1024 chars)
421
+ * - All display output is sanitized to prevent terminal injection attacks
422
+ * - Story IDs are validated with sanitizeStoryId() then stripped with sanitizeForDisplay()
423
+ * - Error messages are generic to prevent information leakage
424
+ *
425
+ * @param targetStory - The story to check for conflicts
426
+ * @param sdlcRoot - Root directory of the .ai-sdlc folder (must be absolute, validated)
427
+ * @param options - Command options (force flag)
428
+ * @param options.force - Skip conflict check if true
429
+ * @returns PreFlightResult indicating whether to proceed and any warnings
430
+ * @throws Error if sdlcRoot is invalid (not absolute, null bytes, too long)
431
+ */
432
+ export async function preFlightConflictCheck(targetStory, sdlcRoot, options) {
433
+ const config = loadConfig();
434
+ const c = getThemedChalk(config);
435
+ // Skip if --force flag
436
+ if (options.force) {
437
+ console.log(c.warning('⚠️ Skipping conflict check (--force)'));
438
+ return { proceed: true, warnings: ['Conflict check skipped'] };
439
+ }
440
+ // Validate sdlcRoot parameter (normalize first to prevent bypass attacks)
441
+ const normalizedPath = path.normalize(sdlcRoot);
442
+ if (!path.isAbsolute(normalizedPath)) {
443
+ throw new Error('Invalid project path');
444
+ }
445
+ if (normalizedPath.includes('\0')) {
446
+ throw new Error('Invalid project path');
447
+ }
448
+ if (normalizedPath.length > 1024) {
449
+ throw new Error('Invalid project path');
450
+ }
451
+ // Check if target story is already in-progress
452
+ if (targetStory.frontmatter.status === 'in-progress') {
453
+ console.log(c.error('❌ Story is already in-progress'));
454
+ return { proceed: false, warnings: ['Story already in progress'] };
455
+ }
456
+ try {
457
+ // Query for all in-progress stories (excluding target)
458
+ // Use normalizedPath for all subsequent operations
459
+ const activeStories = findStoriesByStatus(normalizedPath, 'in-progress')
460
+ .filter(s => s.frontmatter.id !== targetStory.frontmatter.id);
461
+ if (activeStories.length === 0) {
462
+ console.log(c.success('✓ Conflict check: No overlapping files with active stories'));
463
+ return { proceed: true, warnings: [] };
464
+ }
465
+ // Run conflict detection (use normalizedPath)
466
+ const workingDir = path.dirname(normalizedPath);
467
+ const result = detectConflicts([targetStory, ...activeStories], workingDir, 'main');
468
+ // Filter conflicts involving target story
469
+ const relevantConflicts = result.conflicts.filter(conflict => conflict.storyA === targetStory.frontmatter.id || conflict.storyB === targetStory.frontmatter.id);
470
+ // Filter out 'none' severity conflicts (keep all displayable conflicts including low)
471
+ const displayableConflicts = relevantConflicts.filter(conflict => conflict.severity !== 'none');
472
+ if (displayableConflicts.length === 0) {
473
+ console.log(c.success('✓ Conflict check: No overlapping files with active stories'));
474
+ return { proceed: true, warnings: [] };
475
+ }
476
+ // Sort conflicts by severity (high -> medium -> low)
477
+ const severityOrder = { high: 0, medium: 1, low: 2, none: 3 };
478
+ const sortedConflicts = displayableConflicts.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
479
+ // Display conflicts
480
+ console.log();
481
+ console.log(c.warning('⚠️ Potential conflicts detected:'));
482
+ console.log();
483
+ for (const conflict of sortedConflicts) {
484
+ const otherStoryId = conflict.storyA === targetStory.frontmatter.id ? conflict.storyB : conflict.storyA;
485
+ // Two-stage sanitization: validate structure, then strip for display
486
+ try {
487
+ const validatedTargetId = sanitizeStoryId(targetStory.frontmatter.id);
488
+ const validatedOtherId = sanitizeStoryId(otherStoryId);
489
+ const sanitizedTargetId = sanitizeForDisplay(validatedTargetId);
490
+ const sanitizedOtherId = sanitizeForDisplay(validatedOtherId);
491
+ console.log(c.warning(` ${sanitizedTargetId} may conflict with ${sanitizedOtherId}:`));
492
+ }
493
+ catch (error) {
494
+ // If validation fails, show generic error (defensive)
495
+ console.log(c.warning(` Story may have conflicting changes (invalid ID format)`));
496
+ }
497
+ // Display shared files
498
+ for (const file of conflict.sharedFiles) {
499
+ const severityLabel = conflict.severity === 'high' ? c.error('High') :
500
+ conflict.severity === 'medium' ? c.warning('Medium') :
501
+ c.info('Low');
502
+ const sanitizedFile = sanitizeForDisplay(file);
503
+ console.log(` - ${severityLabel}: ${sanitizedFile} (both stories modify this file)`);
504
+ }
505
+ // Display shared directories
506
+ for (const dir of conflict.sharedDirectories) {
507
+ const severityLabel = conflict.severity === 'high' ? c.error('High') :
508
+ conflict.severity === 'medium' ? c.warning('Medium') :
509
+ c.info('Low');
510
+ const sanitizedDir = sanitizeForDisplay(dir);
511
+ console.log(` - ${severityLabel}: ${sanitizedDir} (both stories modify files in this directory)`);
512
+ }
513
+ console.log();
514
+ const sanitizedRecommendation = sanitizeForDisplay(conflict.recommendation);
515
+ console.log(c.dim(` Recommendation: ${sanitizedRecommendation}`));
516
+ console.log();
517
+ }
518
+ // Non-interactive mode: default to declining
519
+ if (!process.stdin.isTTY) {
520
+ console.log(c.dim('Non-interactive mode: conflicts require --force to proceed'));
521
+ return { proceed: false, warnings: ['Conflicts detected'] };
522
+ }
523
+ // Interactive mode: prompt user
524
+ const shouldContinue = await confirmRemoval('Continue anyway?');
525
+ return {
526
+ proceed: shouldContinue,
527
+ warnings: shouldContinue ? ['User confirmed with conflicts'] : ['Conflicts detected']
528
+ };
529
+ }
530
+ catch (error) {
531
+ // Fail-open: allow proceeding if conflict detection fails
532
+ console.log(c.warning('⚠️ Conflict detection unavailable'));
533
+ console.log(c.dim('Proceeding without conflict check...'));
534
+ return { proceed: true, warnings: ['Conflict detection failed'] };
535
+ }
536
+ }
208
537
  /**
209
538
  * Run the workflow (process one action or all)
210
539
  */
@@ -214,8 +543,28 @@ export async function run(options) {
214
543
  const maxIterationsOverride = options.maxIterations !== undefined
215
544
  ? parseInt(options.maxIterations, 10)
216
545
  : undefined;
217
- const sdlcRoot = getSdlcRoot();
546
+ let sdlcRoot = getSdlcRoot();
218
547
  const c = getThemedChalk(config);
548
+ const logger = getLogger();
549
+ logger.debug('workflow', 'Run command initiated', {
550
+ auto: options.auto,
551
+ dryRun: options.dryRun,
552
+ continue: options.continue,
553
+ story: options.story,
554
+ step: options.step,
555
+ watch: options.watch,
556
+ worktree: options.worktree,
557
+ force: options.force,
558
+ });
559
+ // Migrate global workflow state to story-specific location if needed
560
+ // Only run when NOT continuing (to avoid interrupting resumed workflows)
561
+ if (!options.continue) {
562
+ const { migrateGlobalWorkflowState } = await import('../core/workflow-state.js');
563
+ const migrationResult = await migrateGlobalWorkflowState(sdlcRoot);
564
+ if (migrationResult.migrated) {
565
+ console.log(c.info(migrationResult.message));
566
+ }
567
+ }
219
568
  // Handle daemon/watch mode
220
569
  if (options.watch) {
221
570
  console.log(c.info('🚀 Starting daemon mode...'));
@@ -253,8 +602,15 @@ export async function run(options) {
253
602
  let completedActions = [];
254
603
  let storyContentHash;
255
604
  if (options.continue) {
256
- // Try to load existing state
257
- const existingState = await loadWorkflowState(sdlcRoot);
605
+ // Determine storyId for loading state
606
+ // If --story is provided, use it; otherwise, try to infer from existing state
607
+ let resumeStoryId;
608
+ // First try: use --story flag if provided
609
+ if (options.story) {
610
+ resumeStoryId = options.story;
611
+ }
612
+ // Try to load existing state (with or without storyId)
613
+ const existingState = await loadWorkflowState(sdlcRoot, resumeStoryId);
258
614
  if (!existingState) {
259
615
  console.log(c.error('Error: No checkpoint found.'));
260
616
  console.log(c.dim('Remove --continue flag to start a new workflow.'));
@@ -300,20 +656,37 @@ export async function run(options) {
300
656
  console.log();
301
657
  }
302
658
  else {
659
+ // Early validation of story ID format before any operations that use it
660
+ // This prevents sanitizeStoryId from throwing before we can show a nice error
661
+ if (options.story && !/^[a-z0-9_-]+$/i.test(options.story.toLowerCase().trim())) {
662
+ console.log(c.error('Invalid story ID format. Only letters, numbers, hyphens, and underscores are allowed.'));
663
+ return;
664
+ }
303
665
  // Check if there's an existing state and suggest --continue
304
- if (hasWorkflowState(sdlcRoot) && !options.dryRun) {
666
+ // Check both global and story-specific state
667
+ const hasGlobalState = hasWorkflowState(sdlcRoot);
668
+ const hasStoryState = options.story ? hasWorkflowState(sdlcRoot, options.story) : false;
669
+ if ((hasGlobalState || hasStoryState) && !options.dryRun) {
305
670
  console.log(c.info('Note: Found previous checkpoint. Use --continue to resume.'));
306
671
  console.log();
307
672
  }
308
673
  // Start new workflow
309
674
  workflowId = generateWorkflowId();
310
675
  }
311
- let assessment = assessState(sdlcRoot);
676
+ let assessment = await assessState(sdlcRoot);
677
+ // Hoist targetStory to outer scope so it can be reused for worktree checks
678
+ let targetStory = null;
312
679
  // Filter actions by story if --story flag is provided
313
680
  if (options.story) {
314
681
  const normalizedInput = options.story.toLowerCase().trim();
682
+ // SECURITY: Validate story ID format to prevent path traversal and injection
683
+ // Only allow alphanumeric characters, hyphens, and underscores
684
+ if (!/^[a-z0-9_-]+$/i.test(normalizedInput)) {
685
+ console.log(c.error('Invalid story ID format. Only letters, numbers, hyphens, and underscores are allowed.'));
686
+ return;
687
+ }
315
688
  // Try to find story by ID first, then by slug (case-insensitive)
316
- let targetStory = findStoryById(sdlcRoot, normalizedInput);
689
+ targetStory = findStoryById(sdlcRoot, normalizedInput);
317
690
  if (!targetStory) {
318
691
  targetStory = findStoryBySlug(sdlcRoot, normalizedInput);
319
692
  }
@@ -385,7 +758,8 @@ export async function run(options) {
385
758
  }
386
759
  // Clear state if workflow is complete
387
760
  if (options.continue || hasWorkflowState(sdlcRoot)) {
388
- await clearWorkflowState(sdlcRoot);
761
+ // Using options.story - action not yet created in early exit path
762
+ await clearWorkflowState(sdlcRoot, options.story);
389
763
  console.log(c.dim('Checkpoint cleared.'));
390
764
  }
391
765
  return;
@@ -426,163 +800,304 @@ export async function run(options) {
426
800
  actionsToProcess = remainingActions;
427
801
  if (actionsToProcess.length === 0) {
428
802
  console.log(c.success('All actions from checkpoint already completed!'));
429
- await clearWorkflowState(sdlcRoot);
803
+ // Using options.story - action not yet created in early exit path
804
+ await clearWorkflowState(sdlcRoot, options.story);
430
805
  console.log(c.dim('Checkpoint cleared.'));
431
806
  return;
432
807
  }
433
808
  }
434
- // Process actions with retry support for Full SDLC mode
435
- let currentActions = [...actionsToProcess];
436
- let currentActionIndex = 0;
437
- let retryAttempt = 0;
438
- const MAX_DISPLAY_RETRIES = 3; // For display purposes
439
- while (currentActionIndex < currentActions.length) {
440
- const action = currentActions[currentActionIndex];
441
- const totalActions = currentActions.length;
442
- // Enhanced progress indicator for full SDLC mode
443
- if (isFullSDLC && totalActions > 1) {
444
- const retryIndicator = retryAttempt > 0 ? ` (retry ${retryAttempt})` : '';
445
- console.log(c.info(`\n═══ Phase ${currentActionIndex + 1}/${totalActions}: ${action.type.toUpperCase()}${retryIndicator} ═══`));
446
- }
447
- const actionResult = await executeAction(action, sdlcRoot);
448
- // Handle action failure in full SDLC mode
449
- if (!actionResult.success && isFullSDLC) {
450
- console.log();
451
- console.log(c.error(`✗ Phase ${action.type} failed`));
452
- console.log(c.dim(`Completed ${currentActionIndex} of ${totalActions} phases`));
453
- console.log(c.info('Fix the error above and use --continue to resume.'));
809
+ // Handle worktree creation based on flags, config, and story frontmatter
810
+ // IMPORTANT: This must happen BEFORE git validation because:
811
+ // 1. Worktree mode allows running from protected branches (main/master)
812
+ // 2. The worktree will be created on a feature branch
813
+ let worktreePath;
814
+ let originalCwd;
815
+ let worktreeCreated = false;
816
+ // Determine if worktree should be used
817
+ // Priority: CLI flags > story frontmatter > config > default (disabled)
818
+ const worktreeConfig = config.worktree ?? DEFAULT_WORKTREE_CONFIG;
819
+ // Reuse targetStory from earlier lookup (DRY - avoids duplicate story lookup)
820
+ const shouldUseWorktree = determineWorktreeMode(options, worktreeConfig, targetStory);
821
+ // Validate that worktree mode requires --story
822
+ if (shouldUseWorktree && !options.story) {
823
+ if (options.worktree === true) {
824
+ console.log(c.error('Error: --worktree requires --story flag'));
454
825
  return;
455
826
  }
456
- // Handle review rejection in Full SDLC mode - trigger retry loop
457
- if (isFullSDLC && action.type === 'review' && actionResult.reviewResult) {
458
- const reviewResult = actionResult.reviewResult;
459
- if (reviewResult.decision === ReviewDecision.REJECTED) {
460
- // Load fresh story state and config for retry check
461
- const story = parseStory(action.storyPath);
462
- const config = loadConfig();
463
- // Check if we're at max retries (pass CLI override if provided)
464
- if (isAtMaxRetries(story, config, maxIterationsOverride)) {
465
- console.log();
466
- console.log(c.error('═'.repeat(50)));
467
- console.log(c.error(`✗ Review failed - maximum retries reached`));
468
- console.log(c.error('═'.repeat(50)));
469
- console.log(c.dim(`Story has reached the maximum retry limit.`));
470
- console.log(c.dim(`Issues found: ${reviewResult.issues.length}`));
471
- console.log(c.warning('Manual intervention required to address the review feedback.'));
472
- console.log(c.info('You can:'));
473
- console.log(c.dim(' 1. Fix issues manually and run again'));
474
- console.log(c.dim(' 2. Reset retry count in the story frontmatter'));
475
- await clearWorkflowState(sdlcRoot);
476
- return;
827
+ }
828
+ if (shouldUseWorktree && options.story && targetStory) {
829
+ // PRE-FLIGHT CHECK: Run conflict detection before creating worktree
830
+ const preFlightResult = await preFlightConflictCheck(targetStory, sdlcRoot, options);
831
+ if (!preFlightResult.proceed) {
832
+ console.log(c.error('❌ Aborting. Complete active stories first or use --force.'));
833
+ return;
834
+ }
835
+ // Log warnings if user proceeded despite conflicts (skip internal flag messages)
836
+ if (preFlightResult.warnings.length > 0 && !preFlightResult.warnings.includes('Conflict check skipped')) {
837
+ preFlightResult.warnings.forEach(w => console.log(c.dim(` ⚠ ${w}`)));
838
+ console.log();
839
+ }
840
+ const workingDir = path.dirname(sdlcRoot);
841
+ // Check if story already has an existing worktree (resume scenario)
842
+ const existingWorktreePath = targetStory.frontmatter.worktree_path;
843
+ if (existingWorktreePath && fs.existsSync(existingWorktreePath)) {
844
+ // Reuse existing worktree
845
+ originalCwd = process.cwd();
846
+ worktreePath = existingWorktreePath;
847
+ process.chdir(worktreePath);
848
+ sdlcRoot = getSdlcRoot();
849
+ worktreeCreated = true;
850
+ console.log(c.success(`✓ Resuming in existing worktree: ${worktreePath}`));
851
+ console.log(c.dim(` Branch: ai-sdlc/${targetStory.frontmatter.id}-${targetStory.slug}`));
852
+ console.log();
853
+ }
854
+ else {
855
+ // Create new worktree
856
+ // Resolve worktree base path from config
857
+ let resolvedBasePath;
858
+ try {
859
+ resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
860
+ }
861
+ catch (error) {
862
+ console.log(c.error(`Configuration Error: ${error instanceof Error ? error.message : String(error)}`));
863
+ console.log(c.dim('Fix worktree.basePath in .ai-sdlc.json or remove it to use default location'));
864
+ return;
865
+ }
866
+ const worktreeService = new GitWorktreeService(workingDir, resolvedBasePath);
867
+ // Validate git state for worktree creation
868
+ const validation = worktreeService.validateCanCreateWorktree();
869
+ if (!validation.valid) {
870
+ console.log(c.error(`Error: ${validation.error}`));
871
+ return;
872
+ }
873
+ try {
874
+ // Detect base branch
875
+ const baseBranch = worktreeService.detectBaseBranch();
876
+ // Create worktree
877
+ originalCwd = process.cwd();
878
+ worktreePath = worktreeService.create({
879
+ storyId: targetStory.frontmatter.id,
880
+ slug: targetStory.slug,
881
+ baseBranch,
882
+ });
883
+ // Change to worktree directory BEFORE updating story
884
+ // This ensures story updates happen in the worktree, not on main
885
+ // (allows parallel story launches from clean main)
886
+ process.chdir(worktreePath);
887
+ // Recalculate sdlcRoot for the worktree context
888
+ sdlcRoot = getSdlcRoot();
889
+ worktreeCreated = true;
890
+ // Now update story frontmatter with worktree path (writes to worktree copy)
891
+ // Re-resolve target story in worktree context
892
+ const worktreeStory = findStoryById(sdlcRoot, targetStory.frontmatter.id);
893
+ if (worktreeStory) {
894
+ const updatedStory = await updateStoryField(worktreeStory, 'worktree_path', worktreePath);
895
+ await writeStory(updatedStory);
896
+ // Update targetStory reference for downstream use
897
+ targetStory = updatedStory;
477
898
  }
478
- // We can retry - reset RPIV cycle and loop back
479
- const currentRetry = (story.frontmatter.retry_count || 0) + 1;
480
- // Use CLI override, then story-specific, then config default
481
- const effectiveMaxRetries = maxIterationsOverride !== undefined
482
- ? maxIterationsOverride
483
- : (story.frontmatter.max_retries ?? config.reviewConfig?.maxRetries ?? Infinity);
484
- const maxRetriesDisplay = Number.isFinite(effectiveMaxRetries) ? effectiveMaxRetries : '∞';
899
+ console.log(c.success(`✓ Created worktree at: ${worktreePath}`));
900
+ console.log(c.dim(` Branch: ai-sdlc/${targetStory.frontmatter.id}-${targetStory.slug}`));
485
901
  console.log();
486
- console.log(c.warning(`⟳ Review rejected with ${reviewResult.issues.length} issue(s) - initiating rework (attempt ${currentRetry}/${maxRetriesDisplay})`));
487
- // Display executive summary
488
- const summary = generateReviewSummary(reviewResult.issues, getTerminalWidth());
489
- console.log(c.dim(` Summary: ${summary}`));
490
- // Reset the RPIV cycle (this increments retry_count and resets flags)
491
- resetRPIVCycle(story, reviewResult.feedback);
492
- // Log what's being reset
493
- console.log(c.dim(` → Reset plan_complete, implementation_complete, reviews_complete`));
494
- console.log(c.dim(` → Retry count: ${currentRetry}/${maxRetriesDisplay}`));
495
- // Regenerate actions starting from the phase that needs rework
496
- // For now, we restart from 'plan' since that's the typical flow after research
497
- const freshStory = parseStory(action.storyPath);
498
- const newActions = generateFullSDLCActions(freshStory, c);
499
- if (newActions.length > 0) {
500
- // Replace remaining actions with the new sequence
501
- currentActions = newActions;
502
- currentActionIndex = 0;
503
- retryAttempt++;
504
- console.log(c.info(` → Restarting SDLC from ${newActions[0].type} phase`));
505
- console.log();
506
- continue; // Restart the loop with new actions
507
- }
508
- else {
509
- // No actions to retry (shouldn't happen but handle gracefully)
510
- console.log(c.error('Error: No actions generated for retry. Manual intervention required.'));
511
- return;
902
+ }
903
+ catch (error) {
904
+ // Restore directory on worktree creation failure
905
+ if (originalCwd) {
906
+ process.chdir(originalCwd);
512
907
  }
908
+ console.log(c.error(`Failed to create worktree: ${error instanceof Error ? error.message : String(error)}`));
909
+ return;
513
910
  }
514
911
  }
515
- // Save checkpoint after successful action
516
- if (actionResult.success) {
517
- completedActions.push({
518
- type: action.type,
519
- storyId: action.storyId,
520
- storyPath: action.storyPath,
521
- completedAt: new Date().toISOString(),
522
- });
523
- const state = {
524
- version: '1.0',
525
- workflowId,
526
- timestamp: new Date().toISOString(),
527
- currentAction: null,
528
- completedActions,
529
- context: {
530
- sdlcRoot,
531
- options: {
532
- auto: options.auto,
533
- dryRun: options.dryRun,
534
- story: options.story,
535
- fullSDLC: isFullSDLC,
536
- },
537
- storyContentHash: calculateStoryHash(action.storyPath),
538
- },
539
- };
540
- await saveWorkflowState(state, sdlcRoot);
541
- console.log(c.dim(` ✓ Progress saved (${completedActions.length} actions completed)`));
542
- }
543
- currentActionIndex++;
544
- // Re-assess after each action in auto mode
545
- if (options.auto) {
546
- // For full SDLC mode, check if all phases are complete (and review passed)
547
- if (isFullSDLC) {
548
- // Check if we've completed all actions in our sequence
549
- if (currentActionIndex >= currentActions.length) {
550
- // Verify the review actually passed (reviews_complete should be true)
551
- const finalStory = parseStory(action.storyPath);
552
- if (finalStory.frontmatter.reviews_complete) {
912
+ }
913
+ // Validate git state before processing actions that modify git
914
+ // Skip protected branch check if worktree mode is active (worktree is on feature branch)
915
+ // Skip clean check entirely when worktree was just created:
916
+ // - The worktree starts from a clean base branch
917
+ // - npm install may modify package-lock.json
918
+ // - Story file was just updated with worktree_path
919
+ // - There's no prior user work to protect in a fresh worktree
920
+ if (!options.force && requiresGitValidation(actionsToProcess)) {
921
+ const workingDir = path.dirname(sdlcRoot);
922
+ const gitValidationOptions = worktreeCreated
923
+ ? { skipBranchCheck: true, skipCleanCheck: true }
924
+ : {};
925
+ const gitValidation = validateGitState(workingDir, gitValidationOptions);
926
+ if (!gitValidation.valid) {
927
+ displayGitValidationResult(gitValidation, c);
928
+ if (worktreeCreated && originalCwd) {
929
+ process.chdir(originalCwd);
930
+ }
931
+ return;
932
+ }
933
+ if (gitValidation.warnings.length > 0) {
934
+ displayGitValidationResult(gitValidation, c);
935
+ console.log();
936
+ }
937
+ }
938
+ // Process actions with retry support for Full SDLC mode
939
+ let currentActions = [...actionsToProcess];
940
+ let currentActionIndex = 0;
941
+ let retryAttempt = 0;
942
+ const MAX_DISPLAY_RETRIES = 3; // For display purposes
943
+ try {
944
+ while (currentActionIndex < currentActions.length) {
945
+ const action = currentActions[currentActionIndex];
946
+ const totalActions = currentActions.length;
947
+ // Enhanced progress indicator for full SDLC mode
948
+ if (isFullSDLC && totalActions > 1) {
949
+ const retryIndicator = retryAttempt > 0 ? ` (retry ${retryAttempt})` : '';
950
+ console.log(c.info(`\n═══ Phase ${currentActionIndex + 1}/${totalActions}: ${action.type.toUpperCase()}${retryIndicator} ═══`));
951
+ }
952
+ const actionResult = await executeAction(action, sdlcRoot);
953
+ // Handle action failure in full SDLC mode
954
+ if (!actionResult.success && isFullSDLC) {
955
+ console.log();
956
+ console.log(c.error(`✗ Phase ${action.type} failed`));
957
+ console.log(c.dim(`Completed ${currentActionIndex} of ${totalActions} phases`));
958
+ console.log(c.info('Fix the error above and use --continue to resume.'));
959
+ return;
960
+ }
961
+ // Handle review rejection in Full SDLC mode - trigger retry loop
962
+ if (isFullSDLC && action.type === 'review' && actionResult.reviewResult) {
963
+ const reviewResult = actionResult.reviewResult;
964
+ if (reviewResult.decision === ReviewDecision.REJECTED) {
965
+ // Load fresh story state and config for retry check
966
+ const story = parseStory(action.storyPath);
967
+ const config = loadConfig();
968
+ // Check if we're at max retries (pass CLI override if provided)
969
+ if (isAtMaxRetries(story, config, maxIterationsOverride)) {
553
970
  console.log();
554
- console.log(c.success('═'.repeat(50)));
555
- console.log(c.success(`✓ Full SDLC completed successfully!`));
556
- console.log(c.success('═'.repeat(50)));
557
- console.log(c.dim(`Completed phases: ${currentActions.length}`));
558
- if (retryAttempt > 0) {
559
- console.log(c.dim(`Retry attempts: ${retryAttempt}`));
560
- }
561
- console.log(c.dim(`Story is now ready for PR creation.`));
562
- await clearWorkflowState(sdlcRoot);
563
- console.log(c.dim('Checkpoint cleared.'));
971
+ console.log(c.error('═'.repeat(50)));
972
+ console.log(c.error(`✗ Review failed - maximum retries reached`));
973
+ console.log(c.error('═'.repeat(50)));
974
+ console.log(c.dim(`Story has reached the maximum retry limit.`));
975
+ console.log(c.dim(`Issues found: ${reviewResult.issues.length}`));
976
+ console.log(c.warning('Manual intervention required to address the review feedback.'));
977
+ console.log(c.info('You can:'));
978
+ console.log(c.dim(' 1. Fix issues manually and run again'));
979
+ console.log(c.dim(' 2. Reset retry count in the story frontmatter'));
980
+ // Using action.storyId - available from action loop context
981
+ await clearWorkflowState(sdlcRoot, action.storyId);
982
+ return;
564
983
  }
565
- else {
566
- // This shouldn't happen if our logic is correct, but handle it
984
+ // We can retry - reset RPIV cycle and loop back
985
+ const currentRetry = (story.frontmatter.retry_count || 0) + 1;
986
+ // Use CLI override, then story-specific, then config default
987
+ const effectiveMaxRetries = maxIterationsOverride !== undefined
988
+ ? maxIterationsOverride
989
+ : (story.frontmatter.max_retries ?? config.reviewConfig?.maxRetries ?? Infinity);
990
+ const maxRetriesDisplay = Number.isFinite(effectiveMaxRetries) ? effectiveMaxRetries : '∞';
991
+ console.log();
992
+ console.log(c.warning(`⟳ Review rejected with ${reviewResult.issues.length} issue(s) - initiating rework (attempt ${currentRetry}/${maxRetriesDisplay})`));
993
+ // Display executive summary
994
+ const summary = generateReviewSummary(reviewResult.issues, getTerminalWidth());
995
+ console.log(c.dim(` Summary: ${summary}`));
996
+ // Reset the RPIV cycle (this increments retry_count and resets flags)
997
+ await resetRPIVCycle(story, reviewResult.feedback);
998
+ // Log what's being reset
999
+ console.log(c.dim(` → Reset plan_complete, implementation_complete, reviews_complete`));
1000
+ console.log(c.dim(` → Retry count: ${currentRetry}/${maxRetriesDisplay}`));
1001
+ // Regenerate actions starting from the phase that needs rework
1002
+ // For now, we restart from 'plan' since that's the typical flow after research
1003
+ const freshStory = parseStory(action.storyPath);
1004
+ const newActions = generateFullSDLCActions(freshStory, c);
1005
+ if (newActions.length > 0) {
1006
+ // Replace remaining actions with the new sequence
1007
+ currentActions = newActions;
1008
+ currentActionIndex = 0;
1009
+ retryAttempt++;
1010
+ console.log(c.info(` → Restarting SDLC from ${newActions[0].type} phase`));
567
1011
  console.log();
568
- console.log(c.warning('All phases executed but reviews_complete is false.'));
569
- console.log(c.dim('This may indicate an issue with the review process.'));
1012
+ continue; // Restart the loop with new actions
1013
+ }
1014
+ else {
1015
+ // No actions to retry (shouldn't happen but handle gracefully)
1016
+ console.log(c.error('Error: No actions generated for retry. Manual intervention required.'));
1017
+ return;
570
1018
  }
571
- break;
572
1019
  }
573
1020
  }
574
- else {
575
- // Normal auto mode: re-assess state
576
- const newAssessment = assessState(sdlcRoot);
577
- if (newAssessment.recommendedActions.length === 0) {
578
- console.log(c.success('\n✓ All actions completed!'));
579
- await clearWorkflowState(sdlcRoot);
580
- console.log(c.dim('Checkpoint cleared.'));
581
- break;
1021
+ // Save checkpoint after successful action
1022
+ if (actionResult.success) {
1023
+ completedActions.push({
1024
+ type: action.type,
1025
+ storyId: action.storyId,
1026
+ storyPath: action.storyPath,
1027
+ completedAt: new Date().toISOString(),
1028
+ });
1029
+ const state = {
1030
+ version: '1.0',
1031
+ workflowId,
1032
+ timestamp: new Date().toISOString(),
1033
+ currentAction: null,
1034
+ completedActions,
1035
+ context: {
1036
+ sdlcRoot,
1037
+ options: {
1038
+ auto: options.auto,
1039
+ dryRun: options.dryRun,
1040
+ story: options.story,
1041
+ fullSDLC: isFullSDLC,
1042
+ },
1043
+ storyContentHash: calculateStoryHash(action.storyPath),
1044
+ },
1045
+ };
1046
+ await saveWorkflowState(state, sdlcRoot, action.storyId);
1047
+ console.log(c.dim(` ✓ Progress saved (${completedActions.length} actions completed)`));
1048
+ }
1049
+ currentActionIndex++;
1050
+ // Re-assess after each action in auto mode
1051
+ if (options.auto) {
1052
+ // For full SDLC mode, check if all phases are complete (and review passed)
1053
+ if (isFullSDLC) {
1054
+ // Check if we've completed all actions in our sequence
1055
+ if (currentActionIndex >= currentActions.length) {
1056
+ // Verify the review actually passed (reviews_complete should be true)
1057
+ const finalStory = parseStory(action.storyPath);
1058
+ if (finalStory.frontmatter.reviews_complete) {
1059
+ console.log();
1060
+ console.log(c.success('═'.repeat(50)));
1061
+ console.log(c.success(`✓ Full SDLC completed successfully!`));
1062
+ console.log(c.success('═'.repeat(50)));
1063
+ console.log(c.dim(`Completed phases: ${currentActions.length}`));
1064
+ if (retryAttempt > 0) {
1065
+ console.log(c.dim(`Retry attempts: ${retryAttempt}`));
1066
+ }
1067
+ console.log(c.dim(`Story is now ready for PR creation.`));
1068
+ // Using action.storyId - available from action loop context
1069
+ await clearWorkflowState(sdlcRoot, action.storyId);
1070
+ console.log(c.dim('Checkpoint cleared.'));
1071
+ }
1072
+ else {
1073
+ // This shouldn't happen if our logic is correct, but handle it
1074
+ console.log();
1075
+ console.log(c.warning('All phases executed but reviews_complete is false.'));
1076
+ console.log(c.dim('This may indicate an issue with the review process.'));
1077
+ }
1078
+ break;
1079
+ }
1080
+ }
1081
+ else {
1082
+ // Normal auto mode: re-assess state
1083
+ const newAssessment = await assessState(sdlcRoot);
1084
+ if (newAssessment.recommendedActions.length === 0) {
1085
+ console.log(c.success('\n✓ All actions completed!'));
1086
+ // Using action.storyId - available from action loop context
1087
+ await clearWorkflowState(sdlcRoot, action.storyId);
1088
+ console.log(c.dim('Checkpoint cleared.'));
1089
+ break;
1090
+ }
582
1091
  }
583
1092
  }
584
1093
  }
585
1094
  }
1095
+ finally {
1096
+ // Restore original working directory if worktree was used
1097
+ if (originalCwd) {
1098
+ process.chdir(originalCwd);
1099
+ }
1100
+ }
586
1101
  }
587
1102
  /**
588
1103
  * Execute a specific action
@@ -592,61 +1107,91 @@ export async function run(options) {
592
1107
  async function executeAction(action, sdlcRoot) {
593
1108
  const config = loadConfig();
594
1109
  const c = getThemedChalk(config);
595
- // Resolve story by ID to get current path (handles moves between folders)
596
- let resolvedPath;
1110
+ const globalLogger = getLogger();
1111
+ const actionStartTime = Date.now();
1112
+ // Log action start to global logger
1113
+ globalLogger.info('action', `Starting action: ${action.type}`, {
1114
+ storyId: action.storyId,
1115
+ actionType: action.type,
1116
+ storyPath: action.storyPath,
1117
+ });
1118
+ // Initialize per-story logger
1119
+ const maxLogs = config.logging?.maxFiles ?? 5;
1120
+ let storyLogger = null;
1121
+ let spinner = null;
597
1122
  try {
598
- const story = getStory(sdlcRoot, action.storyId);
599
- resolvedPath = story.path;
1123
+ storyLogger = new StoryLogger(action.storyId, sdlcRoot, maxLogs);
1124
+ storyLogger.log('INFO', `Starting action: ${action.type} for story ${action.storyId}`);
600
1125
  }
601
1126
  catch (error) {
602
- console.log(c.error(`Error: Story not found for action "${action.type}"`));
603
- console.log(c.dim(` Story ID: ${action.storyId}`));
604
- console.log(c.dim(` Original path: ${action.storyPath}`));
605
- if (error instanceof Error) {
606
- console.log(c.dim(` ${error.message}`));
607
- }
608
- return { success: false };
1127
+ // If logger initialization fails, continue without logging (console-only)
1128
+ console.warn(`Warning: Failed to initialize logger: ${error instanceof Error ? error.message : String(error)}`);
609
1129
  }
610
- // Update action path if it was stale
611
- if (resolvedPath !== action.storyPath) {
612
- console.log(c.warning(`Note: Story path updated (file was moved)`));
613
- console.log(c.dim(` From: ${action.storyPath}`));
614
- console.log(c.dim(` To: ${resolvedPath}`));
615
- action.storyPath = resolvedPath;
616
- }
617
- // Store phase completion state BEFORE action execution (to detect transitions)
618
- const storyBeforeAction = parseStory(action.storyPath);
619
- const prevPhaseState = {
620
- research_complete: storyBeforeAction.frontmatter.research_complete,
621
- plan_complete: storyBeforeAction.frontmatter.plan_complete,
622
- implementation_complete: storyBeforeAction.frontmatter.implementation_complete,
623
- reviews_complete: storyBeforeAction.frontmatter.reviews_complete,
624
- status: storyBeforeAction.frontmatter.status,
625
- };
626
- const spinner = ora(formatAction(action, true, c)).start();
627
- const baseText = formatAction(action, true, c);
628
- // Create agent progress callback for real-time updates
629
- const onAgentProgress = (event) => {
630
- switch (event.type) {
631
- case 'session_start':
632
- spinner.text = `${baseText} ${c.dim('(session started)')}`;
633
- break;
634
- case 'tool_start':
635
- // Show which tool is being executed
636
- const toolName = event.toolName || 'unknown';
637
- const shortName = toolName.replace(/^(mcp__|Mcp)/, '').substring(0, 30);
638
- spinner.text = `${baseText} ${c.dim(`→ ${shortName}`)}`;
639
- break;
640
- case 'tool_end':
641
- // Keep showing the action, tool completed
642
- spinner.text = baseText;
643
- break;
644
- case 'completion':
645
- spinner.text = `${baseText} ${c.dim('(completing...)')}`;
646
- break;
647
- }
648
- };
649
1130
  try {
1131
+ // Resolve story by ID to get current path (handles moves between folders)
1132
+ let resolvedPath;
1133
+ try {
1134
+ const story = getStory(sdlcRoot, action.storyId);
1135
+ resolvedPath = story.path;
1136
+ }
1137
+ catch (error) {
1138
+ const errorMsg = `Error: Story not found for action "${action.type}"`;
1139
+ storyLogger?.log('ERROR', errorMsg);
1140
+ storyLogger?.log('ERROR', ` Story ID: ${action.storyId}`);
1141
+ storyLogger?.log('ERROR', ` Original path: ${action.storyPath}`);
1142
+ console.log(c.error(errorMsg));
1143
+ console.log(c.dim(` Story ID: ${action.storyId}`));
1144
+ console.log(c.dim(` Original path: ${action.storyPath}`));
1145
+ if (error instanceof Error) {
1146
+ storyLogger?.log('ERROR', ` ${error.message}`);
1147
+ console.log(c.dim(` ${error.message}`));
1148
+ }
1149
+ return { success: false };
1150
+ }
1151
+ // Update action path if it was stale
1152
+ if (resolvedPath !== action.storyPath) {
1153
+ storyLogger?.log('WARN', `Note: Story path updated (file was moved)`);
1154
+ storyLogger?.log('WARN', ` From: ${action.storyPath}`);
1155
+ storyLogger?.log('WARN', ` To: ${resolvedPath}`);
1156
+ console.log(c.warning(`Note: Story path updated (file was moved)`));
1157
+ console.log(c.dim(` From: ${action.storyPath}`));
1158
+ console.log(c.dim(` To: ${resolvedPath}`));
1159
+ action.storyPath = resolvedPath;
1160
+ }
1161
+ // Store phase completion state BEFORE action execution (to detect transitions)
1162
+ const storyBeforeAction = parseStory(action.storyPath);
1163
+ const prevPhaseState = {
1164
+ research_complete: storyBeforeAction.frontmatter.research_complete,
1165
+ plan_complete: storyBeforeAction.frontmatter.plan_complete,
1166
+ implementation_complete: storyBeforeAction.frontmatter.implementation_complete,
1167
+ reviews_complete: storyBeforeAction.frontmatter.reviews_complete,
1168
+ status: storyBeforeAction.frontmatter.status,
1169
+ };
1170
+ spinner = ora(formatAction(action, true, c)).start();
1171
+ const baseText = formatAction(action, true, c);
1172
+ // Create agent progress callback for real-time updates
1173
+ const onAgentProgress = (event) => {
1174
+ if (!spinner)
1175
+ return; // Guard against null spinner
1176
+ switch (event.type) {
1177
+ case 'session_start':
1178
+ spinner.text = `${baseText} ${c.dim('(session started)')}`;
1179
+ break;
1180
+ case 'tool_start':
1181
+ // Show which tool is being executed
1182
+ const toolName = event.toolName || 'unknown';
1183
+ const shortName = toolName.replace(/^(mcp__|Mcp)/, '').substring(0, 30);
1184
+ spinner.text = `${baseText} ${c.dim(`→ ${shortName}`)}`;
1185
+ break;
1186
+ case 'tool_end':
1187
+ // Keep showing the action, tool completed
1188
+ spinner.text = baseText;
1189
+ break;
1190
+ case 'completion':
1191
+ spinner.text = `${baseText} ${c.dim('(completing...)')}`;
1192
+ break;
1193
+ }
1194
+ };
650
1195
  // Import and run the appropriate agent
651
1196
  let result;
652
1197
  switch (action.type) {
@@ -670,6 +1215,8 @@ async function executeAction(action, sdlcRoot) {
670
1215
  const { runReviewAgent } = await import('../agents/review.js');
671
1216
  result = await runReviewAgent(action.storyPath, sdlcRoot, {
672
1217
  onVerificationProgress: (phase, status, message) => {
1218
+ if (!spinner)
1219
+ return; // Guard against null spinner
673
1220
  const phaseLabel = phase === 'build' ? 'Building' : 'Testing';
674
1221
  switch (status) {
675
1222
  case 'starting':
@@ -687,6 +1234,53 @@ async function executeAction(action, sdlcRoot) {
687
1234
  }
688
1235
  },
689
1236
  });
1237
+ // Auto-complete story if review was approved
1238
+ if (result && result.success) {
1239
+ const reviewResult = result;
1240
+ let story = parseStory(action.storyPath);
1241
+ story = await autoCompleteStoryAfterReview(story, config, reviewResult);
1242
+ // Log auto-completion if it occurred
1243
+ if (reviewResult.decision === ReviewDecision.APPROVED && config.reviewConfig.autoCompleteOnApproval) {
1244
+ spinner.text = c.success('Review approved - auto-completing story');
1245
+ storyLogger?.log('INFO', `Story auto-completed after review approval: "${story.frontmatter.title}"`);
1246
+ // Auto-create PR in automated mode
1247
+ const workflowState = await loadWorkflowState(sdlcRoot, story.frontmatter.id);
1248
+ const isAutoMode = workflowState?.context.options.auto ?? false;
1249
+ if (isAutoMode || config.reviewConfig.autoCreatePROnApproval) {
1250
+ try {
1251
+ // Create PR (this will automatically commit any uncommitted changes)
1252
+ spinner.text = c.dim('Creating pull request...');
1253
+ const { createPullRequest } = await import('../agents/review.js');
1254
+ const prResult = await createPullRequest(action.storyPath, sdlcRoot);
1255
+ if (prResult.success) {
1256
+ spinner.text = c.success('Review approved - PR created');
1257
+ storyLogger?.log('INFO', `PR created successfully for ${story.frontmatter.id}`);
1258
+ }
1259
+ else {
1260
+ // PR creation failed - mark as blocked
1261
+ const { updateStoryStatus } = await import('../core/story.js');
1262
+ const blockedStory = await updateStoryStatus(story, 'blocked');
1263
+ await writeStory(blockedStory);
1264
+ spinner.text = c.warning('Review approved but PR creation failed - story marked as blocked');
1265
+ storyLogger?.log('WARN', `PR creation failed for ${story.frontmatter.id}: ${prResult.error || 'Unknown error'}`);
1266
+ }
1267
+ }
1268
+ catch (error) {
1269
+ // Error during PR creation - mark as blocked
1270
+ const { updateStoryStatus } = await import('../core/story.js');
1271
+ const blockedStory = await updateStoryStatus(story, 'blocked');
1272
+ await writeStory(blockedStory);
1273
+ const errorMsg = error instanceof Error ? error.message : String(error);
1274
+ spinner.text = c.warning(`Review approved but auto-PR failed: ${errorMsg}`);
1275
+ storyLogger?.log('ERROR', `Auto-PR failed for ${story.frontmatter.id}: ${errorMsg}`);
1276
+ }
1277
+ }
1278
+ // Handle worktree cleanup if story has a worktree
1279
+ if (story.frontmatter.worktree_path) {
1280
+ await handleWorktreeCleanup(story, config, c);
1281
+ }
1282
+ }
1283
+ }
690
1284
  break;
691
1285
  case 'rework':
692
1286
  const { runReworkAgent } = await import('../agents/rework.js');
@@ -703,28 +1297,49 @@ async function executeAction(action, sdlcRoot) {
703
1297
  // Update story status to done (no file move in new architecture)
704
1298
  const { updateStoryStatus } = await import('../core/story.js');
705
1299
  const storyToMove = parseStory(action.storyPath);
706
- const updatedStory = updateStoryStatus(storyToMove, 'done');
1300
+ const updatedStory = await updateStoryStatus(storyToMove, 'done');
707
1301
  result = {
708
1302
  success: true,
709
1303
  story: updatedStory,
710
1304
  changesMade: ['Updated story status to done'],
711
1305
  };
1306
+ // Worktree cleanup prompt (if story has a worktree)
1307
+ if (storyToMove.frontmatter.worktree_path) {
1308
+ await handleWorktreeCleanup(storyToMove, config, c);
1309
+ }
712
1310
  break;
713
1311
  default:
714
1312
  throw new Error(`Unknown action type: ${action.type}`);
715
1313
  }
716
1314
  // Check if agent succeeded
1315
+ const actionDuration = Date.now() - actionStartTime;
717
1316
  if (result && !result.success) {
718
1317
  spinner.fail(c.error(`Failed: ${formatAction(action, true, c)}`));
1318
+ storyLogger?.log('ERROR', `Action failed: ${formatAction(action, false, c)}`);
1319
+ globalLogger.warn('action', `Action failed: ${action.type}`, {
1320
+ storyId: action.storyId,
1321
+ actionType: action.type,
1322
+ durationMs: actionDuration,
1323
+ error: result.error,
1324
+ });
719
1325
  if (result.error) {
1326
+ storyLogger?.log('ERROR', ` Error: ${result.error}`);
720
1327
  console.error(c.error(` Error: ${result.error}`));
721
1328
  }
722
1329
  return { success: false };
723
1330
  }
724
1331
  spinner.succeed(c.success(formatAction(action, true, c)));
1332
+ storyLogger?.log('INFO', `Action completed successfully: ${formatAction(action, false, c)}`);
1333
+ globalLogger.info('action', `Action completed: ${action.type}`, {
1334
+ storyId: action.storyId,
1335
+ actionType: action.type,
1336
+ durationMs: actionDuration,
1337
+ changesCount: result?.changesMade?.length ?? 0,
1338
+ });
725
1339
  // Show changes made
726
1340
  if (result && result.changesMade.length > 0) {
727
1341
  for (const change of result.changesMade) {
1342
+ storyLogger?.log('INFO', ` → ${change}`);
728
1343
  console.log(c.dim(` → ${change}`));
729
1344
  }
730
1345
  }
@@ -780,14 +1395,28 @@ async function executeAction(action, sdlcRoot) {
780
1395
  return { success: true };
781
1396
  }
782
1397
  catch (error) {
783
- spinner.fail(c.error(`Failed: ${formatAction(action, true, c)}`));
1398
+ const exceptionDuration = Date.now() - actionStartTime;
1399
+ if (spinner) {
1400
+ spinner.fail(c.error(`Failed: ${formatAction(action, true, c)}`));
1401
+ }
1402
+ else {
1403
+ console.error(c.error(`Failed: ${formatAction(action, true, c)}`));
1404
+ }
1405
+ const errorMessage = error instanceof Error ? error.message : String(error);
1406
+ storyLogger?.log('ERROR', `Exception during action execution: ${errorMessage}`);
1407
+ globalLogger.error('action', `Action exception: ${action.type}`, {
1408
+ storyId: action.storyId,
1409
+ actionType: action.type,
1410
+ durationMs: exceptionDuration,
1411
+ error: errorMessage,
1412
+ });
784
1413
  console.error(error);
785
1414
  // Show phase checklist with error indication (if file still exists)
786
1415
  try {
787
1416
  const story = parseStory(action.storyPath);
788
1417
  console.log(c.dim(` Progress: ${renderPhaseChecklist(story, c)}`));
789
1418
  // Update story with error
790
- story.frontmatter.last_error = error instanceof Error ? error.message : String(error);
1419
+ story.frontmatter.last_error = errorMessage;
791
1420
  }
792
1421
  catch {
793
1422
  // File may have been moved - skip progress display
@@ -795,6 +1424,10 @@ async function executeAction(action, sdlcRoot) {
795
1424
  // Don't throw - let the workflow continue if in auto mode
796
1425
  return { success: false };
797
1426
  }
1427
+ finally {
1428
+ // Always close logger, even if action fails or throws
1429
+ storyLogger?.close();
1430
+ }
798
1431
  }
799
1432
  /**
800
1433
  * Get phase information for an action type
@@ -1202,7 +1835,7 @@ function isEmptySection(content) {
1202
1835
  /**
1203
1836
  * Unblock a story from the blocked folder and move it back to the workflow
1204
1837
  */
1205
- export function unblock(storyId, options) {
1838
+ export async function unblock(storyId, options) {
1206
1839
  const spinner = ora('Unblocking story...').start();
1207
1840
  const config = loadConfig();
1208
1841
  const c = getThemedChalk(config);
@@ -1213,7 +1846,7 @@ export function unblock(storyId, options) {
1213
1846
  return;
1214
1847
  }
1215
1848
  // Unblock the story (using renamed import to avoid naming conflict)
1216
- const unblockedStory = unblockStory(storyId, sdlcRoot, options);
1849
+ const unblockedStory = await unblockStory(storyId, sdlcRoot, options);
1217
1850
  // Determine destination folder from updated path
1218
1851
  const destinationFolder = unblockedStory.path.match(/\/([^/]+)\/[^/]+\.md$/)?.[1] || 'unknown';
1219
1852
  spinner.succeed(c.success(`Unblocked story ${storyId}, moved to ${destinationFolder}/`));
@@ -1327,4 +1960,275 @@ export async function migrate(options) {
1327
1960
  process.exit(1);
1328
1961
  }
1329
1962
  }
1963
+ /**
1964
+ * Helper function to prompt for removal confirmation
1965
+ */
1966
+ async function confirmRemoval(message) {
1967
+ // Sanitize message to prevent terminal injection attacks
1968
+ // Use consistent sanitizeForDisplay() for all terminal output
1969
+ const sanitizedMessage = sanitizeForDisplay(message);
1970
+ const rl = readline.createInterface({
1971
+ input: process.stdin,
1972
+ output: process.stdout,
1973
+ });
1974
+ return new Promise((resolve) => {
1975
+ rl.question(sanitizedMessage + ' (y/N): ', (answer) => {
1976
+ rl.close();
1977
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
1978
+ });
1979
+ });
1980
+ }
1981
+ /**
1982
+ * Handle worktree cleanup when story moves to done
1983
+ * Prompts user in interactive mode to remove worktree
1984
+ */
1985
+ async function handleWorktreeCleanup(story, config, c) {
1986
+ const worktreePath = story.frontmatter.worktree_path;
1987
+ if (!worktreePath)
1988
+ return;
1989
+ const sdlcRoot = getSdlcRoot();
1990
+ const workingDir = path.dirname(sdlcRoot);
1991
+ const worktreeConfig = config.worktree ?? DEFAULT_WORKTREE_CONFIG;
1992
+ // Check if worktree exists
1993
+ if (!fs.existsSync(worktreePath)) {
1994
+ console.log(c.warning(` Note: Worktree path no longer exists: ${worktreePath}`));
1995
+ const updated = await updateStoryField(story, 'worktree_path', undefined);
1996
+ await writeStory(updated);
1997
+ console.log(c.dim(' Cleared worktree_path from frontmatter'));
1998
+ return;
1999
+ }
2000
+ // Only prompt in interactive mode
2001
+ if (!process.stdin.isTTY) {
2002
+ console.log(c.dim(` Worktree preserved (non-interactive mode): ${worktreePath}`));
2003
+ return;
2004
+ }
2005
+ // Prompt for cleanup
2006
+ console.log();
2007
+ console.log(c.info(` Story has a worktree at: ${worktreePath}`));
2008
+ const shouldRemove = await confirmRemoval(' Remove worktree?');
2009
+ if (!shouldRemove) {
2010
+ console.log(c.dim(' Worktree preserved'));
2011
+ return;
2012
+ }
2013
+ // Remove worktree
2014
+ try {
2015
+ let resolvedBasePath;
2016
+ try {
2017
+ resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
2018
+ }
2019
+ catch {
2020
+ resolvedBasePath = path.dirname(worktreePath);
2021
+ }
2022
+ const service = new GitWorktreeService(workingDir, resolvedBasePath);
2023
+ service.remove(worktreePath);
2024
+ const updated = await updateStoryField(story, 'worktree_path', undefined);
2025
+ await writeStory(updated);
2026
+ console.log(c.success(' ✓ Worktree removed'));
2027
+ }
2028
+ catch (error) {
2029
+ console.log(c.warning(` Failed to remove worktree: ${error instanceof Error ? error.message : String(error)}`));
2030
+ // Clear frontmatter anyway (user may have manually deleted)
2031
+ const updated = await updateStoryField(story, 'worktree_path', undefined);
2032
+ await writeStory(updated);
2033
+ }
2034
+ }
2035
+ /**
2036
+ * Security: Escape shell arguments for safe use in commands
2037
+ * For use with execSync when shell execution is required
2038
+ * @internal Exported for testing
2039
+ */
2040
+ export function escapeShellArg(arg) {
2041
+ // Replace single quotes with '\'' and wrap in single quotes
2042
+ return `'${arg.replace(/'/g, "'\\''")}'`;
2043
+ }
2044
+ /**
2045
+ * List all ai-sdlc managed worktrees
2046
+ */
2047
+ export async function listWorktrees() {
2048
+ const config = loadConfig();
2049
+ const c = getThemedChalk(config);
2050
+ try {
2051
+ const sdlcRoot = getSdlcRoot();
2052
+ const workingDir = path.dirname(sdlcRoot);
2053
+ const worktreeConfig = config.worktree ?? DEFAULT_WORKTREE_CONFIG;
2054
+ // Resolve worktree base path
2055
+ let resolvedBasePath;
2056
+ try {
2057
+ resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
2058
+ }
2059
+ catch (error) {
2060
+ // If basePath doesn't exist yet, create an empty list response
2061
+ console.log();
2062
+ console.log(c.bold('═══ Worktrees ═══'));
2063
+ console.log();
2064
+ console.log(c.dim('No worktrees found.'));
2065
+ console.log(c.dim('Use `ai-sdlc worktrees add <story-id>` to create one.'));
2066
+ console.log();
2067
+ return;
2068
+ }
2069
+ const service = new GitWorktreeService(workingDir, resolvedBasePath);
2070
+ const worktrees = service.list();
2071
+ console.log();
2072
+ console.log(c.bold('═══ Worktrees ═══'));
2073
+ console.log();
2074
+ if (worktrees.length === 0) {
2075
+ console.log(c.dim('No worktrees found.'));
2076
+ console.log(c.dim('Use `ai-sdlc worktrees add <story-id>` to create one.'));
2077
+ }
2078
+ else {
2079
+ // Table header
2080
+ console.log(c.dim('Story ID'.padEnd(12) + 'Branch'.padEnd(40) + 'Status'.padEnd(10) + 'Path'));
2081
+ console.log(c.dim('─'.repeat(80)));
2082
+ for (const wt of worktrees) {
2083
+ const storyId = wt.storyId || 'unknown';
2084
+ const branch = wt.branch.length > 38 ? wt.branch.substring(0, 35) + '...' : wt.branch;
2085
+ const status = wt.exists ? c.success('exists') : c.error('missing');
2086
+ const displayPath = wt.path.length > 50 ? '...' + wt.path.slice(-47) : wt.path;
2087
+ console.log(storyId.padEnd(12) +
2088
+ branch.padEnd(40) +
2089
+ (wt.exists ? 'exists ' : 'missing ') +
2090
+ displayPath);
2091
+ }
2092
+ console.log();
2093
+ console.log(c.dim(`Total: ${worktrees.length} worktree(s)`));
2094
+ }
2095
+ console.log();
2096
+ }
2097
+ catch (error) {
2098
+ console.log(c.error(`Error listing worktrees: ${error instanceof Error ? error.message : String(error)}`));
2099
+ process.exit(1);
2100
+ }
2101
+ }
2102
+ /**
2103
+ * Create a worktree for a specific story
2104
+ */
2105
+ export async function addWorktree(storyId) {
2106
+ const spinner = ora('Creating worktree...').start();
2107
+ const config = loadConfig();
2108
+ const c = getThemedChalk(config);
2109
+ try {
2110
+ const sdlcRoot = getSdlcRoot();
2111
+ const workingDir = path.dirname(sdlcRoot);
2112
+ if (!kanbanExists(sdlcRoot)) {
2113
+ spinner.fail('ai-sdlc not initialized. Run `ai-sdlc init` first.');
2114
+ return;
2115
+ }
2116
+ // Find the story
2117
+ const story = findStoryById(sdlcRoot, storyId) || findStoryBySlug(sdlcRoot, storyId);
2118
+ if (!story) {
2119
+ spinner.fail(c.error(`Story not found: "${storyId}"`));
2120
+ console.log(c.dim('Use `ai-sdlc status` to see available stories.'));
2121
+ return;
2122
+ }
2123
+ // Check if story already has a worktree
2124
+ if (story.frontmatter.worktree_path) {
2125
+ spinner.fail(c.error(`Story already has a worktree: ${story.frontmatter.worktree_path}`));
2126
+ return;
2127
+ }
2128
+ // Resolve worktree base path
2129
+ const worktreeConfig = config.worktree ?? DEFAULT_WORKTREE_CONFIG;
2130
+ let resolvedBasePath;
2131
+ try {
2132
+ resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
2133
+ }
2134
+ catch (error) {
2135
+ spinner.fail(c.error(`Configuration Error: ${error instanceof Error ? error.message : String(error)}`));
2136
+ console.log(c.dim('Fix worktree.basePath in .ai-sdlc.json or remove it to use default location'));
2137
+ return;
2138
+ }
2139
+ const service = new GitWorktreeService(workingDir, resolvedBasePath);
2140
+ // Validate git state
2141
+ const validation = service.validateCanCreateWorktree();
2142
+ if (!validation.valid) {
2143
+ spinner.fail(c.error(validation.error || 'Cannot create worktree'));
2144
+ return;
2145
+ }
2146
+ // Detect base branch
2147
+ const baseBranch = service.detectBaseBranch();
2148
+ // Create the worktree
2149
+ const worktreePath = service.create({
2150
+ storyId: story.frontmatter.id,
2151
+ slug: story.slug,
2152
+ baseBranch,
2153
+ });
2154
+ // Update story frontmatter
2155
+ const updatedStory = await updateStoryField(story, 'worktree_path', worktreePath);
2156
+ const branchName = service.getBranchName(story.frontmatter.id, story.slug);
2157
+ const storyWithBranch = await updateStoryField(updatedStory, 'branch', branchName);
2158
+ await writeStory(storyWithBranch);
2159
+ spinner.succeed(c.success(`Created worktree for ${story.frontmatter.id}`));
2160
+ console.log(c.dim(` Path: ${worktreePath}`));
2161
+ console.log(c.dim(` Branch: ${branchName}`));
2162
+ console.log(c.dim(` Base: ${baseBranch}`));
2163
+ }
2164
+ catch (error) {
2165
+ spinner.fail(c.error('Failed to create worktree'));
2166
+ console.error(c.error(` ${error instanceof Error ? error.message : String(error)}`));
2167
+ process.exit(1);
2168
+ }
2169
+ }
2170
+ /**
2171
+ * Remove a worktree for a specific story
2172
+ */
2173
+ export async function removeWorktree(storyId, options) {
2174
+ const config = loadConfig();
2175
+ const c = getThemedChalk(config);
2176
+ try {
2177
+ const sdlcRoot = getSdlcRoot();
2178
+ const workingDir = path.dirname(sdlcRoot);
2179
+ if (!kanbanExists(sdlcRoot)) {
2180
+ console.log(c.warning('ai-sdlc not initialized. Run `ai-sdlc init` first.'));
2181
+ return;
2182
+ }
2183
+ // Find the story
2184
+ const story = findStoryById(sdlcRoot, storyId) || findStoryBySlug(sdlcRoot, storyId);
2185
+ if (!story) {
2186
+ console.log(c.error(`Story not found: "${storyId}"`));
2187
+ console.log(c.dim('Use `ai-sdlc status` to see available stories.'));
2188
+ return;
2189
+ }
2190
+ // Check if story has a worktree
2191
+ if (!story.frontmatter.worktree_path) {
2192
+ console.log(c.warning(`Story ${storyId} does not have a worktree.`));
2193
+ return;
2194
+ }
2195
+ const worktreePath = story.frontmatter.worktree_path;
2196
+ // Confirm removal (unless --force)
2197
+ if (!options?.force) {
2198
+ console.log();
2199
+ console.log(c.warning('About to remove worktree:'));
2200
+ console.log(c.dim(` Story: ${story.frontmatter.title}`));
2201
+ console.log(c.dim(` Path: ${worktreePath}`));
2202
+ console.log();
2203
+ const confirmed = await confirmRemoval('Are you sure you want to remove this worktree?');
2204
+ if (!confirmed) {
2205
+ console.log(c.dim('Cancelled.'));
2206
+ return;
2207
+ }
2208
+ }
2209
+ const spinner = ora('Removing worktree...').start();
2210
+ // Resolve worktree base path
2211
+ const worktreeConfig = config.worktree ?? DEFAULT_WORKTREE_CONFIG;
2212
+ let resolvedBasePath;
2213
+ try {
2214
+ resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
2215
+ }
2216
+ catch {
2217
+ // If basePath doesn't exist, use the worktree path's parent
2218
+ resolvedBasePath = path.dirname(worktreePath);
2219
+ }
2220
+ const service = new GitWorktreeService(workingDir, resolvedBasePath);
2221
+ // Remove the worktree
2222
+ service.remove(worktreePath);
2223
+ // Clear worktree_path from frontmatter
2224
+ const updatedStory = await updateStoryField(story, 'worktree_path', undefined);
2225
+ await writeStory(updatedStory);
2226
+ spinner.succeed(c.success(`Removed worktree for ${story.frontmatter.id}`));
2227
+ console.log(c.dim(` Path: ${worktreePath}`));
2228
+ }
2229
+ catch (error) {
2230
+ console.log(c.error(`Failed to remove worktree: ${error instanceof Error ? error.message : String(error)}`));
2231
+ process.exit(1);
2232
+ }
2233
+ }
1330
2234
  //# sourceMappingURL=commands.js.map