ai-sdlc 0.2.0-alpha.3 → 0.2.0-alpha.30

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 (103) hide show
  1. package/README.md +53 -1058
  2. package/dist/agents/implementation.d.ts +62 -0
  3. package/dist/agents/implementation.d.ts.map +1 -1
  4. package/dist/agents/implementation.js +494 -90
  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 +79 -2
  17. package/dist/agents/review.d.ts.map +1 -1
  18. package/dist/agents/review.js +568 -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 +59 -3
  38. package/dist/cli/commands.d.ts.map +1 -1
  39. package/dist/cli/commands.js +1053 -217
  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 +40 -10
  43. package/dist/cli/daemon.js.map +1 -1
  44. package/dist/cli/formatting.js +1 -1
  45. package/dist/cli/formatting.js.map +1 -1
  46. package/dist/cli/runner.d.ts.map +1 -1
  47. package/dist/cli/runner.js +51 -20
  48. package/dist/cli/runner.js.map +1 -1
  49. package/dist/core/auth.d.ts +51 -2
  50. package/dist/core/auth.d.ts.map +1 -1
  51. package/dist/core/auth.js +267 -7
  52. package/dist/core/auth.js.map +1 -1
  53. package/dist/core/client.d.ts +6 -0
  54. package/dist/core/client.d.ts.map +1 -1
  55. package/dist/core/client.js +68 -4
  56. package/dist/core/client.js.map +1 -1
  57. package/dist/core/config.d.ts +36 -1
  58. package/dist/core/config.d.ts.map +1 -1
  59. package/dist/core/config.js +162 -1
  60. package/dist/core/config.js.map +1 -1
  61. package/dist/core/conflict-detector.d.ts +108 -0
  62. package/dist/core/conflict-detector.d.ts.map +1 -0
  63. package/dist/core/conflict-detector.js +413 -0
  64. package/dist/core/conflict-detector.js.map +1 -0
  65. package/dist/core/git-utils.d.ts +28 -0
  66. package/dist/core/git-utils.d.ts.map +1 -0
  67. package/dist/core/git-utils.js +146 -0
  68. package/dist/core/git-utils.js.map +1 -0
  69. package/dist/core/index.d.ts +17 -0
  70. package/dist/core/index.d.ts.map +1 -0
  71. package/dist/core/index.js +17 -0
  72. package/dist/core/index.js.map +1 -0
  73. package/dist/core/kanban.d.ts +1 -6
  74. package/dist/core/kanban.d.ts.map +1 -1
  75. package/dist/core/kanban.js +10 -49
  76. package/dist/core/kanban.js.map +1 -1
  77. package/dist/core/logger.d.ts +92 -0
  78. package/dist/core/logger.d.ts.map +1 -0
  79. package/dist/core/logger.js +221 -0
  80. package/dist/core/logger.js.map +1 -0
  81. package/dist/core/story-logger.d.ts +102 -0
  82. package/dist/core/story-logger.d.ts.map +1 -0
  83. package/dist/core/story-logger.js +265 -0
  84. package/dist/core/story-logger.js.map +1 -0
  85. package/dist/core/story.d.ts +133 -20
  86. package/dist/core/story.d.ts.map +1 -1
  87. package/dist/core/story.js +426 -61
  88. package/dist/core/story.js.map +1 -1
  89. package/dist/core/workflow-state.d.ts +45 -6
  90. package/dist/core/workflow-state.d.ts.map +1 -1
  91. package/dist/core/workflow-state.js +201 -12
  92. package/dist/core/workflow-state.js.map +1 -1
  93. package/dist/core/worktree.d.ts +77 -0
  94. package/dist/core/worktree.d.ts.map +1 -0
  95. package/dist/core/worktree.js +246 -0
  96. package/dist/core/worktree.js.map +1 -0
  97. package/dist/index.js +135 -5
  98. package/dist/index.js.map +1 -1
  99. package/dist/types/index.d.ts +163 -1
  100. package/dist/types/index.d.ts.map +1 -1
  101. package/dist/types/index.js +1 -0
  102. package/dist/types/index.js.map +1 -1
  103. package/package.json +3 -1
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import matter from 'gray-matter';
4
+ import * as properLockfile from 'proper-lockfile';
4
5
  import { FOLDER_TO_STATUS, BLOCKED_DIR, STORIES_FOLDER, STORY_FILENAME, DEFAULT_PRIORITY_GAP } from '../types/index.js';
