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

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 (131) 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 +103 -2
  26. package/dist/agents/review.d.ts.map +1 -1
  27. package/dist/agents/review.js +775 -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 +300 -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 +362 -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
  131. package/templates/story.md +5 -0
@@ -1,7 +1,8 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import matter from 'gray-matter';
4
- import { FOLDER_TO_STATUS, BLOCKED_DIR, STORIES_FOLDER, STORY_FILENAME, DEFAULT_PRIORITY_GAP } from '../types/index.js';
4
+ import * as properLockfile from 'proper-lockfile';
5
+ import { FOLDER_TO_STATUS, BLOCKED_DIR, STORIES_FOLDER, STORY_FILENAME, DEFAULT_PRIORITY_GAP, ReviewDecision } from '../types/index.js';
5
6
  /**
6
7
  * Parse a story markdown file into a Story object
7
8
  *
@@ -37,27 +38,91 @@ export function parseStory(filePath) {
37
38
  };
38
39
  }
39
40
  /**
40
- * Write a story back to disk
41
+ * Write a story back to disk with file locking for atomic updates.
42
+ *
43
+ * This function acquires an exclusive lock before writing to prevent race conditions
44
+ * from concurrent processes. The lock is always released, even if an error occurs.
45
+ *
46
+ * **IMPORTANT:** Do not nest locks on the same file to avoid deadlock. Batch multiple
47
+ * updates into a single writeStory() call instead.
48
+ *
49
+ * @param story - Story object to write
50
+ * @param options - Lock options (timeout, retries, stale threshold)
51
+ * @throws Error if file is locked by another process or filesystem is read-only
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * // Good: Batch updates
56
+ * story.frontmatter.status = 'in-progress';
57
+ * story.frontmatter.priority = 1;
58
+ * await writeStory(story);
59
+ *
60
+ * // Bad: Nested locks (potential deadlock)
61
+ * await writeStory(story); // holds lock
62
+ * await writeStory(story); // tries to acquire same lock = deadlock
63
+ * ```
41
64
  */
42
- export function writeStory(story) {
65
+ export async function writeStory(story, options) {
43
66
  const content = matter.stringify(story.content, story.frontmatter);
44
- fs.writeFileSync(story.path, content);
67
+ // For new files (file doesn't exist yet), write directly without locking
68
+ // No race condition possible when creating a new file
69
+ if (!fs.existsSync(story.path)) {
70
+ fs.writeFileSync(story.path, content);
71
+ return;
72
+ }
73
+ // For existing files, use locking to prevent concurrent write corruption
74
+ const timeout = options?.lockTimeout ?? 5000;
75
+ const retries = options?.retries ?? 3;
76
+ const stale = options?.stale ?? timeout;
77
+ // Acquire lock with retry logic and exponential backoff
78
+ let release;
79
+ try {
80
+ release = await properLockfile.lock(story.path, {
81
+ retries: {
82
+ retries,
83
+ minTimeout: 100,
84
+ maxTimeout: 1000,
85
+ },
86
+ stale,
87
+ });
88
+ }
89
+ catch (error) {
90
+ // Handle lock-specific errors with actionable messages
91
+ if (error.code === 'ELOCKED') {
92
+ throw new Error(`Story ${story.frontmatter.id || story.path} is locked by another process. ` +
93
+ `Please retry in a moment or check for hung processes.`);
94
+ }
95
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
96
+ throw new Error(`Cannot create lock file: filesystem is read-only or insufficient permissions. ` +
97
+ `Ensure ${path.dirname(story.path)} is writable.`);
98
+ }
99
+ // ESTALE is handled automatically by proper-lockfile (stale lock removed)
100
+ throw error;
101
+ }
102
+ try {
103
+ // Critical section: write story to disk
104
+ fs.writeFileSync(story.path, content);
105
+ }
106
+ finally {
107
+ // Always release lock, even on error
108
+ await release();
109
+ }
45
110
  }
