ai-sdlc 0.2.0-alpha.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) 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 +87 -13
  5. package/dist/agents/implementation.js.map +1 -1
  6. package/dist/agents/planning.d.ts.map +1 -1
  7. package/dist/agents/planning.js +22 -3
  8. package/dist/agents/planning.js.map +1 -1
  9. package/dist/agents/refinement.d.ts.map +1 -1
  10. package/dist/agents/refinement.js +22 -3
  11. package/dist/agents/refinement.js.map +1 -1
  12. package/dist/agents/research.d.ts +85 -1
  13. package/dist/agents/research.d.ts.map +1 -1
  14. package/dist/agents/research.js +506 -16
  15. package/dist/agents/research.js.map +1 -1
  16. package/dist/agents/review.d.ts +67 -2
  17. package/dist/agents/review.d.ts.map +1 -1
  18. package/dist/agents/review.js +477 -68
  19. package/dist/agents/review.js.map +1 -1
  20. package/dist/agents/rework.d.ts.map +1 -1
  21. package/dist/agents/rework.js +22 -3
  22. package/dist/agents/rework.js.map +1 -1
  23. package/dist/agents/state-assessor.d.ts +3 -3
  24. package/dist/agents/state-assessor.d.ts.map +1 -1
  25. package/dist/agents/state-assessor.js +6 -6
  26. package/dist/agents/state-assessor.js.map +1 -1
  27. package/dist/agents/test-pattern-detector.d.ts +49 -0
  28. package/dist/agents/test-pattern-detector.d.ts.map +1 -0
  29. package/dist/agents/test-pattern-detector.js +273 -0
  30. package/dist/agents/test-pattern-detector.js.map +1 -0
  31. package/dist/agents/verification.d.ts +11 -0
  32. package/dist/agents/verification.d.ts.map +1 -1
  33. package/dist/agents/verification.js +74 -1
  34. package/dist/agents/verification.js.map +1 -1
  35. package/dist/cli/commands/migrate.js +1 -1
  36. package/dist/cli/commands/migrate.js.map +1 -1
  37. package/dist/cli/commands.d.ts +43 -3
  38. package/dist/cli/commands.d.ts.map +1 -1
  39. package/dist/cli/commands.js +588 -150
  40. package/dist/cli/commands.js.map +1 -1
  41. package/dist/cli/daemon.d.ts.map +1 -1
  42. package/dist/cli/daemon.js +20 -3
  43. package/dist/cli/daemon.js.map +1 -1
  44. package/dist/cli/runner.d.ts.map +1 -1
  45. package/dist/cli/runner.js +18 -6
  46. package/dist/cli/runner.js.map +1 -1
  47. package/dist/core/auth.d.ts +43 -0
  48. package/dist/core/auth.d.ts.map +1 -1
  49. package/dist/core/auth.js +105 -1
  50. package/dist/core/auth.js.map +1 -1
  51. package/dist/core/client.d.ts +6 -0
  52. package/dist/core/client.d.ts.map +1 -1
  53. package/dist/core/client.js +57 -3
  54. package/dist/core/client.js.map +1 -1
  55. package/dist/core/config.d.ts +5 -1
  56. package/dist/core/config.d.ts.map +1 -1
  57. package/dist/core/config.js +27 -0
  58. package/dist/core/config.js.map +1 -1
  59. package/dist/core/conflict-detector.d.ts +108 -0
  60. package/dist/core/conflict-detector.d.ts.map +1 -0
  61. package/dist/core/conflict-detector.js +413 -0
  62. package/dist/core/conflict-detector.js.map +1 -0
  63. package/dist/core/git-utils.d.ts +10 -1
  64. package/dist/core/git-utils.d.ts.map +1 -1
  65. package/dist/core/git-utils.js +55 -4
  66. package/dist/core/git-utils.js.map +1 -1
  67. package/dist/core/index.d.ts +17 -0
  68. package/dist/core/index.d.ts.map +1 -0
  69. package/dist/core/index.js +17 -0
  70. package/dist/core/index.js.map +1 -0
  71. package/dist/core/kanban.d.ts +1 -1
  72. package/dist/core/kanban.d.ts.map +1 -1
  73. package/dist/core/kanban.js +3 -3
  74. package/dist/core/kanban.js.map +1 -1
  75. package/dist/core/logger.d.ts +92 -0
  76. package/dist/core/logger.d.ts.map +1 -0
  77. package/dist/core/logger.js +221 -0
  78. package/dist/core/logger.js.map +1 -0
  79. package/dist/core/story-logger.d.ts +102 -0
  80. package/dist/core/story-logger.d.ts.map +1 -0
  81. package/dist/core/story-logger.js +265 -0
  82. package/dist/core/story-logger.js.map +1 -0
  83. package/dist/core/story.d.ts +79 -20
  84. package/dist/core/story.d.ts.map +1 -1
  85. package/dist/core/story.js +221 -39
  86. package/dist/core/story.js.map +1 -1
  87. package/dist/core/workflow-state.d.ts +45 -6
  88. package/dist/core/workflow-state.d.ts.map +1 -1
  89. package/dist/core/workflow-state.js +201 -12
  90. package/dist/core/workflow-state.js.map +1 -1
  91. package/dist/core/worktree.d.ts +9 -0
  92. package/dist/core/worktree.d.ts.map +1 -1
  93. package/dist/core/worktree.js +52 -1
  94. package/dist/core/worktree.js.map +1 -1
  95. package/dist/index.js +112 -6
  96. package/dist/index.js.map +1 -1
  97. package/dist/types/index.d.ts +123 -1
  98. package/dist/types/index.d.ts.map +1 -1
  99. package/dist/types/index.js +1 -0
  100. package/dist/types/index.js.map +1 -1
  101. package/package.json +3 -1