5
6
  /**
6
7
  * Parse a story markdown file into a Story object
@@ -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,12 +178,45 @@ 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);
182
+ }
183
+ /**
184
+ * Generate a unique story ID in sequential format (S-0001, S-0002, etc.)
185
+ * Scans the stories folder to find the highest existing number.
186
+ *
187
+ * @param storiesFolder - Path to the stories directory
188
+ * @returns Sequential story ID like "S-0001"
189
+ */
190
+ export function generateStoryId(storiesFolder) {
191
+ let maxNum = 0;
192
+ if (storiesFolder && fs.existsSync(storiesFolder)) {
193
+ try {
194
+ const dirs = fs.readdirSync(storiesFolder, { withFileTypes: true });
195
+ for (const dir of dirs) {
196
+ if (!dir.isDirectory())
197
+ continue;
198
+ // Match both S-XXXX format (new) and fall back to checking for any existing
199
+ const match = dir.name.match(/^S-(\d+)$/);
200
+ if (match) {
201
+ const num = parseInt(match[1], 10);
202
+ if (num > maxNum)
203
+ maxNum = num;
204
+ }
205
+ }
206
+ }
207
+ catch {
208
+ // If we can't read, start from 0
209
+ }
210
+ }
211
+ // Generate next ID with zero-padded 4 digits
212
+ const nextId = `S-${String(maxNum + 1).padStart(4, '0')}`;
213
+ return nextId;
117
214
  }
118
215
  /**
119
- * Generate a unique story ID
216
+ * Generate a legacy story ID (for backwards compatibility/fallback)
217
+ * @deprecated Use generateStoryId() instead
120
218
  */
