ai-sdlc 0.2.0-alpha.4 → 0.2.0-alpha.40
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/README.md +53 -1058
- package/dist/agents/implementation.d.ts +62 -0
- package/dist/agents/implementation.d.ts.map +1 -1
- package/dist/agents/implementation.js +494 -90
- package/dist/agents/implementation.js.map +1 -1
- package/dist/agents/index.d.ts +1 -0
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/index.js +1 -0
- package/dist/agents/index.js.map +1 -1
- package/dist/agents/planning.d.ts +1 -1
- package/dist/agents/planning.d.ts.map +1 -1
- package/dist/agents/planning.js +55 -4
- package/dist/agents/planning.js.map +1 -1
- package/dist/agents/refinement.d.ts.map +1 -1
- package/dist/agents/refinement.js +22 -3
- package/dist/agents/refinement.js.map +1 -1
- package/dist/agents/research.d.ts +85 -1
- package/dist/agents/research.d.ts.map +1 -1
- package/dist/agents/research.js +506 -16
- package/dist/agents/research.js.map +1 -1
- package/dist/agents/review.d.ts +79 -2
- package/dist/agents/review.d.ts.map +1 -1
- package/dist/agents/review.js +568 -68
- package/dist/agents/review.js.map +1 -1
- package/dist/agents/rework.d.ts.map +1 -1
- package/dist/agents/rework.js +22 -3
- package/dist/agents/rework.js.map +1 -1
- package/dist/agents/single-task.d.ts +41 -0
- package/dist/agents/single-task.d.ts.map +1 -0
- package/dist/agents/single-task.js +357 -0
- package/dist/agents/single-task.js.map +1 -0
- 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/test-pattern-detector.d.ts +49 -0
- package/dist/agents/test-pattern-detector.d.ts.map +1 -0
- package/dist/agents/test-pattern-detector.js +273 -0
- package/dist/agents/test-pattern-detector.js.map +1 -0
- 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 +59 -3
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +1053 -217
- package/dist/cli/commands.js.map +1 -1
- package/dist/cli/daemon.d.ts.map +1 -1
- package/dist/cli/daemon.js +40 -10
- package/dist/cli/daemon.js.map +1 -1
- package/dist/cli/runner.d.ts.map +1 -1
- package/dist/cli/runner.js +51 -20
- package/dist/cli/runner.js.map +1 -1
- package/dist/core/auth.d.ts +43 -0
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js +105 -1
- 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 +57 -3
- 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 +162 -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 +18 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +18 -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-logger.d.ts +102 -0
- package/dist/core/story-logger.d.ts.map +1 -0
- package/dist/core/story-logger.js +265 -0
- package/dist/core/story-logger.js.map +1 -0
- package/dist/core/story.d.ts +122 -18
- package/dist/core/story.d.ts.map +1 -1
- package/dist/core/story.js +390 -58
- package/dist/core/story.js.map +1 -1
- package/dist/core/task-parser.d.ts +59 -0
- package/dist/core/task-parser.d.ts.map +1 -0
- package/dist/core/task-parser.js +235 -0
- package/dist/core/task-parser.js.map +1 -0
- package/dist/core/task-progress.d.ts +92 -0
- package/dist/core/task-progress.d.ts.map +1 -0
- package/dist/core/task-progress.js +280 -0
- package/dist/core/task-progress.js.map +1 -0
- 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 +201 -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 +135 -5
- package/dist/index.js.map +1 -1
- package/dist/types/index.d.ts +288 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -0
- 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,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)) {
|
|
@@ -230,7 +368,17 @@ export function createStory(title, sdlcRoot, options = {}) {
|
|
|
230
368
|
reviews_complete: false,
|
|
231
369
|
...options,
|
|
232
370
|
};
|
|
233
|
-
|
|
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}
|
|
234
382
|
|
|
235
383
|
## Summary
|
|
236
384
|
|
|
@@ -251,28 +399,31 @@ export function createStory(title, sdlcRoot, options = {}) {
|
|
|
251
399
|
## Review Notes
|
|
252
400
|
|
|
253
401
|
<!-- Populated by review agents -->`;
|
|
402
|
+
}
|
|
254
403
|
const story = {
|
|
255
404
|
path: filePath,
|
|
256
405
|
slug,
|
|
257
406
|
frontmatter,
|
|
258
|
-
content,
|
|
407
|
+
content: storyContent,
|
|
259
408
|
};
|
|
260
|
-
writeStory(story);
|
|
261
|
-
|
|
409
|
+
await writeStory(story);
|
|
410
|
+
// Return story with canonical path for consistency
|
|
411
|
+
const canonicalPath = fs.realpathSync(filePath);
|
|
412
|
+
return { ...story, path: canonicalPath };
|
|
262
413
|
}
|
|
263
414
|
/**
|
|
264
415
|
* Update story frontmatter field
|
|
265
416
|
*/
|
|
266
|
-
export function updateStoryField(story, field, value) {
|
|
417
|
+
export async function updateStoryField(story, field, value) {
|
|
267
418
|
story.frontmatter[field] = value;
|
|
268
419
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
269
|
-
writeStory(story);
|
|
420
|
+
await writeStory(story);
|
|
270
421
|
return story;
|
|
271
422
|
}
|
|
272
423
|
/**
|
|
273
424
|
* Append content to a section in the story
|
|
274
425
|
*/
|
|
275
|
-
export function appendToSection(story, section, content) {
|
|
426
|
+
export async function appendToSection(story, section, content) {
|
|
276
427
|
const sectionHeader = `## ${section}`;
|
|
277
428
|
const sectionIndex = story.content.indexOf(sectionHeader);
|
|
278
429
|
if (sectionIndex === -1) {
|
|
@@ -295,13 +446,13 @@ export function appendToSection(story, section, content) {
|
|
|
295
446
|
story.content.substring(insertPoint);
|
|
296
447
|
}
|
|
297
448
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
298
|
-
writeStory(story);
|
|
449
|
+
await writeStory(story);
|
|
299
450
|
return story;
|
|
300
451
|
}
|
|
301
452
|
/**
|
|
302
453
|
* Record a refinement attempt in the story's frontmatter
|
|
303
454
|
*/
|
|
304
|
-
export function recordRefinementAttempt(story, agentType, reviewFeedback) {
|
|
455
|
+
export async function recordRefinementAttempt(story, agentType, reviewFeedback) {
|
|
305
456
|
// Initialize refinement tracking if not present
|
|
306
457
|
if (!story.frontmatter.refinement_iterations) {
|
|
307
458
|
story.frontmatter.refinement_iterations = [];
|
|
@@ -318,7 +469,7 @@ export function recordRefinementAttempt(story, agentType, reviewFeedback) {
|
|
|
318
469
|
story.frontmatter.refinement_iterations.push(refinementRecord);
|
|
319
470
|
story.frontmatter.refinement_count = iteration;
|
|
320
471
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
321
|
-
writeStory(story);
|
|
472
|
+
await writeStory(story);
|
|
322
473
|
return story;
|
|
323
474
|
}
|
|
324
475
|
/**
|
|
@@ -339,7 +490,7 @@ export function canRetryRefinement(story, maxAttempts) {
|
|
|
339
490
|
/**
|
|
340
491
|
* Reset phase completion flags for rework
|
|
341
492
|
*/
|
|
342
|
-
export function resetPhaseCompletion(story, phase) {
|
|
493
|
+
export async function resetPhaseCompletion(story, phase) {
|
|
343
494
|
switch (phase) {
|
|
344
495
|
case 'research':
|
|
345
496
|
story.frontmatter.research_complete = false;
|
|
@@ -352,7 +503,7 @@ export function resetPhaseCompletion(story, phase) {
|
|
|
352
503
|
break;
|
|
353
504
|
}
|
|
354
505
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
355
|
-
writeStory(story);
|
|
506
|
+
await writeStory(story);
|
|
356
507
|
return story;
|
|
357
508
|
}
|
|
358
509
|
/**
|
|
@@ -370,9 +521,9 @@ export function getLatestReviewFeedback(story) {
|
|
|
370
521
|
/**
|
|
371
522
|
* Append refinement feedback to the story content
|
|
372
523
|
*/
|
|
373
|
-
export function appendRefinementNote(story, iteration, feedback) {
|
|
524
|
+
export async function appendRefinementNote(story, iteration, feedback) {
|
|
374
525
|
const refinementNote = `### Refinement Iteration ${iteration}\n\n${feedback}`;
|
|
375
|
-
return appendToSection(story, 'Review Notes', refinementNote);
|
|
526
|
+
return await appendToSection(story, 'Review Notes', refinementNote);
|
|
376
527
|
}
|
|
377
528
|
/**
|
|
378
529
|
* Get the effective maximum retries for a story (story-specific or config default)
|
|
@@ -401,18 +552,18 @@ export function isAtMaxRetries(story, config, maxIterationsOverride) {
|
|
|
401
552
|
/**
|
|
402
553
|
* Increment the retry count for a story
|
|
403
554
|
*/
|
|
404
|
-
export function incrementRetryCount(story) {
|
|
555
|
+
export async function incrementRetryCount(story) {
|
|
405
556
|
const currentCount = story.frontmatter.retry_count || 0;
|
|
406
557
|
story.frontmatter.retry_count = currentCount + 1;
|
|
407
558
|
story.frontmatter.last_restart_timestamp = new Date().toISOString();
|
|
408
559
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
409
|
-
writeStory(story);
|
|
560
|
+
await writeStory(story);
|
|
410
561
|
return story;
|
|
411
562
|
}
|
|
412
563
|
/**
|
|
413
564
|
* Reset RPIV cycle for a story (keep research, reset plan/implementation/reviews)
|
|
414
565
|
*/
|
|
415
|
-
export function resetRPIVCycle(story, reason) {
|
|
566
|
+
export async function resetRPIVCycle(story, reason) {
|
|
416
567
|
// Keep research_complete as true, reset other flags
|
|
417
568
|
story.frontmatter.plan_complete = false;
|
|
418
569
|
story.frontmatter.implementation_complete = false;
|
|
@@ -423,13 +574,13 @@ export function resetRPIVCycle(story, reason) {
|
|
|
423
574
|
// Increment retry count
|
|
424
575
|
const currentCount = story.frontmatter.retry_count || 0;
|
|
425
576
|
story.frontmatter.retry_count = currentCount + 1;
|
|
426
|
-
writeStory(story);
|
|
577
|
+
await writeStory(story);
|
|
427
578
|
return story;
|
|
428
579
|
}
|
|
429
580
|
/**
|
|
430
581
|
* Append a review attempt to the story's review history
|
|
431
582
|
*/
|
|
432
|
-
export function appendReviewHistory(story, attempt) {
|
|
583
|
+
export async function appendReviewHistory(story, attempt) {
|
|
433
584
|
if (!story.frontmatter.review_history) {
|
|
434
585
|
story.frontmatter.review_history = [];
|
|
435
586
|
}
|
|
@@ -440,7 +591,7 @@ export function appendReviewHistory(story, attempt) {
|
|
|
440
591
|
story.frontmatter.review_history = story.frontmatter.review_history.slice(-10);
|
|
441
592
|
}
|
|
442
593
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
443
|
-
writeStory(story);
|
|
594
|
+
await writeStory(story);
|
|
444
595
|
return story;
|
|
445
596
|
}
|
|
446
597
|
/**
|
|
@@ -455,26 +606,115 @@ export function getLatestReviewAttempt(story) {
|
|
|
455
606
|
/**
|
|
456
607
|
* Mark a story as complete (all workflow flags set to true)
|
|
457
608
|
*/
|
|
458
|
-
export function markStoryComplete(story) {
|
|
609
|
+
export async function markStoryComplete(story) {
|
|
459
610
|
story.frontmatter.research_complete = true;
|
|
460
611
|
story.frontmatter.plan_complete = true;
|
|
461
612
|
story.frontmatter.implementation_complete = true;
|
|
462
613
|
story.frontmatter.reviews_complete = true;
|
|
463
614
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
464
|
-
writeStory(story);
|
|
615
|
+
await writeStory(story);
|
|
465
616
|
return story;
|
|
466
617
|
}
|
|
467
618
|
/**
|
|
468
619
|
* Snapshot max_retries from config to story frontmatter (for mid-cycle config change protection)
|
|
469
620
|
*/
|
|
470
|
-
export function snapshotMaxRetries(story, config) {
|
|
621
|
+
export async function snapshotMaxRetries(story, config) {
|
|
471
622
|
if (story.frontmatter.max_retries === undefined) {
|
|
472
623
|
story.frontmatter.max_retries = config.reviewConfig.maxRetries;
|
|
473
624
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
474
|
-
writeStory(story);
|
|
625
|
+
await writeStory(story);
|
|
475
626
|
}
|
|
476
627
|
return story;
|
|
477
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
|
+
}
|
|
478
718
|
/**
|
|
479
719
|
* Sanitize user-controlled text for safe display and storage.
|
|
480
720
|
* Removes ANSI escape sequences, control characters, and potential injection vectors.
|
|
@@ -505,6 +745,118 @@ export function sanitizeReasonText(text) {
|
|
|
505
745
|
}
|
|
506
746
|
return sanitized.trim();
|
|
507
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
|
+
}
|
|
508
860
|
/**
|
|
509
861
|
* Unblock a story and set status back to in-progress
|
|
510
862
|
* In the new architecture, this only updates frontmatter - file path remains unchanged
|
|
@@ -514,29 +866,9 @@ export function sanitizeReasonText(text) {
|
|
|
514
866
|
* @param options - Optional configuration { resetRetries?: boolean }
|
|
515
867
|
* @returns The unblocked story
|
|
516
868
|
*/
|
|
517
|
-
export function unblockStory(storyId, sdlcRoot, options) {
|
|
518
|
-
//
|
|
519
|
-
const
|
|
520
|
-
let foundStory = null;
|
|
521
|
-
if (fs.existsSync(storyPath)) {
|
|
522
|
-
// Found in new structure
|
|
523
|
-
foundStory = parseStory(storyPath);
|
|
524
|
-
}
|
|
525
|
-
else {
|
|
526
|
-
// Fallback: search for story in old blocked folder (backwards compatibility)
|
|
527
|
-
const blockedFolder = path.join(sdlcRoot, BLOCKED_DIR);
|
|
528
|
-
if (fs.existsSync(blockedFolder)) {
|
|
529
|
-
const blockedFiles = fs.readdirSync(blockedFolder).filter(f => f.endsWith('.md'));
|
|
530
|
-
for (const file of blockedFiles) {
|
|
531
|
-
const filePath = path.join(blockedFolder, file);
|
|
532
|
-
const story = parseStory(filePath);
|
|
533
|
-
if (story.frontmatter.id === storyId) {
|
|
534
|
-
foundStory = story;
|
|
535
|
-
break;
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
}
|
|
869
|
+
export async function unblockStory(storyId, sdlcRoot, options) {
|
|
870
|
+
// Use the centralized getStory() function for lookup
|
|
871
|
+
const foundStory = findStoryById(sdlcRoot, storyId);
|
|
540
872
|
if (!foundStory) {
|
|
541
873
|
throw new Error(`Story ${storyId} not found`);
|
|
542
874
|
}
|
|
@@ -569,7 +901,7 @@ export function unblockStory(storyId, sdlcRoot, options) {
|
|
|
569
901
|
foundStory.frontmatter.status = newStatus;
|
|
570
902
|
foundStory.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
571
903
|
// Write back to same location
|
|
572
|
-
writeStory(foundStory);
|
|
904
|
+
await writeStory(foundStory);
|
|
573
905
|
return foundStory;
|
|
574
906
|
}
|
|
575
907
|
//# sourceMappingURL=story.js.map
|