@@ -3,8 +3,8 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  import * as readline from 'readline';
5
5
  import { getSdlcRoot, loadConfig, initConfig, validateWorktreeBasePath, DEFAULT_WORKTREE_CONFIG } from '../core/config.js';
6
- import { initializeKanban, kanbanExists, assessState, getBoardStats, findStoryBySlug } from '../core/kanban.js';
7
- import { createStory, parseStory, resetRPIVCycle, isAtMaxRetries, unblockStory, getStory, findStoryById, updateStoryField, writeStory } from '../core/story.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 } from '../core/story.js';
8
8
  import { GitWorktreeService } from '../core/worktree.js';
9
9
  import { ReviewDecision } from '../types/index.js';
10
10
  import { getThemedChalk } from '../core/theme.js';
@@ -15,6 +15,9 @@ import { migrateToFolderPerStory } from './commands/migrate.js';
15
15
  import { generateReviewSummary } from '../agents/review.js';
16
16
  import { getTerminalWidth } from './formatting.js';
17
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';
18
21
  /**
19
22
  * Initialize the .ai-sdlc folder structure
20
23
  */
@@ -52,7 +55,7 @@ export async function status(options) {
52
55
  console.log(c.warning('ai-sdlc not initialized. Run `ai-sdlc init` first.'));
53
56
  return;
54
57
  }
55
- const assessment = assessState(sdlcRoot);
58
+ const assessment = await assessState(sdlcRoot);
56
59
  const stats = getBoardStats(sdlcRoot);
57
60
  console.log();
58
61
  console.log(c.bold('═══ AI SDLC Board ═══'));
@@ -117,10 +120,58 @@ export async function status(options) {
117
120
  console.log(c.success('No pending actions. Board is up to date!'));
118
121
  }
119
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
+ }
120
171
  /**
121
172
  * Add a new story to the backlog
122
173
  */
123
- export async function add(title) {
174
+ export async function add(title, options) {
124
175
  const spinner = ora('Creating story...').start();
125
176
  try {
126
177
  const config = loadConfig();
@@ -130,10 +181,83 @@ export async function add(title) {
130
181
  spinner.fail('ai-sdlc not initialized. Run `ai-sdlc init` first.');
131
182
  return;
132
183
  }
133
- 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);
134
254
  spinner.succeed(c.success(`Created: ${story.path}`));
135
255
  console.log(c.dim(` ID: ${story.frontmatter.id}`));
256
+ console.log(c.dim(` Title: ${story.frontmatter.title}`));
136
257
  console.log(c.dim(` Slug: ${story.slug}`));
258
+ if (options?.file) {
259
+ console.log(c.dim(` Source: ${path.basename(options.file)}`));
260
+ }
137
261
  console.log();
138
262
  console.log(c.info('Next step:'), `ai-sdlc run`);
139
263
  }
@@ -218,6 +342,23 @@ const GIT_MODIFYING_ACTIONS = ['implement', 'review', 'create_pr'];
218
342
  function requiresGitValidation(actions) {
219
343
  return actions.some(action => GIT_MODIFYING_ACTIONS.includes(action.type));
220
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
+ }
221
362
  /**
222
363
  * Display git validation errors and warnings
223
364
  */
@@ -239,6 +380,160 @@ function displayGitValidationResult(result, c) {
239
380
  }
240
381
  }
241
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
+ }
242
537
  /**
243
538
  * Run the workflow (process one action or all)
244
539
  */
@@ -250,6 +545,26 @@ export async function run(options) {
250
545
  : undefined;
251
546
  let sdlcRoot = getSdlcRoot();
252
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
+ }
253
568
  // Handle daemon/watch mode