46
111
  /**
47
112
  * Update story status in frontmatter without moving files
48
113
  * This is the preferred method in the new folder-per-story architecture
49
114
  */
50
- export function updateStoryStatus(story, newStatus) {
115
+ export async function updateStoryStatus(story, newStatus) {
51
116
  story.frontmatter.status = newStatus;
52
117
  story.frontmatter.updated = new Date().toISOString().split('T')[0];
53
- writeStory(story);
118
+ await writeStory(story);
54
119
  return story;
55
120
  }
56
121
  /**
57
122
  * Move a story to a different kanban folder
58
123
  * @deprecated Use updateStoryStatus() instead. Will be removed in v2.0
59
124
  */
60
- export function moveStory(story, toFolder, sdlcRoot) {
125
+ export async function moveStory(story, toFolder, sdlcRoot) {
61
126
  console.warn('moveStory() is deprecated. Use updateStoryStatus() instead. Will be removed in v2.0');
62
127
  const targetFolder = path.join(sdlcRoot, toFolder);
63
128
  // Ensure target folder exists
@@ -77,7 +142,7 @@ export function moveStory(story, toFolder, sdlcRoot) {
77
142
  // Write to new location
78
143
  const oldPath = story.path;
79
144
  story.path = newPath;
80
- writeStory(story);
145
+ await writeStory(story);
81
146
  // Remove old file
82
147
  if (fs.existsSync(oldPath) && oldPath !== newPath) {
83
148
  fs.unlinkSync(oldPath);
@@ -91,7 +156,7 @@ export function moveStory(story, toFolder, sdlcRoot) {
91
156
  * @param storyPath - Absolute path to the story file
92
157
  * @param reason - Reason for blocking (e.g., "Max refinement attempts (2/2) reached")
93
158
  */
94
- export function moveToBlocked(storyPath, reason) {
159
+ export async function moveToBlocked(storyPath, reason) {
95
160
  // Security: Validate path BEFORE any file I/O operations
96
161
  const resolvedPath = path.resolve(storyPath);
97
162
  const storyDir = path.dirname(resolvedPath);
@@ -113,7 +178,7 @@ export function moveToBlocked(storyPath, reason) {
113
178
  story.frontmatter.blocked_at = new Date().toISOString();
114
179
  story.frontmatter.updated = new Date().toISOString().split('T')[0];
115
180
  // Write back to same location
116
- writeStory(story);
181
+ await writeStory(story);
117
182
  }
118
183
  /**
119
184
  * Generate a unique story ID in sequential format (S-0001, S-0002, etc.)
@@ -166,13 +231,86 @@ export function slugify(title) {
166
231
  .replace(/^-|-$/g, '')
167
232
  .substring(0, 50);
168
233
  }
234
+ /**
235
+ * Sanitize a title string for safe use in file paths and display.
236
+ * Removes dangerous characters that could be used for injection attacks.
237
+ *
238
+ * SECURITY: This function prevents command injection and path traversal through titles.
239
+ *
240
+ * @param title - Title string to sanitize
241
+ * @returns Sanitized title safe for use in paths and commands
242
+ */
243
+ export function sanitizeTitle(title) {
244
+ if (!title)
245
+ return '';
246
+ let sanitized = title
247
+ // Remove shell metacharacters that could be used for command injection
248
+ .replace(/[`$()\\|&;<>]/g, '')
249
+ // Remove ANSI escape codes (colors, cursor control)
250
+ .replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '')
251
+ // Remove OSC sequences (hyperlinks, window titles)
252
+ .replace(/\x1B\][^\x07]*\x07/g, '')
253
+ .replace(/\x1B\][^\x1B]*\x1B\\/g, '')
254
+ // Remove null bytes and control characters
255
+ .replace(/[\x00-\x1F\x7F-\x9F]/g, '');
256
+ // Normalize Unicode to prevent homograph attacks
257
+ sanitized = sanitized.normalize('NFC');
258
+ // Limit length to prevent storage issues
259
+ if (sanitized.length > 200) {
260
+ sanitized = sanitized.substring(0, 200);
261
+ }
262
+ return sanitized.trim();
263
+ }
264
+ /**
265
+ * Extract title from file content using safe parsing.
266
+ * Priority: YAML frontmatter > H1 heading > null
267
+ *
268
+ * SECURITY: Uses regex-only approach to avoid YAML parser vulnerabilities.
269
+ *
270
+ * @param content - File content to extract title from
271
+ * @returns Extracted title or null if not found
272
+ */
273
+ export function extractTitleFromContent(content) {
274
+ if (!content || content.trim().length === 0) {
275
+ return null;
276
+ }
277
+ // Try to extract from YAML frontmatter using safe regex (no full YAML parsing)
278
+ // Match: --- at start, then look for title: field, then --- to close
279
+ const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/m);
280
+ if (frontmatterMatch) {
281
+ // Look for title: field in the frontmatter block
282
+ const titleMatch = frontmatterMatch[1].match(/^title:\s*['"]?([^'"\n]+?)['"]?\s*$/m);
283
+ if (titleMatch && titleMatch[1]) {
284
+ const title = titleMatch[1].trim();
285
+ if (title.length > 0) {
286
+ return sanitizeTitle(title);
287
+ }
288
+ }
289
+ }
290
+ // Fall back to first H1 heading (# Title)
291
+ // Use non-greedy matching with length limit to prevent ReDoS
292
+ const h1Match = content.match(/^#\s+(.{1,200}?)$/m);
293
+ if (h1Match && h1Match[1]) {
294
+ const title = h1Match[1].trim();
295
+ if (title.length > 0) {
296
+ return sanitizeTitle(title);
297
+ }
298
+ }
299
+ // No title found
300
+ return null;
301
+ }
169
302
  /**
170
303
  * Create a new story in the folder-per-story structure
171
304
  *
172
305
  * Creates stories/{id}/story.md with slug and priority in frontmatter.
173
306
  * Priority uses gaps (10, 20, 30...) for easy insertion without renumbering.
307
+ *
308
+ * @param title - Story title
309
+ * @param sdlcRoot - Root path of .ai-sdlc folder
310
+ * @param options - Optional frontmatter fields
311
+ * @param content - Optional custom story content (if not provided, uses default template)
174
312
  */
175
- export function createStory(title, sdlcRoot, options = {}) {
313
+ export async function createStory(title, sdlcRoot, options = {}, content) {
176
314
  const storiesFolder = path.join(sdlcRoot, STORIES_FOLDER);
177
315
  // Validate parent stories/ directory exists
178
316
  if (!fs.existsSync(storiesFolder)) {
@@ -228,9 +366,22 @@ export function createStory(title, sdlcRoot, options = {}) {
228
366
  plan_complete: false,
229
367
  implementation_complete: false,
230
368
  reviews_complete: false,
369
+ // Default content_type to 'code' for backward compatibility
370
+ // Stories that don't modify src/ should explicitly set content_type: 'configuration' or 'documentation'
371
+ content_type: 'code',
231
372
  ...options,
232
373
  };
233
- const content = `# ${title}
374
+ // Use custom content if provided, otherwise use default template
375
+ let storyContent;
376
+ if (content) {
377
+ // Security: Strip dangerous HTML tags from custom content
378
+ storyContent = content
379
+ .replace(/<script[^>]*>.*?<\/script>/gis, '')
380
+ .replace(/<iframe[^>]*>.*?<\/iframe>/gis, '');
381
+ }
382
+ else {
383
+ // Default template
384
+ storyContent = `# ${title}
234
385
 
235
386
  ## Summary
236
387
 
@@ -251,28 +402,31 @@ export function createStory(title, sdlcRoot, options = {}) {
251
402
  ## Review Notes
252
403
 
253
404
  <!-- Populated by review agents -->`;
405
+ }
254
406
  const story = {
255
407
  path: filePath,
256
408
  slug,
257
409
  frontmatter,
258
- content,
410
+ content: storyContent,
259
411
  };
260
- writeStory(story);
261
- return story;
412
+ await writeStory(story);
413
+ // Return story with canonical path for consistency
414
+ const canonicalPath = fs.realpathSync(filePath);
415
+ return { ...story, path: canonicalPath };
262
416
  }
263
417
  /**
264
418
  * Update story frontmatter field
265
419
  */
266
- export function updateStoryField(story, field, value) {
420
+ export async function updateStoryField(story, field, value) {
267
421
  story.frontmatter[field] = value;
268
422
  story.frontmatter.updated = new Date().toISOString().split('T')[0];
269
- writeStory(story);
423
+ await writeStory(story);
270
424
  return story;
271
425
  }
272
426
  /**
273
427
  * Append content to a section in the story
274
428
  */
275
- export function appendToSection(story, section, content) {
429
+ export async function appendToSection(story, section, content) {
276
430
  const sectionHeader = `## ${section}`;
277
431
  const sectionIndex = story.content.indexOf(sectionHeader);
278
432
  if (sectionIndex === -1) {
@@ -295,13 +449,13 @@ export function appendToSection(story, section, content) {
295
449
  story.content.substring(insertPoint);
296
450
  }
297
451
  story.frontmatter.updated = new Date().toISOString().split('T')[0];
298
- writeStory(story);
452
+ await writeStory(story);
299
453
  return story;
300
454
  }
301
455
  /**
302
456
  * Record a refinement attempt in the story's frontmatter
303
457
  */
304
- export function recordRefinementAttempt(story, agentType, reviewFeedback) {
458
+ export async function recordRefinementAttempt(story, agentType, reviewFeedback) {
305
459
  // Initialize refinement tracking if not present
306
460
  if (!story.frontmatter.refinement_iterations) {
307
461
  story.frontmatter.refinement_iterations = [];
@@ -318,7 +472,7 @@ export function recordRefinementAttempt(story, agentType, reviewFeedback) {
318
472
  story.frontmatter.refinement_iterations.push(refinementRecord);
319
473
  story.frontmatter.refinement_count = iteration;
320
474
  story.frontmatter.updated = new Date().toISOString().split('T')[0];
321
- writeStory(story);
475
+ await writeStory(story);
322
476
  return story;
323
477
  }
324
478
  /**
@@ -339,7 +493,7 @@ export function canRetryRefinement(story, maxAttempts) {
339
493
  /**
340
494
  * Reset phase completion flags for rework
341
495
  */
342
- export function resetPhaseCompletion(story, phase) {
496
+ export async function resetPhaseCompletion(story, phase) {
343
497
  switch (phase) {
344
498
  case 'research':
345
499
  story.frontmatter.research_complete = false;
@@ -352,7 +506,7 @@ export function resetPhaseCompletion(story, phase) {
352
506
  break;
353
507
  }
354
508
  story.frontmatter.updated = new Date().toISOString().split('T')[0];
355
- writeStory(story);
509
+ await writeStory(story);
356
510
  return story;
357
511
  }
358
512
  /**
@@ -370,9 +524,9 @@ export function getLatestReviewFeedback(story) {
370
524
  /**
371
525
  * Append refinement feedback to the story content
372
526
  */
373
- export function appendRefinementNote(story, iteration, feedback) {
527
+ export async function appendRefinementNote(story, iteration, feedback) {
374
528
  const refinementNote = `### Refinement Iteration ${iteration}\n\n${feedback}`;
375
- return appendToSection(story, 'Review Notes', refinementNote);
529
+ return await appendToSection(story, 'Review Notes', refinementNote);
376
530
  }
377
531
  /**
378
532
  * Get the effective maximum retries for a story (story-specific or config default)
@@ -401,18 +555,18 @@ export function isAtMaxRetries(story, config, maxIterationsOverride) {
401
555
  /**
402
556
  * Increment the retry count for a story
403
557
  */
404
- export function incrementRetryCount(story) {
558
+ export async function incrementRetryCount(story) {
405
559
  const currentCount = story.frontmatter.retry_count || 0;
406
560
  story.frontmatter.retry_count = currentCount + 1;
407
561
  story.frontmatter.last_restart_timestamp = new Date().toISOString();
408
562
  story.frontmatter.updated = new Date().toISOString().split('T')[0];
409
- writeStory(story);
563
+ await writeStory(story);
410
564
  return story;
411
565
  }
412
566
  /**
413
567
  * Reset RPIV cycle for a story (keep research, reset plan/implementation/reviews)
414
568
  */
415
- export function resetRPIVCycle(story, reason) {
569
+ export async function resetRPIVCycle(story, reason) {
416
570
  // Keep research_complete as true, reset other flags
417
571
  story.frontmatter.plan_complete = false;
418
572
  story.frontmatter.implementation_complete = false;
@@ -423,13 +577,13 @@ export function resetRPIVCycle(story, reason) {
423
577
  // Increment retry count
424
578
  const currentCount = story.frontmatter.retry_count || 0;
425
579
  story.frontmatter.retry_count = currentCount + 1;
426
- writeStory(story);
580
+ await writeStory(story);
427
581
  return story;
428
582
  }
429
583
  /**
430
584
  * Append a review attempt to the story's review history
431
585
  */
432
- export function appendReviewHistory(story, attempt) {
586
+ export async function appendReviewHistory(story, attempt) {
433
587
  if (!story.frontmatter.review_history) {
434
588
  story.frontmatter.review_history = [];
435
589
  }
@@ -440,7 +594,7 @@ export function appendReviewHistory(story, attempt) {
440
594
  story.frontmatter.review_history = story.frontmatter.review_history.slice(-10);
441
595
  }
442
596
  story.frontmatter.updated = new Date().toISOString().split('T')[0];
443
- writeStory(story);
597
+ await writeStory(story);
444
598
  return story;
445
599
  }
446
600
  /**
@@ -455,23 +609,55 @@ export function getLatestReviewAttempt(story) {
455
609
  /**
456
610
  * Mark a story as complete (all workflow flags set to true)
457
611
  */
458
- export function markStoryComplete(story) {
612
+ export async function markStoryComplete(story) {
459
613
  story.frontmatter.research_complete = true;
460
614
  story.frontmatter.plan_complete = true;
461
615
  story.frontmatter.implementation_complete = true;
462
616
  story.frontmatter.reviews_complete = true;
463
617
  story.frontmatter.updated = new Date().toISOString().split('T')[0];
464
- writeStory(story);
618
+ await writeStory(story);
465
619
  return story;
466
620
  }
621
+ /**
622
+ * Auto-complete story after review approval
623
+ * Handles marking story as complete and transitioning to done status
624
+ *
625
+ * @param story - The story to auto-complete
626
+ * @param config - The configuration containing reviewConfig settings
627
+ * @param reviewResult - The result from the review agent
628
+ * @returns Updated story if auto-completion occurred, original story otherwise
629
+ */
630
+ export async function autoCompleteStoryAfterReview(story, config, reviewResult) {
631
+ // Only auto-complete if review was approved and config allows it
632
+ if (reviewResult.decision !== ReviewDecision.APPROVED) {
633
+ return story;
634
+ }
635
+ if (!config.reviewConfig.autoCompleteOnApproval) {
636
+ return story;
637
+ }
638
+ try {
639
+ // Mark all workflow flags as complete
640
+ story = await markStoryComplete(story);
641
+ // Update status to done if currently in-progress
642
+ if (story.frontmatter.status === 'in-progress') {
643
+ story = await updateStoryStatus(story, 'done');
644
+ }
645
+ return story;
646
+ }
647
+ catch (error) {
648
+ // Log error but don't fail the entire review operation
649
+ console.error('Failed to auto-complete story after review:', error);
650
+ return story;
651
+ }
652
+ }
467
653
  /**
468
654
  * Snapshot max_retries from config to story frontmatter (for mid-cycle config change protection)
469
655
  */
470
- export function snapshotMaxRetries(story, config) {
656
+ export async function snapshotMaxRetries(story, config) {
471
657
  if (story.frontmatter.max_retries === undefined) {
472
658
  story.frontmatter.max_retries = config.reviewConfig.maxRetries;
473
659
  story.frontmatter.updated = new Date().toISOString().split('T')[0];
474
- writeStory(story);
660
+ await writeStory(story);
475
661
  }
476
662
  return story;
477
663
  }
@@ -515,22 +701,55 @@ export function isAtMaxImplementationRetries(story, config) {
515
701
  /**
516
702
  * Reset implementation retry count to 0
517
703
  */
518
- export function resetImplementationRetryCount(story) {
704
+ export async function resetImplementationRetryCount(story) {
519
705
  story.frontmatter.implementation_retry_count = 0;
520
706
  story.frontmatter.updated = new Date().toISOString().split('T')[0];
521
- writeStory(story);
707
+ await writeStory(story);
522
708
  return story;
523
709
  }
524
710
  /**
525
711
  * Increment the implementation retry count for a story
526
712
  */
527
- export function incrementImplementationRetryCount(story) {
713
+ export async function incrementImplementationRetryCount(story) {
528
714
  const currentCount = story.frontmatter.implementation_retry_count || 0;
529
715
  story.frontmatter.implementation_retry_count = currentCount + 1;
530
716
  story.frontmatter.updated = new Date().toISOString().split('T')[0];
531
- writeStory(story);
717
+ await writeStory(story);
532
718
  return story;
533
719
  }
720
+ /**
721
+ * Sanitize story ID for safe path construction.
722
+ * Prevents path traversal attacks by rejecting dangerous characters.
723
+ *
724
+ * SECURITY: This function is CRITICAL for preventing path traversal vulnerabilities.
725
+ * Use this before constructing ANY file paths with user-provided story IDs.
726
+ *
727
+ * @param storyId - Story ID to sanitize (e.g., 'S-0001')
728
+ * @returns Sanitized story ID safe for path construction
729
+ * @throws Error if storyId contains dangerous characters or patterns
730
+ */
731
+ export function sanitizeStoryId(storyId) {
732
+ if (!storyId) {
733
+ throw new Error('Story ID cannot be empty');
734
+ }
735
+ // Reject path traversal attempts
736
+ if (storyId.includes('..')) {
737
+ throw new Error('Invalid story ID: contains path traversal sequence (..)');
738
+ }
739
+ // Reject path separators
740
+ if (storyId.includes('/') || storyId.includes('\\')) {
741
+ throw new Error('Invalid story ID: contains path separator');
742
+ }
743
+ // Reject absolute paths
744
+ if (path.isAbsolute(storyId)) {
745
+ throw new Error('Invalid story ID: cannot be an absolute path');
746
+ }
747
+ // Reject control characters and other dangerous characters
748
+ if (/[\x00-\x1F\x7F]/.test(storyId)) {
749
+ throw new Error('Invalid story ID: contains control characters');
750
+ }
751
+ return storyId;
752
+ }
534
753
  /**
535
754
  * Sanitize user-controlled text for safe display and storage.
536
755
  * Removes ANSI escape sequences, control characters, and potential injection vectors.
@@ -572,14 +791,39 @@ export function sanitizeReasonText(text) {
572
791
  * @returns Story object or null if not found
573
792
  */
574
793
  export function findStoryById(sdlcRoot, storyId) {
575
- // O(1) direct path construction for new architecture
576
- const storyPath = path.join(sdlcRoot, STORIES_FOLDER, storyId, STORY_FILENAME);
577
- if (fs.existsSync(storyPath)) {
794
+ // SECURITY: Validate storyId format (defense-in-depth)
795
+ // Reject any input that could be used for path traversal
796
+ if (!storyId ||
797
+ storyId.includes('..') ||
798
+ storyId.includes('/') ||
799
+ storyId.includes('\\') ||
800
+ path.isAbsolute(storyId)) {
801
+ return null;
802
+ }
803
+ // O(n) directory scan for case-insensitive matching in new architecture
804
+ // Reads all story directories to find case-insensitive match
805
+ // Note: fs.realpathSync() does NOT canonicalize casing on macOS, so we must scan
806
+ const storiesFolder = path.join(sdlcRoot, STORIES_FOLDER);
807
+ if (fs.existsSync(storiesFolder)) {
578
808
  try {
579
- return parseStory(storyPath);
809
+ // Read actual directory names from filesystem
810
+ const directories = fs.readdirSync(storiesFolder, { withFileTypes: true })
811
+ .filter(dirent => dirent.isDirectory())
812
+ .map(dirent => dirent.name);
813
+ // Find directory that matches case-insensitively
814
+ const actualDirName = directories.find(dir => dir.toLowerCase() === storyId.toLowerCase());
815
+ if (actualDirName) {
816
+ // Use the actual directory name (with correct filesystem casing)
817
+ const storyPath = path.join(storiesFolder, actualDirName, STORY_FILENAME);
818
+ if (fs.existsSync(storyPath)) {
819
+ const canonicalPath = fs.realpathSync(storyPath);
820
+ const story = parseStory(canonicalPath);
821
+ return { ...story, path: canonicalPath };
822
+ }
823
+ }
580
824
  }
581
825
  catch (err) {
582
- // Story file exists but is malformed, fall through to search
826
+ // If reading directory fails, fall through to fallback search
583
827
  }
584
828
  }
585
829
  // Fallback: search old folder structure for backwards compatibility
@@ -594,9 +838,11 @@ export function findStoryById(sdlcRoot, storyId) {
594
838
  for (const file of files) {
595
839
  const filePath = path.join(folderPath, file);
596
840
  try {
597
- const story = parseStory(filePath);
598
- if (story.frontmatter.id === storyId) {
599
- return story;
841
+ const canonicalPath = fs.realpathSync(filePath);
842
+ const story = parseStory(canonicalPath);
843
+ // Case-insensitive comparison to match input
844
+ if (story.frontmatter.id?.toLowerCase() === storyId.toLowerCase()) {
845
+ return { ...story, path: canonicalPath };
600
846
  }
601
847
  }
602
848
  catch (err) {
@@ -611,9 +857,11 @@ export function findStoryById(sdlcRoot, storyId) {
611
857
  for (const file of blockedFiles) {
612
858
  const filePath = path.join(blockedFolder, file);
613
859
  try {
614
- const story = parseStory(filePath);
615
- if (story.frontmatter.id === storyId) {
616
- return story;
860
+ const canonicalPath = fs.realpathSync(filePath);
861
+ const story = parseStory(canonicalPath);
862
+ // Case-insensitive comparison to match input
863
+ if (story.frontmatter.id?.toLowerCase() === storyId.toLowerCase()) {
864
+ return { ...story, path: canonicalPath };
617
865
  }
618
866
  }
619
867
  catch (err) {
@@ -653,7 +901,7 @@ export function getStory(sdlcRoot, storyId) {
653
901
  * @param options - Optional configuration { resetRetries?: boolean }
654
902
  * @returns The unblocked story
655
903
  */
656
- export function unblockStory(storyId, sdlcRoot, options) {
904
+ export async function unblockStory(storyId, sdlcRoot, options) {
657
905
  // Use the centralized getStory() function for lookup
658
906
  const foundStory = findStoryById(sdlcRoot, storyId);
659
907
  if (!foundStory) {
@@ -688,7 +936,7 @@ export function unblockStory(storyId, sdlcRoot, options) {
688
936
  foundStory.frontmatter.status = newStatus;
689
937
  foundStory.frontmatter.updated = new Date().toISOString().split('T')[0];
690
938
  // Write back to same location
691
- writeStory(foundStory);
939
+ await writeStory(foundStory);
692
940
  return foundStory;
693
941
  }
694
942
  //# sourceMappingURL=story.js.map