121
- export function generateStoryId() {
219
+ export function generateLegacyStoryId() {
122
220
  const timestamp = Date.now().toString(36);
123
221
  const random = Math.random().toString(36).substring(2, 6);
124
222
  return `story-${timestamp}-${random}`;
@@ -133,20 +231,93 @@ export function slugify(title) {
133
231
  .replace(/^-|-$/g, '')
134
232
  .substring(0, 50);
135
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
+ }
136
302
  /**
137
303
  * Create a new story in the folder-per-story structure
138
304
  *
139
305
  * Creates stories/{id}/story.md with slug and priority in frontmatter.
140
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)
141
312
  */
142
- export function createStory(title, sdlcRoot, options = {}) {
313
+ export async function createStory(title, sdlcRoot, options = {}, content) {
143
314
  const storiesFolder = path.join(sdlcRoot, STORIES_FOLDER);
144
315
  // Validate parent stories/ directory exists
145
316
  if (!fs.existsSync(storiesFolder)) {
146
317
  throw new Error(`Stories folder does not exist: ${storiesFolder}. Run 'ai-sdlc init' first.`);
147
318
  }
148
319
  // Generate unique ID and slug
149
- const id = generateStoryId();
320
+ const id = generateStoryId(storiesFolder);
150
321
  const slug = slugify(title);
151
322
  // Create story folder: stories/{id}/
152
323
  const storyFolder = path.join(storiesFolder, id);
@@ -197,7 +368,17 @@ export function createStory(title, sdlcRoot, options = {}) {
197
368
  reviews_complete: false,
198
369
  ...options,
199
370
  };
200
- const content = `# ${title}
371
+ // Use custom content if provided, otherwise use default template
372
+ let storyContent;
373
+ if (content) {
374
+ // Security: Strip dangerous HTML tags from custom content
375
+ storyContent = content
376
+ .replace(/<script[^>]*>.*?<\/script>/gis, '')
377
+ .replace(/<iframe[^>]*>.*?<\/iframe>/gis, '');
378
+ }
379
+ else {
380
+ // Default template
381
+ storyContent = `# ${title}
201
382
 
202
383
  ## Summary
203
384
 
@@ -218,28 +399,31 @@ export function createStory(title, sdlcRoot, options = {}) {
218
399
  ## Review Notes
219
400
 
220
401
  <!-- Populated by review agents -->`;
402
+ }
221
403
  const story = {
222
404
  path: filePath,
223
405
  slug,
224
406
  frontmatter,
225
- content,
407
+ content: storyContent,
226
408
  };
227
- writeStory(story);
228
- return story;
409
+ await writeStory(story);
410
+ // Return story with canonical path for consistency
411
+ const canonicalPath = fs.realpathSync(filePath);
412
+ return { ...story, path: canonicalPath };
229
413
  }
230
414
  /**
231
415
  * Update story frontmatter field
232
416
  */
233
- export function updateStoryField(story, field, value) {
417
+ export async function updateStoryField(story, field, value) {
234
418
  story.frontmatter[field] = value;
235
419
  story.frontmatter.updated = new Date().toISOString().split('T')[0];
236
- writeStory(story);
420
+ await writeStory(story);
237
421
  return story;
238
422
  }
239
423
  /**
240
424
  * Append content to a section in the story
241
425
  */
242
- export function appendToSection(story, section, content) {
426
+ export async function appendToSection(story, section, content) {
243
427
  const sectionHeader = `## ${section}`;
244
428
  const sectionIndex = story.content.indexOf(sectionHeader);
245
429
  if (sectionIndex === -1) {
@@ -262,13 +446,13 @@ export function appendToSection(story, section, content) {
262
446
  story.content.substring(insertPoint);
263
447
  }
264
448
  story.frontmatter.updated = new Date().toISOString().split('T')[0];
265
- writeStory(story);
449
+ await writeStory(story);
266
450
  return story;
267
451
  }
268
452
  /**
269
453
  * Record a refinement attempt in the story's frontmatter
270
454
  */
271
- export function recordRefinementAttempt(story, agentType, reviewFeedback) {
455
+ export async function recordRefinementAttempt(story, agentType, reviewFeedback) {
272
456
  // Initialize refinement tracking if not present
273
457
  if (!story.frontmatter.refinement_iterations) {
274
458
  story.frontmatter.refinement_iterations = [];
@@ -285,7 +469,7 @@ export function recordRefinementAttempt(story, agentType, reviewFeedback) {
285
469
  story.frontmatter.refinement_iterations.push(refinementRecord);
286
470
  story.frontmatter.refinement_count = iteration;
287
471
  story.frontmatter.updated = new Date().toISOString().split('T')[0];
288
- writeStory(story);
472
+ await writeStory(story);
289
473
  return story;
290
474
  }
291
475
  /**
@@ -306,7 +490,7 @@ export function canRetryRefinement(story, maxAttempts) {
306
490
  /**
307
491
  * Reset phase completion flags for rework
308
492
  */
309
- export function resetPhaseCompletion(story, phase) {
493
+ export async function resetPhaseCompletion(story, phase) {
310
494
  switch (phase) {
311
495
  case 'research':
312
496
  story.frontmatter.research_complete = false;
@@ -319,7 +503,7 @@ export function resetPhaseCompletion(story, phase) {
319
503
  break;
320
504
  }
321
505
  story.frontmatter.updated = new Date().toISOString().split('T')[0];
322
- writeStory(story);
506
+ await writeStory(story);
323
507
  return story;
324
508
  }
325
509
  /**
@@ -337,9 +521,9 @@ export function getLatestReviewFeedback(story) {
337
521
  /**
338
522
  * Append refinement feedback to the story content
339
523
  */
340
- export function appendRefinementNote(story, iteration, feedback) {
524
+ export async function appendRefinementNote(story, iteration, feedback) {
341
525
  const refinementNote = `### Refinement Iteration ${iteration}\n\n${feedback}`;
342
- return appendToSection(story, 'Review Notes', refinementNote);
526
+ return await appendToSection(story, 'Review Notes', refinementNote);
343
527
  }
344
528
  /**
345
529
  * Get the effective maximum retries for a story (story-specific or config default)
@@ -368,18 +552,18 @@ export function isAtMaxRetries(story, config, maxIterationsOverride) {
368
552
  /**
369
553
  * Increment the retry count for a story
370
554
  */
371
- export function incrementRetryCount(story) {
555
+ export async function incrementRetryCount(story) {
372
556
  const currentCount = story.frontmatter.retry_count || 0;
373
557
  story.frontmatter.retry_count = currentCount + 1;
374
558
  story.frontmatter.last_restart_timestamp = new Date().toISOString();
375
559
  story.frontmatter.updated = new Date().toISOString().split('T')[0];
376
- writeStory(story);
560
+ await writeStory(story);
377
561
  return story;
378
562
  }
379
563
  /**
380
564
  * Reset RPIV cycle for a story (keep research, reset plan/implementation/reviews)
381
565
  */
382
- export function resetRPIVCycle(story, reason) {
566
+ export async function resetRPIVCycle(story, reason) {
383
567
  // Keep research_complete as true, reset other flags
384
568
  story.frontmatter.plan_complete = false;
385
569
  story.frontmatter.implementation_complete = false;
@@ -390,13 +574,13 @@ export function resetRPIVCycle(story, reason) {
390
574
  // Increment retry count
391
575
  const currentCount = story.frontmatter.retry_count || 0;
392
576
  story.frontmatter.retry_count = currentCount + 1;
393
- writeStory(story);
577
+ await writeStory(story);
394
578
  return story;
395
579
  }
396
580
  /**
397
581
  * Append a review attempt to the story's review history
398
582
  */
399
- export function appendReviewHistory(story, attempt) {
583
+ export async function appendReviewHistory(story, attempt) {
400
584
  if (!story.frontmatter.review_history) {
401
585
  story.frontmatter.review_history = [];
402
586
  }
@@ -407,7 +591,7 @@ export function appendReviewHistory(story, attempt) {
407
591
  story.frontmatter.review_history = story.frontmatter.review_history.slice(-10);
408
592
  }
409
593
  story.frontmatter.updated = new Date().toISOString().split('T')[0];
410
- writeStory(story);
594
+ await writeStory(story);
411
595
  return story;
412
596
  }
413
597
  /**
@@ -422,26 +606,115 @@ export function getLatestReviewAttempt(story) {
422
606
  /**
423
607
  * Mark a story as complete (all workflow flags set to true)
424
608
  */
425
- export function markStoryComplete(story) {
609
+ export async function markStoryComplete(story) {
426
610
  story.frontmatter.research_complete = true;
427
611
  story.frontmatter.plan_complete = true;
428
612
  story.frontmatter.implementation_complete = true;
429
613
  story.frontmatter.reviews_complete = true;
430
614
  story.frontmatter.updated = new Date().toISOString().split('T')[0];
431
- writeStory(story);
615
+ await writeStory(story);
432
616
  return story;
433
617
  }
434
618
  /**
435
619
  * Snapshot max_retries from config to story frontmatter (for mid-cycle config change protection)
436
620
  */
437
- export function snapshotMaxRetries(story, config) {
621
+ export async function snapshotMaxRetries(story, config) {
438
622
  if (story.frontmatter.max_retries === undefined) {
439
623
  story.frontmatter.max_retries = config.reviewConfig.maxRetries;
440
624
  story.frontmatter.updated = new Date().toISOString().split('T')[0];
441
- writeStory(story);
625
+ await writeStory(story);
442
626
  }
443
627
  return story;
444
628
  }
629
+ /**
630
+ * Get the current implementation retry count for a story
631
+ */
632
+ export function getImplementationRetryCount(story) {
633
+ return story.frontmatter.implementation_retry_count || 0;
634
+ }
635
+ /**
636
+ * Get the effective maximum implementation retries for a story (story-specific or config default)
637
+ * Story-specific overrides are capped at the upper bound to prevent resource exhaustion
638
+ */
639
+ export function getEffectiveMaxImplementationRetries(story, config) {
640
+ const storyMax = story.frontmatter.max_implementation_retries;
641
+ const configMax = config.implementation.maxRetries;
642
+ const upperBound = config.implementation.maxRetriesUpperBound;
643
+ if (storyMax !== undefined) {
644
+ // Cap story override at upper bound
645
+ return Math.min(storyMax, upperBound);
646
+ }
647
+ return configMax;
648
+ }
649
+ /**
650
+ * Check if a story has reached its maximum implementation retry limit.
651
+ * maxRetries represents the number of RETRY attempts allowed after the initial attempt.
652
+ * So with maxRetries=1, you get 1 initial attempt + 1 retry = 2 total attempts.
653
+ */
654
+ export function isAtMaxImplementationRetries(story, config) {
655
+ const currentRetryCount = getImplementationRetryCount(story);
656
+ const maxRetries = getEffectiveMaxImplementationRetries(story, config);
657
+ // Infinity means no limit
658
+ if (!Number.isFinite(maxRetries)) {
659
+ return false;
660
+ }
661
+ // Use > instead of >= because maxRetries is the number of retries allowed,
662
+ // not the total number of attempts. With maxRetries=1, we allow 1 retry
663
+ // (so 2 total attempts before being considered "at max").
664
+ return currentRetryCount > maxRetries;
665
+ }
666
+ /**
667
+ * Reset implementation retry count to 0
668
+ */
669
+ export async function resetImplementationRetryCount(story) {
670
+ story.frontmatter.implementation_retry_count = 0;
671
+ story.frontmatter.updated = new Date().toISOString().split('T')[0];
672
+ await writeStory(story);
673
+ return story;
674
+ }
675
+ /**
676
+ * Increment the implementation retry count for a story
677
+ */
678
+ export async function incrementImplementationRetryCount(story) {
679
+ const currentCount = story.frontmatter.implementation_retry_count || 0;
680
+ story.frontmatter.implementation_retry_count = currentCount + 1;
681
+ story.frontmatter.updated = new Date().toISOString().split('T')[0];
682
+ await writeStory(story);
683
+ return story;
684
+ }
685
+ /**
686
+ * Sanitize story ID for safe path construction.
687
+ * Prevents path traversal attacks by rejecting dangerous characters.
688
+ *
689
+ * SECURITY: This function is CRITICAL for preventing path traversal vulnerabilities.
690
+ * Use this before constructing ANY file paths with user-provided story IDs.
691
+ *
692
+ * @param storyId - Story ID to sanitize (e.g., 'S-0001')
693
+ * @returns Sanitized story ID safe for path construction
694
+ * @throws Error if storyId contains dangerous characters or patterns
695
+ */
696
+ export function sanitizeStoryId(storyId) {
697
+ if (!storyId) {
698
+ throw new Error('Story ID cannot be empty');
699
+ }
700
+ // Reject path traversal attempts
701
+ if (storyId.includes('..')) {
702
+ throw new Error('Invalid story ID: contains path traversal sequence (..)');
703
+ }
704
+ // Reject path separators
705
+ if (storyId.includes('/') || storyId.includes('\\')) {
706
+ throw new Error('Invalid story ID: contains path separator');
707
+ }
708
+ // Reject absolute paths
709
+ if (path.isAbsolute(storyId)) {
710
+ throw new Error('Invalid story ID: cannot be an absolute path');
711
+ }
712
+ // Reject control characters and other dangerous characters
713
+ if (/[\x00-\x1F\x7F]/.test(storyId)) {
714
+ throw new Error('Invalid story ID: contains control characters');
715
+ }
716
+ return storyId;
717
+ }
445
718
  /**
446
719
  * Sanitize user-controlled text for safe display and storage.
447
720
  * Removes ANSI escape sequences, control characters, and potential injection vectors.
@@ -472,6 +745,118 @@ export function sanitizeReasonText(text) {
472
745
  }
473
746
  return sanitized.trim();
474
747
  }
748
+ /**
749
+ * Find a story by ID using O(1) direct path lookup
750
+ * Falls back to searching old folder structure for backwards compatibility
751
+ *
752
+ * This function is the internal lookup mechanism. Use getStory() for external access.
753
+ *
754
+ * @param sdlcRoot - Root directory of the SDLC workspace
755
+ * @param storyId - Story ID (e.g., 'S-0001')
756
+ * @returns Story object or null if not found
757
+ */
758
+ export function findStoryById(sdlcRoot, storyId) {
759
+ // SECURITY: Validate storyId format (defense-in-depth)
760
+ // Reject any input that could be used for path traversal
761
+ if (!storyId ||
762
+ storyId.includes('..') ||
763
+ storyId.includes('/') ||
764
+ storyId.includes('\\') ||
765
+ path.isAbsolute(storyId)) {
766
+ return null;
767
+ }
768
+ // O(n) directory scan for case-insensitive matching in new architecture
769
+ // Reads all story directories to find case-insensitive match
770
+ // Note: fs.realpathSync() does NOT canonicalize casing on macOS, so we must scan
771
+ const storiesFolder = path.join(sdlcRoot, STORIES_FOLDER);
772
+ if (fs.existsSync(storiesFolder)) {
773
+ try {
774
+ // Read actual directory names from filesystem
775
+ const directories = fs.readdirSync(storiesFolder, { withFileTypes: true })
776
+ .filter(dirent => dirent.isDirectory())
777
+ .map(dirent => dirent.name);
778
+ // Find directory that matches case-insensitively
779
+ const actualDirName = directories.find(dir => dir.toLowerCase() === storyId.toLowerCase());
780
+ if (actualDirName) {
781
+ // Use the actual directory name (with correct filesystem casing)
782
+ const storyPath = path.join(storiesFolder, actualDirName, STORY_FILENAME);
783
+ if (fs.existsSync(storyPath)) {
784
+ const canonicalPath = fs.realpathSync(storyPath);
785
+ const story = parseStory(canonicalPath);
786
+ return { ...story, path: canonicalPath };
787
+ }
788
+ }
789
+ }
790
+ catch (err) {
791
+ // If reading directory fails, fall through to fallback search
792
+ }
793
+ }
794
+ // Fallback: search old folder structure for backwards compatibility
795
+ // Search kanban folders first
796
+ const KANBAN_FOLDERS = ['backlog', 'ready', 'in-progress', 'done'];
797
+ for (const folder of KANBAN_FOLDERS) {
798
+ const folderPath = path.join(sdlcRoot, folder);
799
+ if (!fs.existsSync(folderPath)) {
800
+ continue;
801
+ }
802
+ const files = fs.readdirSync(folderPath).filter(f => f.endsWith('.md'));
803
+ for (const file of files) {
804
+ const filePath = path.join(folderPath, file);
805
+ try {
806
+ const canonicalPath = fs.realpathSync(filePath);
807
+ const story = parseStory(canonicalPath);
808
+ // Case-insensitive comparison to match input
809
+ if (story.frontmatter.id?.toLowerCase() === storyId.toLowerCase()) {
810
+ return { ...story, path: canonicalPath };
811
+ }
812
+ }
813
+ catch (err) {
814
+ continue;
815
+ }
816
+ }
817
+ }
818
+ // Also search blocked folder
819
+ const blockedFolder = path.join(sdlcRoot, BLOCKED_DIR);
820
+ if (fs.existsSync(blockedFolder)) {
821
+ const blockedFiles = fs.readdirSync(blockedFolder).filter(f => f.endsWith('.md'));
822
+ for (const file of blockedFiles) {
823
+ const filePath = path.join(blockedFolder, file);
824
+ try {
825
+ const canonicalPath = fs.realpathSync(filePath);
826
+ const story = parseStory(canonicalPath);
827
+ // Case-insensitive comparison to match input
828
+ if (story.frontmatter.id?.toLowerCase() === storyId.toLowerCase()) {
829
+ return { ...story, path: canonicalPath };
830
+ }
831
+ }
832
+ catch (err) {
833
+ continue;
834
+ }
835
+ }
836
+ }
837
+ return null;
838
+ }
839
+ /**
840
+ * Retrieves a story by ID, resolving its current location across all folders.
841
+ * This is the single source of truth for story lookup - use this instead of
842
+ * directly calling parseStory() with cached paths.
843
+ *
844
+ * @param sdlcRoot - Root directory of the SDLC workspace
845
+ * @param storyId - Story ID (e.g., 'S-0001')
846
+ * @returns Fully parsed Story object with current path and metadata
847
+ * @throws Error if story ID not found in any folder
848
+ */
849
+ export function getStory(sdlcRoot, storyId) {
850
+ const story = findStoryById(sdlcRoot, storyId);
851
+ if (!story) {
852
+ const newStructurePath = path.join(sdlcRoot, STORIES_FOLDER, storyId, STORY_FILENAME);
853
+ throw new Error(`Story not found: ${storyId}\n` +
854
+ `Searched in: ${newStructurePath}\n` +
855
+ `Also searched old folder structure (backlog, in-progress, done, blocked).\n` +
856
+ `The story may have been deleted or the ID is incorrect.`);
857
+ }
858
+ return story;
859
+ }
475
860
  /**
476
861
  * Unblock a story and set status back to in-progress
477
862
  * In the new architecture, this only updates frontmatter - file path remains unchanged
@@ -481,29 +866,9 @@ export function sanitizeReasonText(text) {
481
866
  * @param options - Optional configuration { resetRetries?: boolean }
482
867
  * @returns The unblocked story
483
868
  */
484
- export function unblockStory(storyId, sdlcRoot, options) {
485
- // In new architecture, try direct path lookup first
486
- const storyPath = path.join(sdlcRoot, STORIES_FOLDER, storyId, STORY_FILENAME);
487
- let foundStory = null;
488
- if (fs.existsSync(storyPath)) {
489
- // Found in new structure
490
- foundStory = parseStory(storyPath);
491
- }
492
- else {
493
- // Fallback: search for story in old blocked folder (backwards compatibility)
494
- const blockedFolder = path.join(sdlcRoot, BLOCKED_DIR);
495
- if (fs.existsSync(blockedFolder)) {
496
- const blockedFiles = fs.readdirSync(blockedFolder).filter(f => f.endsWith('.md'));
497
- for (const file of blockedFiles) {
498
- const filePath = path.join(blockedFolder, file);
499
- const story = parseStory(filePath);
500
- if (story.frontmatter.id === storyId) {
501
- foundStory = story;
502
- break;
503
- }
504
- }
505
- }
506
- }
869
+ export async function unblockStory(storyId, sdlcRoot, options) {
870
+ // Use the centralized getStory() function for lookup
871
+ const foundStory = findStoryById(sdlcRoot, storyId);
507
872
  if (!foundStory) {
508
873
  throw new Error(`Story ${storyId} not found`);
509
874
  }
@@ -536,7 +901,7 @@ export function unblockStory(storyId, sdlcRoot, options) {
536
901
  foundStory.frontmatter.status = newStatus;
537
902
  foundStory.frontmatter.updated = new Date().toISOString().split('T')[0];
538
903
  // Write back to same location
539
- writeStory(foundStory);
904
+ await writeStory(foundStory);
540
905
  return foundStory;
541
906
  }
542
907
  //# sourceMappingURL=story.js.map