254
569
  if (options.watch) {
255
570
  console.log(c.info('🚀 Starting daemon mode...'));
@@ -287,8 +602,15 @@ export async function run(options) {
287
602
  let completedActions = [];
288
603
  let storyContentHash;
289
604
  if (options.continue) {
290
- // Try to load existing state
291
- 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);
292
614
  if (!existingState) {
293
615
  console.log(c.error('Error: No checkpoint found.'));
294
616
  console.log(c.dim('Remove --continue flag to start a new workflow.'));
@@ -334,15 +656,26 @@ export async function run(options) {
334
656
  console.log();
335
657
  }
336
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
+ }
337
665
  // Check if there's an existing state and suggest --continue
338
- 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) {
339
670
  console.log(c.info('Note: Found previous checkpoint. Use --continue to resume.'));
340
671
  console.log();
341
672
  }
342
673
  // Start new workflow
343
674
  workflowId = generateWorkflowId();
344
675
  }
345
- 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;
346
679
  // Filter actions by story if --story flag is provided
347
680
  if (options.story) {
348
681
  const normalizedInput = options.story.toLowerCase().trim();
@@ -353,7 +686,7 @@ export async function run(options) {
353
686
  return;
354
687
  }
355
688
  // Try to find story by ID first, then by slug (case-insensitive)
356
- let targetStory = findStoryById(sdlcRoot, normalizedInput);
689
+ targetStory = findStoryById(sdlcRoot, normalizedInput);
357
690
  if (!targetStory) {
358
691
  targetStory = findStoryBySlug(sdlcRoot, normalizedInput);
359
692
  }
@@ -425,7 +758,8 @@ export async function run(options) {
425
758
  }
426
759
  // Clear state if workflow is complete
427
760
  if (options.continue || hasWorkflowState(sdlcRoot)) {
428
- await clearWorkflowState(sdlcRoot);
761
+ // Using options.story - action not yet created in early exit path
762
+ await clearWorkflowState(sdlcRoot, options.story);
429
763
  console.log(c.dim('Checkpoint cleared.'));
430
764
  }
431
765
  return;
@@ -466,105 +800,136 @@ export async function run(options) {
466
800
  actionsToProcess = remainingActions;
467
801
  if (actionsToProcess.length === 0) {
468
802
  console.log(c.success('All actions from checkpoint already completed!'));
469
- await clearWorkflowState(sdlcRoot);
803
+ // Using options.story - action not yet created in early exit path
804
+ await clearWorkflowState(sdlcRoot, options.story);
470
805
  console.log(c.dim('Checkpoint cleared.'));
471
806
  return;
472
807
  }
473
808
  }
474
- // Validate git state before processing actions that modify git
475
- if (!options.force && requiresGitValidation(actionsToProcess)) {
476
- const workingDir = path.dirname(sdlcRoot);
477
- const gitValidation = validateGitState(workingDir);
478
- if (!gitValidation.valid) {
479
- displayGitValidationResult(gitValidation, c);
480
- return;
481
- }
482
- if (gitValidation.warnings.length > 0) {
483
- displayGitValidationResult(gitValidation, c);
484
- console.log();
485
- }
486
- }
487
- // Handle worktree creation based on flags and config
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
488
813
  let worktreePath;
489
814
  let originalCwd;
815
+ let worktreeCreated = false;
490
816
  // Determine if worktree should be used
491
- // Priority: CLI flags > config > default (disabled)
817
+ // Priority: CLI flags > story frontmatter > config > default (disabled)
492
818
  const worktreeConfig = config.worktree ?? DEFAULT_WORKTREE_CONFIG;
493
- const shouldUseWorktree = (() => {
494
- // Explicit --no-worktree disables worktrees
495
- if (options.worktree === false)
496
- return false;
497
- // Explicit --worktree enables worktrees
498
- if (options.worktree === true)
499
- return true;
500
- // Fall back to config default
501
- return worktreeConfig.enabled;
502
- })();
819
+ // Reuse targetStory from earlier lookup (DRY - avoids duplicate story lookup)
820
+ const shouldUseWorktree = determineWorktreeMode(options, worktreeConfig, targetStory);
503
821
  // Validate that worktree mode requires --story
504
822
  if (shouldUseWorktree && !options.story) {
505
823
  if (options.worktree === true) {
506
- // Explicit --worktree flag without --story is an error
507
824
  console.log(c.error('Error: --worktree requires --story flag'));
508
825
  return;
509
826
  }
510
- // Config-enabled worktree without --story just silently skips worktree
511
827
  }
512
- if (shouldUseWorktree && options.story) {
513
- const workingDir = path.dirname(sdlcRoot);
514
- // Resolve worktree base path from config
515
- let resolvedBasePath;
516
- try {
517
- resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
518
- }
519
- catch (error) {
520
- console.log(c.error(`Configuration Error: ${error instanceof Error ? error.message : String(error)}`));
521
- console.log(c.dim('Fix worktree.basePath in .ai-sdlc.json or remove it to use default location'));
522
- return;
523
- }
524
- const worktreeService = new GitWorktreeService(workingDir, resolvedBasePath);
525
- // Validate git state for worktree creation
526
- const validation = worktreeService.validateCanCreateWorktree();
527
- if (!validation.valid) {
528
- console.log(c.error(`Error: ${validation.error}`));
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.'));
529
833
  return;
530
834
  }
531
- // Get the target story (already loaded from --story processing above)
532
- const targetStory = findStoryById(sdlcRoot, options.story) || findStoryBySlug(sdlcRoot, options.story);
533
- if (!targetStory) {
534
- console.log(c.error(`Error: Story not found: "${options.story}"`));
535
- return;
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();
536
839
  }
537
- try {
538
- // Detect base branch
539
- const baseBranch = worktreeService.detectBaseBranch();
540
- // Create worktree
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
541
845
  originalCwd = process.cwd();
542
- worktreePath = worktreeService.create({
543
- storyId: targetStory.frontmatter.id,
544
- slug: targetStory.slug,
545
- baseBranch,
546
- });
547
- // Update story frontmatter with worktree path
548
- const updatedStory = updateStoryField(targetStory, 'worktree_path', worktreePath);
549
- await writeStory(updatedStory);
550
- // Change to worktree directory
846
+ worktreePath = existingWorktreePath;
551
847
  process.chdir(worktreePath);
552
- // Recalculate sdlcRoot for the worktree context
553
- // Since we've changed cwd to the worktree, getSdlcRoot() will now return the worktree's .ai-sdlc path
554
- // This ensures all subsequent agent operations work within the isolated worktree
555
848
  sdlcRoot = getSdlcRoot();
556
- console.log(c.success(`✓ Created worktree at: ${worktreePath}`));
849
+ worktreeCreated = true;
850
+ console.log(c.success(`✓ Resuming in existing worktree: ${worktreePath}`));
557
851
  console.log(c.dim(` Branch: ai-sdlc/${targetStory.frontmatter.id}-${targetStory.slug}`));
558
852
  console.log();
559
853
  }
560
- catch (error) {
561
- // Restore directory on worktree creation failure
562
- if (originalCwd) {
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;
898
+ }
899
+ console.log(c.success(`✓ Created worktree at: ${worktreePath}`));
900
+ console.log(c.dim(` Branch: ai-sdlc/${targetStory.frontmatter.id}-${targetStory.slug}`));
901
+ console.log();
902
+ }
903
+ catch (error) {
904
+ // Restore directory on worktree creation failure
905
+ if (originalCwd) {
906
+ process.chdir(originalCwd);
907
+ }
908
+ console.log(c.error(`Failed to create worktree: ${error instanceof Error ? error.message : String(error)}`));
909
+ return;
910
+ }
911
+ }
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
+ // Exclude .ai-sdlc/** from clean check when worktree was created (story file was just updated)
916
+ if (!options.force && requiresGitValidation(actionsToProcess)) {
917
+ const workingDir = path.dirname(sdlcRoot);
918
+ const gitValidationOptions = worktreeCreated
919
+ ? { skipBranchCheck: true, excludePatterns: ['.ai-sdlc/**'] }
920
+ : {};
921
+ const gitValidation = validateGitState(workingDir, gitValidationOptions);
922
+ if (!gitValidation.valid) {
923
+ displayGitValidationResult(gitValidation, c);
924
+ if (worktreeCreated && originalCwd) {
563
925
  process.chdir(originalCwd);
564
926
  }
565
- console.log(c.error(`Failed to create worktree: ${error instanceof Error ? error.message : String(error)}`));
566
927
  return;
567
928
  }
929
+ if (gitValidation.warnings.length > 0) {
930
+ displayGitValidationResult(gitValidation, c);
931
+ console.log();
932
+ }
568
933
  }
569
934
  // Process actions with retry support for Full SDLC mode
570
935
  let currentActions = [...actionsToProcess];
@@ -608,7 +973,8 @@ export async function run(options) {
608
973
  console.log(c.info('You can:'));
609
974
  console.log(c.dim(' 1. Fix issues manually and run again'));
610
975
  console.log(c.dim(' 2. Reset retry count in the story frontmatter'));
611
- await clearWorkflowState(sdlcRoot);
976
+ // Using action.storyId - available from action loop context
977
+ await clearWorkflowState(sdlcRoot, action.storyId);
612
978
  return;
613
979
  }
614
980
  // We can retry - reset RPIV cycle and loop back
@@ -624,7 +990,7 @@ export async function run(options) {
624
990
  const summary = generateReviewSummary(reviewResult.issues, getTerminalWidth());
625
991
  console.log(c.dim(` Summary: ${summary}`));
626
992
  // Reset the RPIV cycle (this increments retry_count and resets flags)
627
- resetRPIVCycle(story, reviewResult.feedback);
993
+ await resetRPIVCycle(story, reviewResult.feedback);
628
994
  // Log what's being reset
629
995
  console.log(c.dim(` → Reset plan_complete, implementation_complete, reviews_complete`));
630
996
  console.log(c.dim(` → Retry count: ${currentRetry}/${maxRetriesDisplay}`));
@@ -673,7 +1039,7 @@ export async function run(options) {
673
1039
  storyContentHash: calculateStoryHash(action.storyPath),
674
1040
  },
675
1041
  };
676
- await saveWorkflowState(state, sdlcRoot);
1042
+ await saveWorkflowState(state, sdlcRoot, action.storyId);
677
1043
  console.log(c.dim(` ✓ Progress saved (${completedActions.length} actions completed)`));
678
1044
  }
