ai-sdlc 0.2.0-alpha.2 → 0.2.0-alpha.21
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.
- package/dist/agents/implementation.d.ts +62 -0
- package/dist/agents/implementation.d.ts.map +1 -1
- package/dist/agents/implementation.js +502 -89
- package/dist/agents/implementation.js.map +1 -1
- package/dist/agents/planning.js +2 -2
- package/dist/agents/planning.js.map +1 -1
- package/dist/agents/refinement.js +2 -2
- package/dist/agents/refinement.js.map +1 -1
- package/dist/agents/research.js +2 -2
- package/dist/agents/research.js.map +1 -1
- package/dist/agents/review.d.ts +12 -0
- package/dist/agents/review.d.ts.map +1 -1
- package/dist/agents/review.js +100 -9
- package/dist/agents/review.js.map +1 -1
- package/dist/agents/rework.js +3 -3
- package/dist/agents/rework.js.map +1 -1
- package/dist/agents/state-assessor.d.ts +3 -3
- package/dist/agents/state-assessor.d.ts.map +1 -1
- package/dist/agents/state-assessor.js +6 -6
- package/dist/agents/state-assessor.js.map +1 -1
- package/dist/agents/verification.d.ts +11 -0
- package/dist/agents/verification.d.ts.map +1 -1
- package/dist/agents/verification.js +74 -1
- package/dist/agents/verification.js.map +1 -1
- package/dist/cli/commands/migrate.js +1 -1
- package/dist/cli/commands/migrate.js.map +1 -1
- package/dist/cli/commands.d.ts +56 -2
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +806 -171
- package/dist/cli/commands.js.map +1 -1
- package/dist/cli/daemon.d.ts.map +1 -1
- package/dist/cli/daemon.js +23 -10
- package/dist/cli/daemon.js.map +1 -1
- package/dist/cli/formatting.js +1 -1
- package/dist/cli/formatting.js.map +1 -1
- package/dist/cli/runner.d.ts.map +1 -1
- package/dist/cli/runner.js +39 -20
- package/dist/cli/runner.js.map +1 -1
- package/dist/core/auth.d.ts +51 -2
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js +267 -7
- package/dist/core/auth.js.map +1 -1
- package/dist/core/client.d.ts +6 -0
- package/dist/core/client.d.ts.map +1 -1
- package/dist/core/client.js +34 -2
- package/dist/core/client.js.map +1 -1
- package/dist/core/config.d.ts +36 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +161 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/conflict-detector.d.ts +108 -0
- package/dist/core/conflict-detector.d.ts.map +1 -0
- package/dist/core/conflict-detector.js +413 -0
- package/dist/core/conflict-detector.js.map +1 -0
- package/dist/core/git-utils.d.ts +28 -0
- package/dist/core/git-utils.d.ts.map +1 -0
- package/dist/core/git-utils.js +146 -0
- package/dist/core/git-utils.js.map +1 -0
- package/dist/core/index.d.ts +16 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +16 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/kanban.d.ts +1 -6
- package/dist/core/kanban.d.ts.map +1 -1
- package/dist/core/kanban.js +10 -49
- package/dist/core/kanban.js.map +1 -1
- package/dist/core/logger.d.ts +92 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +221 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/story.d.ts +108 -20
- package/dist/core/story.d.ts.map +1 -1
- package/dist/core/story.js +340 -59
- package/dist/core/story.js.map +1 -1
- package/dist/core/workflow-state.d.ts +45 -6
- package/dist/core/workflow-state.d.ts.map +1 -1
- package/dist/core/workflow-state.js +185 -12
- package/dist/core/workflow-state.js.map +1 -1
- package/dist/core/worktree.d.ts +77 -0
- package/dist/core/worktree.d.ts.map +1 -0
- package/dist/core/worktree.js +246 -0
- package/dist/core/worktree.js.map +1 -0
- package/dist/index.js +49 -3
- package/dist/index.js.map +1 -1
- package/dist/types/index.d.ts +110 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/package.json +3 -1
package/dist/core/story.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
216
|
+
* Generate a legacy story ID (for backwards compatibility/fallback)
|
|
217
|
+
* @deprecated Use generateStoryId() instead
|
|
120
218
|
*/
|
|
121
|
-
export function
|
|
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}`;
|
|
@@ -139,14 +237,14 @@ export function slugify(title) {
|
|
|
139
237
|
* Creates stories/{id}/story.md with slug and priority in frontmatter.
|
|
140
238
|
* Priority uses gaps (10, 20, 30...) for easy insertion without renumbering.
|
|
141
239
|
*/
|
|
142
|
-
export function createStory(title, sdlcRoot, options = {}) {
|
|
240
|
+
export async function createStory(title, sdlcRoot, options = {}) {
|
|
143
241
|
const storiesFolder = path.join(sdlcRoot, STORIES_FOLDER);
|
|
144
242
|
// Validate parent stories/ directory exists
|
|
145
243
|
if (!fs.existsSync(storiesFolder)) {
|
|
146
244
|
throw new Error(`Stories folder does not exist: ${storiesFolder}. Run 'ai-sdlc init' first.`);
|
|
147
245
|
}
|
|
148
246
|
// Generate unique ID and slug
|
|
149
|
-
const id = generateStoryId();
|
|
247
|
+
const id = generateStoryId(storiesFolder);
|
|
150
248
|
const slug = slugify(title);
|
|
151
249
|
// Create story folder: stories/{id}/
|
|
152
250
|
const storyFolder = path.join(storiesFolder, id);
|
|
@@ -224,22 +322,24 @@ export function createStory(title, sdlcRoot, options = {}) {
|
|
|
224
322
|
frontmatter,
|
|
225
323
|
content,
|
|
226
324
|
};
|
|
227
|
-
writeStory(story);
|
|
228
|
-
|
|
325
|
+
await writeStory(story);
|
|
326
|
+
// Return story with canonical path for consistency
|
|
327
|
+
const canonicalPath = fs.realpathSync(filePath);
|
|
328
|
+
return { ...story, path: canonicalPath };
|
|
229
329
|
}
|
|
230
330
|
/**
|
|
231
331
|
* Update story frontmatter field
|
|
232
332
|
*/
|
|
233
|
-
export function updateStoryField(story, field, value) {
|
|
333
|
+
export async function updateStoryField(story, field, value) {
|
|
234
334
|
story.frontmatter[field] = value;
|
|
235
335
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
236
|
-
writeStory(story);
|
|
336
|
+
await writeStory(story);
|
|
237
337
|
return story;
|
|
238
338
|
}
|
|
239
339
|
/**
|
|
240
340
|
* Append content to a section in the story
|
|
241
341
|
*/
|
|
242
|
-
export function appendToSection(story, section, content) {
|
|
342
|
+
export async function appendToSection(story, section, content) {
|
|
243
343
|
const sectionHeader = `## ${section}`;
|
|
244
344
|
const sectionIndex = story.content.indexOf(sectionHeader);
|
|
245
345
|
if (sectionIndex === -1) {
|
|
@@ -262,13 +362,13 @@ export function appendToSection(story, section, content) {
|
|
|
262
362
|
story.content.substring(insertPoint);
|
|
263
363
|
}
|
|
264
364
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
265
|
-
writeStory(story);
|
|
365
|
+
await writeStory(story);
|
|
266
366
|
return story;
|
|
267
367
|
}
|
|
268
368
|
/**
|
|
269
369
|
* Record a refinement attempt in the story's frontmatter
|
|
270
370
|
*/
|
|
271
|
-
export function recordRefinementAttempt(story, agentType, reviewFeedback) {
|
|
371
|
+
export async function recordRefinementAttempt(story, agentType, reviewFeedback) {
|
|
272
372
|
// Initialize refinement tracking if not present
|
|
273
373
|
if (!story.frontmatter.refinement_iterations) {
|
|
274
374
|
story.frontmatter.refinement_iterations = [];
|
|
@@ -285,7 +385,7 @@ export function recordRefinementAttempt(story, agentType, reviewFeedback) {
|
|
|
285
385
|
story.frontmatter.refinement_iterations.push(refinementRecord);
|
|
286
386
|
story.frontmatter.refinement_count = iteration;
|
|
287
387
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
288
|
-
writeStory(story);
|
|
388
|
+
await writeStory(story);
|
|
289
389
|
return story;
|
|
290
390
|
}
|
|
291
391
|
/**
|
|
@@ -306,7 +406,7 @@ export function canRetryRefinement(story, maxAttempts) {
|
|
|
306
406
|
/**
|
|
307
407
|
* Reset phase completion flags for rework
|
|
308
408
|
*/
|
|
309
|
-
export function resetPhaseCompletion(story, phase) {
|
|
409
|
+
export async function resetPhaseCompletion(story, phase) {
|
|
310
410
|
switch (phase) {
|
|
311
411
|
case 'research':
|
|
312
412
|
story.frontmatter.research_complete = false;
|
|
@@ -319,7 +419,7 @@ export function resetPhaseCompletion(story, phase) {
|
|
|
319
419
|
break;
|
|
320
420
|
}
|
|
321
421
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
322
|
-
writeStory(story);
|
|
422
|
+
await writeStory(story);
|
|
323
423
|
return story;
|
|
324
424
|
}
|
|
325
425
|
/**
|
|
@@ -337,9 +437,9 @@ export function getLatestReviewFeedback(story) {
|
|
|
337
437
|
/**
|
|
338
438
|
* Append refinement feedback to the story content
|
|
339
439
|
*/
|
|
340
|
-
export function appendRefinementNote(story, iteration, feedback) {
|
|
440
|
+
export async function appendRefinementNote(story, iteration, feedback) {
|
|
341
441
|
const refinementNote = `### Refinement Iteration ${iteration}\n\n${feedback}`;
|
|
342
|
-
return appendToSection(story, 'Review Notes', refinementNote);
|
|
442
|
+
return await appendToSection(story, 'Review Notes', refinementNote);
|
|
343
443
|
}
|
|
344
444
|
/**
|
|
345
445
|
* Get the effective maximum retries for a story (story-specific or config default)
|
|
@@ -368,18 +468,18 @@ export function isAtMaxRetries(story, config, maxIterationsOverride) {
|
|
|
368
468
|
/**
|
|
369
469
|
* Increment the retry count for a story
|
|
370
470
|
*/
|
|
371
|
-
export function incrementRetryCount(story) {
|
|
471
|
+
export async function incrementRetryCount(story) {
|
|
372
472
|
const currentCount = story.frontmatter.retry_count || 0;
|
|
373
473
|
story.frontmatter.retry_count = currentCount + 1;
|
|
374
474
|
story.frontmatter.last_restart_timestamp = new Date().toISOString();
|
|
375
475
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
376
|
-
writeStory(story);
|
|
476
|
+
await writeStory(story);
|
|
377
477
|
return story;
|
|
378
478
|
}
|
|
379
479
|
/**
|
|
380
480
|
* Reset RPIV cycle for a story (keep research, reset plan/implementation/reviews)
|
|
381
481
|
*/
|
|
382
|
-
export function resetRPIVCycle(story, reason) {
|
|
482
|
+
export async function resetRPIVCycle(story, reason) {
|
|
383
483
|
// Keep research_complete as true, reset other flags
|
|
384
484
|
story.frontmatter.plan_complete = false;
|
|
385
485
|
story.frontmatter.implementation_complete = false;
|
|
@@ -390,13 +490,13 @@ export function resetRPIVCycle(story, reason) {
|
|
|
390
490
|
// Increment retry count
|
|
391
491
|
const currentCount = story.frontmatter.retry_count || 0;
|
|
392
492
|
story.frontmatter.retry_count = currentCount + 1;
|
|
393
|
-
writeStory(story);
|
|
493
|
+
await writeStory(story);
|
|
394
494
|
return story;
|
|
395
495
|
}
|
|
396
496
|
/**
|
|
397
497
|
* Append a review attempt to the story's review history
|
|
398
498
|
*/
|
|
399
|
-
export function appendReviewHistory(story, attempt) {
|
|
499
|
+
export async function appendReviewHistory(story, attempt) {
|
|
400
500
|
if (!story.frontmatter.review_history) {
|
|
401
501
|
story.frontmatter.review_history = [];
|
|
402
502
|
}
|
|
@@ -407,7 +507,7 @@ export function appendReviewHistory(story, attempt) {
|
|
|
407
507
|
story.frontmatter.review_history = story.frontmatter.review_history.slice(-10);
|
|
408
508
|
}
|
|
409
509
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
410
|
-
writeStory(story);
|
|
510
|
+
await writeStory(story);
|
|
411
511
|
return story;
|
|
412
512
|
}
|
|
413
513
|
/**
|
|
@@ -422,26 +522,115 @@ export function getLatestReviewAttempt(story) {
|
|
|
422
522
|
/**
|
|
423
523
|
* Mark a story as complete (all workflow flags set to true)
|
|
424
524
|
*/
|
|
425
|
-
export function markStoryComplete(story) {
|
|
525
|
+
export async function markStoryComplete(story) {
|
|
426
526
|
story.frontmatter.research_complete = true;
|
|
427
527
|
story.frontmatter.plan_complete = true;
|
|
428
528
|
story.frontmatter.implementation_complete = true;
|
|
429
529
|
story.frontmatter.reviews_complete = true;
|
|
430
530
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
431
|
-
writeStory(story);
|
|
531
|
+
await writeStory(story);
|
|
432
532
|
return story;
|
|
433
533
|
}
|
|
434
534
|
/**
|
|
435
535
|
* Snapshot max_retries from config to story frontmatter (for mid-cycle config change protection)
|
|
436
536
|
*/
|
|
437
|
-
export function snapshotMaxRetries(story, config) {
|
|
537
|
+
export async function snapshotMaxRetries(story, config) {
|
|
438
538
|
if (story.frontmatter.max_retries === undefined) {
|
|
439
539
|
story.frontmatter.max_retries = config.reviewConfig.maxRetries;
|
|
440
540
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
441
|
-
writeStory(story);
|
|
541
|
+
await writeStory(story);
|
|
442
542
|
}
|
|
443
543
|
return story;
|
|
444
544
|
}
|
|
545
|
+
/**
|
|
546
|
+
* Get the current implementation retry count for a story
|
|
547
|
+
*/
|
|
548
|
+
export function getImplementationRetryCount(story) {
|
|
549
|
+
return story.frontmatter.implementation_retry_count || 0;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Get the effective maximum implementation retries for a story (story-specific or config default)
|
|
553
|
+
* Story-specific overrides are capped at the upper bound to prevent resource exhaustion
|
|
554
|
+
*/
|
|
555
|
+
export function getEffectiveMaxImplementationRetries(story, config) {
|
|
556
|
+
const storyMax = story.frontmatter.max_implementation_retries;
|
|
557
|
+
const configMax = config.implementation.maxRetries;
|
|
558
|
+
const upperBound = config.implementation.maxRetriesUpperBound;
|
|
559
|
+
if (storyMax !== undefined) {
|
|
560
|
+
// Cap story override at upper bound
|
|
561
|
+
return Math.min(storyMax, upperBound);
|
|
562
|
+
}
|
|
563
|
+
return configMax;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Check if a story has reached its maximum implementation retry limit.
|
|
567
|
+
* maxRetries represents the number of RETRY attempts allowed after the initial attempt.
|
|
568
|
+
* So with maxRetries=1, you get 1 initial attempt + 1 retry = 2 total attempts.
|
|
569
|
+
*/
|
|
570
|
+
export function isAtMaxImplementationRetries(story, config) {
|
|
571
|
+
const currentRetryCount = getImplementationRetryCount(story);
|
|
572
|
+
const maxRetries = getEffectiveMaxImplementationRetries(story, config);
|
|
573
|
+
// Infinity means no limit
|
|
574
|
+
if (!Number.isFinite(maxRetries)) {
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
// Use > instead of >= because maxRetries is the number of retries allowed,
|
|
578
|
+
// not the total number of attempts. With maxRetries=1, we allow 1 retry
|
|
579
|
+
// (so 2 total attempts before being considered "at max").
|
|
580
|
+
return currentRetryCount > maxRetries;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Reset implementation retry count to 0
|
|
584
|
+
*/
|
|
585
|
+
export async function resetImplementationRetryCount(story) {
|
|
586
|
+
story.frontmatter.implementation_retry_count = 0;
|
|
587
|
+
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
588
|
+
await writeStory(story);
|
|
589
|
+
return story;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Increment the implementation retry count for a story
|
|
593
|
+
*/
|
|
594
|
+
export async function incrementImplementationRetryCount(story) {
|
|
595
|
+
const currentCount = story.frontmatter.implementation_retry_count || 0;
|
|
596
|
+
story.frontmatter.implementation_retry_count = currentCount + 1;
|
|
597
|
+
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
598
|
+
await writeStory(story);
|
|
599
|
+
return story;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Sanitize story ID for safe path construction.
|
|
603
|
+
* Prevents path traversal attacks by rejecting dangerous characters.
|
|
604
|
+
*
|
|
605
|
+
* SECURITY: This function is CRITICAL for preventing path traversal vulnerabilities.
|
|
606
|
+
* Use this before constructing ANY file paths with user-provided story IDs.
|
|
607
|
+
*
|
|
608
|
+
* @param storyId - Story ID to sanitize (e.g., 'S-0001')
|
|
609
|
+
* @returns Sanitized story ID safe for path construction
|
|
610
|
+
* @throws Error if storyId contains dangerous characters or patterns
|
|
611
|
+
*/
|
|
612
|
+
export function sanitizeStoryId(storyId) {
|
|
613
|
+
if (!storyId) {
|
|
614
|
+
throw new Error('Story ID cannot be empty');
|
|
615
|
+
}
|
|
616
|
+
// Reject path traversal attempts
|
|
617
|
+
if (storyId.includes('..')) {
|
|
618
|
+
throw new Error('Invalid story ID: contains path traversal sequence (..)');
|
|
619
|
+
}
|
|
620
|
+
// Reject path separators
|
|
621
|
+
if (storyId.includes('/') || storyId.includes('\\')) {
|
|
622
|
+
throw new Error('Invalid story ID: contains path separator');
|
|
623
|
+
}
|
|
624
|
+
// Reject absolute paths
|
|
625
|
+
if (path.isAbsolute(storyId)) {
|
|
626
|
+
throw new Error('Invalid story ID: cannot be an absolute path');
|
|
627
|
+
}
|
|
628
|
+
// Reject control characters and other dangerous characters
|
|
629
|
+
if (/[\x00-\x1F\x7F]/.test(storyId)) {
|
|
630
|
+
throw new Error('Invalid story ID: contains control characters');
|
|
631
|
+
}
|
|
632
|
+
return storyId;
|
|
633
|
+
}
|
|
445
634
|
/**
|
|
446
635
|
* Sanitize user-controlled text for safe display and storage.
|
|
447
636
|
* Removes ANSI escape sequences, control characters, and potential injection vectors.
|
|
@@ -472,6 +661,118 @@ export function sanitizeReasonText(text) {
|
|
|
472
661
|
}
|
|
473
662
|
return sanitized.trim();
|
|
474
663
|
}
|
|
664
|
+
/**
|
|
665
|
+
* Find a story by ID using O(1) direct path lookup
|
|
666
|
+
* Falls back to searching old folder structure for backwards compatibility
|
|
667
|
+
*
|
|
668
|
+
* This function is the internal lookup mechanism. Use getStory() for external access.
|
|
669
|
+
*
|
|
670
|
+
* @param sdlcRoot - Root directory of the SDLC workspace
|
|
671
|
+
* @param storyId - Story ID (e.g., 'S-0001')
|
|
672
|
+
* @returns Story object or null if not found
|
|
673
|
+
*/
|
|
674
|
+
export function findStoryById(sdlcRoot, storyId) {
|
|
675
|
+
// SECURITY: Validate storyId format (defense-in-depth)
|
|
676
|
+
// Reject any input that could be used for path traversal
|
|
677
|
+
if (!storyId ||
|
|
678
|
+
storyId.includes('..') ||
|
|
679
|
+
storyId.includes('/') ||
|
|
680
|
+
storyId.includes('\\') ||
|
|
681
|
+
path.isAbsolute(storyId)) {
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
// O(n) directory scan for case-insensitive matching in new architecture
|
|
685
|
+
// Reads all story directories to find case-insensitive match
|
|
686
|
+
// Note: fs.realpathSync() does NOT canonicalize casing on macOS, so we must scan
|
|
687
|
+
const storiesFolder = path.join(sdlcRoot, STORIES_FOLDER);
|
|
688
|
+
if (fs.existsSync(storiesFolder)) {
|
|
689
|
+
try {
|
|
690
|
+
// Read actual directory names from filesystem
|
|
691
|
+
const directories = fs.readdirSync(storiesFolder, { withFileTypes: true })
|
|
692
|
+
.filter(dirent => dirent.isDirectory())
|
|
693
|
+
.map(dirent => dirent.name);
|
|
694
|
+
// Find directory that matches case-insensitively
|
|
695
|
+
const actualDirName = directories.find(dir => dir.toLowerCase() === storyId.toLowerCase());
|
|
696
|
+
if (actualDirName) {
|
|
697
|
+
// Use the actual directory name (with correct filesystem casing)
|
|
698
|
+
const storyPath = path.join(storiesFolder, actualDirName, STORY_FILENAME);
|
|
699
|
+
if (fs.existsSync(storyPath)) {
|
|
700
|
+
const canonicalPath = fs.realpathSync(storyPath);
|
|
701
|
+
const story = parseStory(canonicalPath);
|
|
702
|
+
return { ...story, path: canonicalPath };
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
catch (err) {
|
|
707
|
+
// If reading directory fails, fall through to fallback search
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
// Fallback: search old folder structure for backwards compatibility
|
|
711
|
+
// Search kanban folders first
|
|
712
|
+
const KANBAN_FOLDERS = ['backlog', 'ready', 'in-progress', 'done'];
|
|
713
|
+
for (const folder of KANBAN_FOLDERS) {
|
|
714
|
+
const folderPath = path.join(sdlcRoot, folder);
|
|
715
|
+
if (!fs.existsSync(folderPath)) {
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
const files = fs.readdirSync(folderPath).filter(f => f.endsWith('.md'));
|
|
719
|
+
for (const file of files) {
|
|
720
|
+
const filePath = path.join(folderPath, file);
|
|
721
|
+
try {
|
|
722
|
+
const canonicalPath = fs.realpathSync(filePath);
|
|
723
|
+
const story = parseStory(canonicalPath);
|
|
724
|
+
// Case-insensitive comparison to match input
|
|
725
|
+
if (story.frontmatter.id?.toLowerCase() === storyId.toLowerCase()) {
|
|
726
|
+
return { ...story, path: canonicalPath };
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
catch (err) {
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// Also search blocked folder
|
|
735
|
+
const blockedFolder = path.join(sdlcRoot, BLOCKED_DIR);
|
|
736
|
+
if (fs.existsSync(blockedFolder)) {
|
|
737
|
+
const blockedFiles = fs.readdirSync(blockedFolder).filter(f => f.endsWith('.md'));
|
|
738
|
+
for (const file of blockedFiles) {
|
|
739
|
+
const filePath = path.join(blockedFolder, file);
|
|
740
|
+
try {
|
|
741
|
+
const canonicalPath = fs.realpathSync(filePath);
|
|
742
|
+
const story = parseStory(canonicalPath);
|
|
743
|
+
// Case-insensitive comparison to match input
|
|
744
|
+
if (story.frontmatter.id?.toLowerCase() === storyId.toLowerCase()) {
|
|
745
|
+
return { ...story, path: canonicalPath };
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
catch (err) {
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Retrieves a story by ID, resolving its current location across all folders.
|
|
757
|
+
* This is the single source of truth for story lookup - use this instead of
|
|
758
|
+
* directly calling parseStory() with cached paths.
|
|
759
|
+
*
|
|
760
|
+
* @param sdlcRoot - Root directory of the SDLC workspace
|
|
761
|
+
* @param storyId - Story ID (e.g., 'S-0001')
|
|
762
|
+
* @returns Fully parsed Story object with current path and metadata
|
|
763
|
+
* @throws Error if story ID not found in any folder
|
|
764
|
+
*/
|
|
765
|
+
export function getStory(sdlcRoot, storyId) {
|
|
766
|
+
const story = findStoryById(sdlcRoot, storyId);
|
|
767
|
+
if (!story) {
|
|
768
|
+
const newStructurePath = path.join(sdlcRoot, STORIES_FOLDER, storyId, STORY_FILENAME);
|
|
769
|
+
throw new Error(`Story not found: ${storyId}\n` +
|
|
770
|
+
`Searched in: ${newStructurePath}\n` +
|
|
771
|
+
`Also searched old folder structure (backlog, in-progress, done, blocked).\n` +
|
|
772
|
+
`The story may have been deleted or the ID is incorrect.`);
|
|
773
|
+
}
|
|
774
|
+
return story;
|
|
775
|
+
}
|
|
475
776
|
/**
|
|
476
777
|
* Unblock a story and set status back to in-progress
|
|
477
778
|
* In the new architecture, this only updates frontmatter - file path remains unchanged
|
|
@@ -481,29 +782,9 @@ export function sanitizeReasonText(text) {
|
|
|
481
782
|
* @param options - Optional configuration { resetRetries?: boolean }
|
|
482
783
|
* @returns The unblocked story
|
|
483
784
|
*/
|
|
484
|
-
export function unblockStory(storyId, sdlcRoot, options) {
|
|
485
|
-
//
|
|
486
|
-
const
|
|
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
|
-
}
|
|
785
|
+
export async function unblockStory(storyId, sdlcRoot, options) {
|
|
786
|
+
// Use the centralized getStory() function for lookup
|
|
787
|
+
const foundStory = findStoryById(sdlcRoot, storyId);
|
|
507
788
|
if (!foundStory) {
|
|
508
789
|
throw new Error(`Story ${storyId} not found`);
|
|
509
790
|
}
|
|
@@ -536,7 +817,7 @@ export function unblockStory(storyId, sdlcRoot, options) {
|
|
|
536
817
|
foundStory.frontmatter.status = newStatus;
|
|
537
818
|
foundStory.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
538
819
|
// Write back to same location
|
|
539
|
-
writeStory(foundStory);
|
|
820
|
+
await writeStory(foundStory);
|
|
540
821
|
return foundStory;
|
|
541
822
|
}
|
|
542
823
|
//# sourceMappingURL=story.js.map
|