679
1045
  currentActionIndex++;
@@ -695,7 +1061,8 @@ export async function run(options) {
695
1061
  console.log(c.dim(`Retry attempts: ${retryAttempt}`));
696
1062
  }
697
1063
  console.log(c.dim(`Story is now ready for PR creation.`));
698
- await clearWorkflowState(sdlcRoot);
1064
+ // Using action.storyId - available from action loop context
1065
+ await clearWorkflowState(sdlcRoot, action.storyId);
699
1066
  console.log(c.dim('Checkpoint cleared.'));
700
1067
  }
701
1068
  else {
@@ -709,10 +1076,11 @@ export async function run(options) {
709
1076
  }
710
1077
  else {
711
1078
  // Normal auto mode: re-assess state
712
- const newAssessment = assessState(sdlcRoot);
1079
+ const newAssessment = await assessState(sdlcRoot);
713
1080
  if (newAssessment.recommendedActions.length === 0) {
714
1081
  console.log(c.success('\n✓ All actions completed!'));
715
- await clearWorkflowState(sdlcRoot);
1082
+ // Using action.storyId - available from action loop context
1083
+ await clearWorkflowState(sdlcRoot, action.storyId);
716
1084
  console.log(c.dim('Checkpoint cleared.'));
717
1085
  break;
718
1086
  }
@@ -735,61 +1103,91 @@ export async function run(options) {
735
1103
  async function executeAction(action, sdlcRoot) {
736
1104
  const config = loadConfig();
737
1105
  const c = getThemedChalk(config);
738
- // Resolve story by ID to get current path (handles moves between folders)
739
- let resolvedPath;
1106
+ const globalLogger = getLogger();
1107
+ const actionStartTime = Date.now();
1108
+ // Log action start to global logger
1109
+ globalLogger.info('action', `Starting action: ${action.type}`, {
1110
+ storyId: action.storyId,
1111
+ actionType: action.type,
1112
+ storyPath: action.storyPath,
1113
+ });
1114
+ // Initialize per-story logger
1115
+ const maxLogs = config.logging?.maxFiles ?? 5;
1116
+ let storyLogger = null;
1117
+ let spinner = null;
740
1118
  try {
741
- const story = getStory(sdlcRoot, action.storyId);
742
- resolvedPath = story.path;
1119
+ storyLogger = new StoryLogger(action.storyId, sdlcRoot, maxLogs);
1120
+ storyLogger.log('INFO', `Starting action: ${action.type} for story ${action.storyId}`);
743
1121
  }
744
1122
  catch (error) {
745
- console.log(c.error(`Error: Story not found for action "${action.type}"`));
746
- console.log(c.dim(` Story ID: ${action.storyId}`));
747
- console.log(c.dim(` Original path: ${action.storyPath}`));
748
- if (error instanceof Error) {
749
- console.log(c.dim(` ${error.message}`));
750
- }
751
- return { success: false };
1123
+ // If logger initialization fails, continue without logging (console-only)
1124
+ console.warn(`Warning: Failed to initialize logger: ${error instanceof Error ? error.message : String(error)}`);
752
1125
  }
753
- // Update action path if it was stale
754
- if (resolvedPath !== action.storyPath) {
755
- console.log(c.warning(`Note: Story path updated (file was moved)`));
756
- console.log(c.dim(` From: ${action.storyPath}`));
757
- console.log(c.dim(` To: ${resolvedPath}`));
758
- action.storyPath = resolvedPath;
759
- }
760
- // Store phase completion state BEFORE action execution (to detect transitions)
761
- const storyBeforeAction = parseStory(action.storyPath);
762
- const prevPhaseState = {
763
- research_complete: storyBeforeAction.frontmatter.research_complete,
764
- plan_complete: storyBeforeAction.frontmatter.plan_complete,
765
- implementation_complete: storyBeforeAction.frontmatter.implementation_complete,
766
- reviews_complete: storyBeforeAction.frontmatter.reviews_complete,
767
- status: storyBeforeAction.frontmatter.status,
768
- };
769
- const spinner = ora(formatAction(action, true, c)).start();
770
- const baseText = formatAction(action, true, c);
771
- // Create agent progress callback for real-time updates
772
- const onAgentProgress = (event) => {
773
- switch (event.type) {
774
- case 'session_start':
775
- spinner.text = `${baseText} ${c.dim('(session started)')}`;
776
- break;
777
- case 'tool_start':
778
- // Show which tool is being executed
779
- const toolName = event.toolName || 'unknown';
780
- const shortName = toolName.replace(/^(mcp__|Mcp)/, '').substring(0, 30);
781
- spinner.text = `${baseText} ${c.dim(`→ ${shortName}`)}`;
782
- break;
783
- case 'tool_end':
784
- // Keep showing the action, tool completed
785
- spinner.text = baseText;
786
- break;
787
- case 'completion':
788
- spinner.text = `${baseText} ${c.dim('(completing...)')}`;
789
- break;
790
- }
791
- };
792
1126
  try {
1127
+ // Resolve story by ID to get current path (handles moves between folders)
1128
+ let resolvedPath;
1129
+ try {
1130
+ const story = getStory(sdlcRoot, action.storyId);
1131
+ resolvedPath = story.path;
1132
+ }
1133
+ catch (error) {
1134
+ const errorMsg = `Error: Story not found for action "${action.type}"`;
1135
+ storyLogger?.log('ERROR', errorMsg);
1136
+ storyLogger?.log('ERROR', ` Story ID: ${action.storyId}`);
1137
+ storyLogger?.log('ERROR', ` Original path: ${action.storyPath}`);
1138
+ console.log(c.error(errorMsg));
1139
+ console.log(c.dim(` Story ID: ${action.storyId}`));
1140
+ console.log(c.dim(` Original path: ${action.storyPath}`));
1141
+ if (error instanceof Error) {
1142
+ storyLogger?.log('ERROR', ` ${error.message}`);
1143
+ console.log(c.dim(` ${error.message}`));
1144
+ }
1145
+ return { success: false };
1146
+ }
1147
+ // Update action path if it was stale
1148
+ if (resolvedPath !== action.storyPath) {
1149
+ storyLogger?.log('WARN', `Note: Story path updated (file was moved)`);
1150
+ storyLogger?.log('WARN', ` From: ${action.storyPath}`);
1151
+ storyLogger?.log('WARN', ` To: ${resolvedPath}`);
1152
+ console.log(c.warning(`Note: Story path updated (file was moved)`));
1153
+ console.log(c.dim(` From: ${action.storyPath}`));
1154
+ console.log(c.dim(` To: ${resolvedPath}`));
1155
+ action.storyPath = resolvedPath;
1156
+ }
1157
+ // Store phase completion state BEFORE action execution (to detect transitions)
1158
+ const storyBeforeAction = parseStory(action.storyPath);
1159
+ const prevPhaseState = {
1160
+ research_complete: storyBeforeAction.frontmatter.research_complete,
1161
+ plan_complete: storyBeforeAction.frontmatter.plan_complete,
1162
+ implementation_complete: storyBeforeAction.frontmatter.implementation_complete,
1163
+ reviews_complete: storyBeforeAction.frontmatter.reviews_complete,
1164
+ status: storyBeforeAction.frontmatter.status,
1165
+ };
1166
+ spinner = ora(formatAction(action, true, c)).start();
1167
+ const baseText = formatAction(action, true, c);
1168
+ // Create agent progress callback for real-time updates
1169
+ const onAgentProgress = (event) => {
1170
+ if (!spinner)
1171
+ return; // Guard against null spinner
1172
+ switch (event.type) {
1173
+ case 'session_start':
1174
+ spinner.text = `${baseText} ${c.dim('(session started)')}`;
1175
+ break;
1176
+ case 'tool_start':
1177
+ // Show which tool is being executed
1178
+ const toolName = event.toolName || 'unknown';
1179
+ const shortName = toolName.replace(/^(mcp__|Mcp)/, '').substring(0, 30);
1180
+ spinner.text = `${baseText} ${c.dim(`→ ${shortName}`)}`;
1181
+ break;
1182
+ case 'tool_end':
1183
+ // Keep showing the action, tool completed
1184
+ spinner.text = baseText;
1185
+ break;
1186
+ case 'completion':
1187
+ spinner.text = `${baseText} ${c.dim('(completing...)')}`;
1188
+ break;
1189
+ }
1190
+ };
793
1191
  // Import and run the appropriate agent
794
1192
  let result;
795
1193
  switch (action.type) {
@@ -813,6 +1211,8 @@ async function executeAction(action, sdlcRoot) {
813
1211
  const { runReviewAgent } = await import('../agents/review.js');
814
1212
  result = await runReviewAgent(action.storyPath, sdlcRoot, {
815
1213
  onVerificationProgress: (phase, status, message) => {
1214
+ if (!spinner)
1215
+ return; // Guard against null spinner
816
1216
  const phaseLabel = phase === 'build' ? 'Building' : 'Testing';
817
1217
  switch (status) {
818
1218
  case 'starting':
@@ -846,7 +1246,7 @@ async function executeAction(action, sdlcRoot) {
846
1246
  // Update story status to done (no file move in new architecture)
847
1247
  const { updateStoryStatus } = await import('../core/story.js');
848
1248
  const storyToMove = parseStory(action.storyPath);
849
- const updatedStory = updateStoryStatus(storyToMove, 'done');
1249
+ const updatedStory = await updateStoryStatus(storyToMove, 'done');
850
1250
  result = {
851
1251
  success: true,
852
1252
  story: updatedStory,
@@ -861,17 +1261,34 @@ async function executeAction(action, sdlcRoot) {
861
1261
  throw new Error(`Unknown action type: ${action.type}`);
862
1262
  }
863
1263
  // Check if agent succeeded
1264
+ const actionDuration = Date.now() - actionStartTime;
864
1265
  if (result && !result.success) {
865
1266
  spinner.fail(c.error(`Failed: ${formatAction(action, true, c)}`));
1267
+ storyLogger?.log('ERROR', `Action failed: ${formatAction(action, false, c)}`);
1268
+ globalLogger.warn('action', `Action failed: ${action.type}`, {
1269
+ storyId: action.storyId,
1270
+ actionType: action.type,
1271
+ durationMs: actionDuration,
1272
+ error: result.error,
1273
+ });
866
1274
  if (result.error) {
1275
+ storyLogger?.log('ERROR', ` Error: ${result.error}`);
867
1276
  console.error(c.error(` Error: ${result.error}`));
868
1277
  }
869
1278
  return { success: false };
870
1279
  }
871
1280
  spinner.succeed(c.success(formatAction(action, true, c)));
1281
+ storyLogger?.log('INFO', `Action completed successfully: ${formatAction(action, false, c)}`);
1282
+ globalLogger.info('action', `Action completed: ${action.type}`, {
1283
+ storyId: action.storyId,
1284
+ actionType: action.type,
1285
+ durationMs: actionDuration,
1286
+ changesCount: result?.changesMade?.length ?? 0,
1287
+ });
872
1288
  // Show changes made
873
1289
  if (result && result.changesMade.length > 0) {
874
1290
  for (const change of result.changesMade) {
1291
+ storyLogger?.log('INFO', ` → ${change}`);
875
1292
  console.log(c.dim(` → ${change}`));
876
1293
  }
877
1294
  }
@@ -927,14 +1344,28 @@ async function executeAction(action, sdlcRoot) {
927
1344
  return { success: true };
928
1345
  }
929
1346
  catch (error) {
930
- spinner.fail(c.error(`Failed: ${formatAction(action, true, c)}`));
1347
+ const exceptionDuration = Date.now() - actionStartTime;
1348
+ if (spinner) {
1349
+ spinner.fail(c.error(`Failed: ${formatAction(action, true, c)}`));
1350
+ }
1351
+ else {
1352
+ console.error(c.error(`Failed: ${formatAction(action, true, c)}`));
1353
+ }
1354
+ const errorMessage = error instanceof Error ? error.message : String(error);
1355
+ storyLogger?.log('ERROR', `Exception during action execution: ${errorMessage}`);
1356
+ globalLogger.error('action', `Action exception: ${action.type}`, {
1357
+ storyId: action.storyId,
1358
+ actionType: action.type,
1359
+ durationMs: exceptionDuration,
1360
+ error: errorMessage,
1361
+ });
931
1362
  console.error(error);
932
1363
  // Show phase checklist with error indication (if file still exists)
933
1364
  try {
934
1365
  const story = parseStory(action.storyPath);
935
1366
  console.log(c.dim(` Progress: ${renderPhaseChecklist(story, c)}`));
936
1367
  // Update story with error
937
- story.frontmatter.last_error = error instanceof Error ? error.message : String(error);
1368
+ story.frontmatter.last_error = errorMessage;
938
1369
  }
939
1370
  catch {
940
1371
  // File may have been moved - skip progress display
@@ -942,6 +1373,10 @@ async function executeAction(action, sdlcRoot) {
942
1373
  // Don't throw - let the workflow continue if in auto mode
943
1374
  return { success: false };
944
1375
  }
1376
+ finally {
1377
+ // Always close logger, even if action fails or throws
1378
+ storyLogger?.close();
1379
+ }
945
1380
  }
946
1381
  /**
947
1382
  * Get phase information for an action type
@@ -1349,7 +1784,7 @@ function isEmptySection(content) {
1349
1784
  /**
1350
1785
  * Unblock a story from the blocked folder and move it back to the workflow
1351
1786
  */
1352
- export function unblock(storyId, options) {
1787
+ export async function unblock(storyId, options) {
1353
1788
  const spinner = ora('Unblocking story...').start();
1354
1789
  const config = loadConfig();
1355
1790
  const c = getThemedChalk(config);
@@ -1360,7 +1795,7 @@ export function unblock(storyId, options) {
1360
1795
  return;
1361
1796
  }
1362
1797
  // Unblock the story (using renamed import to avoid naming conflict)
1363
- const unblockedStory = unblockStory(storyId, sdlcRoot, options);
1798
+ const unblockedStory = await unblockStory(storyId, sdlcRoot, options);
1364
1799
  // Determine destination folder from updated path
1365
1800
  const destinationFolder = unblockedStory.path.match(/\/([^/]+)\/[^/]+\.md$/)?.[1] || 'unknown';
1366
1801
  spinner.succeed(c.success(`Unblocked story ${storyId}, moved to ${destinationFolder}/`));
@@ -1478,12 +1913,15 @@ export async function migrate(options) {
1478
1913
  * Helper function to prompt for removal confirmation
1479
1914
  */
1480
1915
  async function confirmRemoval(message) {
1916
+ // Sanitize message to prevent terminal injection attacks
1917
+ // Use consistent sanitizeForDisplay() for all terminal output
1918
+ const sanitizedMessage = sanitizeForDisplay(message);
1481
1919
  const rl = readline.createInterface({
1482
1920
  input: process.stdin,
1483
1921
  output: process.stdout,
1484
1922
  });
1485
1923
  return new Promise((resolve) => {
1486
- rl.question(message + ' (y/N): ', (answer) => {
1924
+ rl.question(sanitizedMessage + ' (y/N): ', (answer) => {
1487
1925
  rl.close();
1488
1926
  resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
1489
1927
  });
@@ -1503,7 +1941,7 @@ async function handleWorktreeCleanup(story, config, c) {
1503
1941
  // Check if worktree exists
1504
1942
  if (!fs.existsSync(worktreePath)) {
1505
1943
  console.log(c.warning(` Note: Worktree path no longer exists: ${worktreePath}`));
1506
- const updated = updateStoryField(story, 'worktree_path', undefined);
1944
+ const updated = await updateStoryField(story, 'worktree_path', undefined);
1507
1945
  await writeStory(updated);
1508
1946
  console.log(c.dim(' Cleared worktree_path from frontmatter'));
1509
1947
  return;
@@ -1532,14 +1970,14 @@ async function handleWorktreeCleanup(story, config, c) {
1532
1970
  }
1533
1971
  const service = new GitWorktreeService(workingDir, resolvedBasePath);
1534
1972
  service.remove(worktreePath);
1535
- const updated = updateStoryField(story, 'worktree_path', undefined);
1973
+ const updated = await updateStoryField(story, 'worktree_path', undefined);
1536
1974
  await writeStory(updated);
1537
1975
  console.log(c.success(' ✓ Worktree removed'));
1538
1976
  }
1539
1977
  catch (error) {
1540
1978
  console.log(c.warning(` Failed to remove worktree: ${error instanceof Error ? error.message : String(error)}`));
1541
1979
  // Clear frontmatter anyway (user may have manually deleted)
1542
- const updated = updateStoryField(story, 'worktree_path', undefined);
1980
+ const updated = await updateStoryField(story, 'worktree_path', undefined);
1543
1981
  await writeStory(updated);
1544
1982
  }
1545
1983
  }
@@ -1654,9 +2092,9 @@ export async function addWorktree(storyId) {
1654
2092
  baseBranch,
1655
2093
  });
1656
2094
  // Update story frontmatter
1657
- const updatedStory = updateStoryField(story, 'worktree_path', worktreePath);
2095
+ const updatedStory = await updateStoryField(story, 'worktree_path', worktreePath);
1658
2096
  const branchName = service.getBranchName(story.frontmatter.id, story.slug);
1659
- const storyWithBranch = updateStoryField(updatedStory, 'branch', branchName);
2097
+ const storyWithBranch = await updateStoryField(updatedStory, 'branch', branchName);
1660
2098
  await writeStory(storyWithBranch);
1661
2099
  spinner.succeed(c.success(`Created worktree for ${story.frontmatter.id}`));
1662
2100
  console.log(c.dim(` Path: ${worktreePath}`));
@@ -1723,7 +2161,7 @@ export async function removeWorktree(storyId, options) {
1723
2161
  // Remove the worktree
1724
2162
  service.remove(worktreePath);
1725
2163
  // Clear worktree_path from frontmatter
1726
- const updatedStory = updateStoryField(story, 'worktree_path', undefined);
2164
+ const updatedStory = await updateStoryField(story, 'worktree_path', undefined);
1727
2165
  await writeStory(updatedStory);
1728
2166
  spinner.succeed(c.success(`Removed worktree for ${story.frontmatter.id}`));
1729
2167
  console.log(c.dim(` Path: ${worktreePath}`));