ai-sdlc 0.2.0-alpha.6 → 0.2.0-alpha.61
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 +65 -1057
- package/dist/agents/implementation.d.ts +36 -1
- package/dist/agents/implementation.d.ts.map +1 -1
- package/dist/agents/implementation.js +259 -30
- package/dist/agents/implementation.js.map +1 -1
- package/dist/agents/index.d.ts +2 -0
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/index.js +2 -0
- package/dist/agents/index.js.map +1 -1
- package/dist/agents/orchestrator.d.ts +61 -0
- package/dist/agents/orchestrator.d.ts.map +1 -0
- package/dist/agents/orchestrator.js +443 -0
- package/dist/agents/orchestrator.js.map +1 -0
- 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 +116 -2
- package/dist/agents/review.d.ts.map +1 -1
- package/dist/agents/review.js +847 -93
- package/dist/agents/review.js.map +1 -1
- package/dist/agents/rework.d.ts.map +1 -1
- package/dist/agents/rework.js +25 -4
- 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 +99 -12
- package/dist/agents/verification.js.map +1 -1
- package/dist/cli/batch-processor.d.ts +64 -0
- package/dist/cli/batch-processor.d.ts.map +1 -0
- package/dist/cli/batch-processor.js +85 -0
- package/dist/cli/batch-processor.js.map +1 -0
- package/dist/cli/batch-validator.d.ts +80 -0
- package/dist/cli/batch-validator.d.ts.map +1 -0
- package/dist/cli/batch-validator.js +121 -0
- package/dist/cli/batch-validator.js.map +1 -0
- package/dist/cli/commands/migrate.js +1 -1
- package/dist/cli/commands/migrate.js.map +1 -1
- package/dist/cli/commands.d.ts +67 -3
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +1765 -198
- package/dist/cli/commands.js.map +1 -1
- package/dist/cli/daemon.d.ts.map +1 -1
- package/dist/cli/daemon.js +25 -3
- package/dist/cli/daemon.js.map +1 -1
- package/dist/cli/runner.d.ts.map +1 -1
- package/dist/cli/runner.js +35 -12
- 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 +25 -1
- package/dist/core/client.d.ts.map +1 -1
- package/dist/core/client.js +247 -7
- package/dist/core/client.js.map +1 -1
- package/dist/core/config.d.ts +32 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +146 -3
- 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 +19 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +19 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/kanban.d.ts +1 -1
- package/dist/core/kanban.d.ts.map +1 -1
- package/dist/core/kanban.js +3 -3
- package/dist/core/kanban.js.map +1 -1
- package/dist/core/llm-utils.d.ts +103 -0
- package/dist/core/llm-utils.d.ts.map +1 -0
- package/dist/core/llm-utils.js +368 -0
- package/dist/core/llm-utils.js.map +1 -0
- 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/process-manager.d.ts +15 -0
- package/dist/core/process-manager.d.ts.map +1 -0
- package/dist/core/process-manager.js +132 -0
- package/dist/core/process-manager.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 +113 -20
- package/dist/core/story.d.ts.map +1 -1
- package/dist/core/story.js +328 -40
- 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 +186 -0
- package/dist/core/worktree.d.ts.map +1 -0
- package/dist/core/worktree.js +554 -0
- package/dist/core/worktree.js.map +1 -0
- package/dist/index.js +146 -5
- package/dist/index.js.map +1 -1
- package/dist/services/error-classifier.d.ts +119 -0
- package/dist/services/error-classifier.d.ts.map +1 -0
- package/dist/services/error-classifier.js +182 -0
- package/dist/services/error-classifier.js.map +1 -0
- package/dist/types/index.d.ts +381 -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 +5 -2
- package/templates/story.md +5 -0
package/dist/core/story.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import matter from 'gray-matter';
|
|
4
|
-
import
|
|
4
|
+
import * as properLockfile from 'proper-lockfile';
|
|
5
|
+
import { FOLDER_TO_STATUS, BLOCKED_DIR, STORIES_FOLDER, STORY_FILENAME, DEFAULT_PRIORITY_GAP, ReviewDecision } from '../types/index.js';
|
|
5
6
|
/**
|
|
6
7
|
* Parse a story markdown file into a Story object
|
|
7
8
|
*
|
|
@@ -37,27 +38,91 @@ export function parseStory(filePath) {
|
|
|
37
38
|
};
|
|
38
39
|
}
|
|
39
40
|
/**
|
|
40
|
-
* Write a story back to disk
|
|
41
|
+
* Write a story back to disk with file locking for atomic updates.
|
|
42
|
+
*
|
|
43
|
+
* This function acquires an exclusive lock before writing to prevent race conditions
|
|
44
|
+
* from concurrent processes. The lock is always released, even if an error occurs.
|
|
45
|
+
*
|
|
46
|
+
* **IMPORTANT:** Do not nest locks on the same file to avoid deadlock. Batch multiple
|
|
47
|
+
* updates into a single writeStory() call instead.
|
|
48
|
+
*
|
|
49
|
+
* @param story - Story object to write
|
|
50
|
+
* @param options - Lock options (timeout, retries, stale threshold)
|
|
51
|
+
* @throws Error if file is locked by another process or filesystem is read-only
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```typescript
|
|
55
|
+
* // Good: Batch updates
|
|
56
|
+
* story.frontmatter.status = 'in-progress';
|
|
57
|
+
* story.frontmatter.priority = 1;
|
|
58
|
+
* await writeStory(story);
|
|
59
|
+
*
|
|
60
|
+
* // Bad: Nested locks (potential deadlock)
|
|
61
|
+
* await writeStory(story); // holds lock
|
|
62
|
+
* await writeStory(story); // tries to acquire same lock = deadlock
|
|
63
|
+
* ```
|
|
41
64
|
*/
|
|
42
|
-
export function writeStory(story) {
|
|
65
|
+
export async function writeStory(story, options) {
|
|
43
66
|
const content = matter.stringify(story.content, story.frontmatter);
|
|
44
|
-
|
|
67
|
+
// For new files (file doesn't exist yet), write directly without locking
|
|
68
|
+
// No race condition possible when creating a new file
|
|
69
|
+
if (!fs.existsSync(story.path)) {
|
|
70
|
+
fs.writeFileSync(story.path, content);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// For existing files, use locking to prevent concurrent write corruption
|
|
74
|
+
const timeout = options?.lockTimeout ?? 5000;
|
|
75
|
+
const retries = options?.retries ?? 3;
|
|
76
|
+
const stale = options?.stale ?? timeout;
|
|
77
|
+
// Acquire lock with retry logic and exponential backoff
|
|
78
|
+
let release;
|
|
79
|
+
try {
|
|
80
|
+
release = await properLockfile.lock(story.path, {
|
|
81
|
+
retries: {
|
|
82
|
+
retries,
|
|
83
|
+
minTimeout: 100,
|
|
84
|
+
maxTimeout: 1000,
|
|
85
|
+
},
|
|
86
|
+
stale,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
// Handle lock-specific errors with actionable messages
|
|
91
|
+
if (error.code === 'ELOCKED') {
|
|
92
|
+
throw new Error(`Story ${story.frontmatter.id || story.path} is locked by another process. ` +
|
|
93
|
+
`Please retry in a moment or check for hung processes.`);
|
|
94
|
+
}
|
|
95
|
+
if (error.code === 'EACCES' || error.code === 'EPERM') {
|
|
96
|
+
throw new Error(`Cannot create lock file: filesystem is read-only or insufficient permissions. ` +
|
|
97
|
+
`Ensure ${path.dirname(story.path)} is writable.`);
|
|
98
|
+
}
|
|
99
|
+
// ESTALE is handled automatically by proper-lockfile (stale lock removed)
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
// Critical section: write story to disk
|
|
104
|
+
fs.writeFileSync(story.path, content);
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
// Always release lock, even on error
|
|
108
|
+
await release();
|
|
109
|
+
}
|
|
45
110
|
}
|
|
46
111
|
/**
|
|
47
112
|
* Update story status in frontmatter without moving files
|
|
48
113
|
* This is the preferred method in the new folder-per-story architecture
|
|
49
114
|
*/
|
|
50
|
-
export function updateStoryStatus(story, newStatus) {
|
|
115
|
+
export async function updateStoryStatus(story, newStatus) {
|
|
51
116
|
story.frontmatter.status = newStatus;
|
|
52
117
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
53
|
-
writeStory(story);
|
|
118
|
+
await writeStory(story);
|
|
54
119
|
return story;
|
|
55
120
|
}
|
|
56
121
|
/**
|
|
57
122
|
* Move a story to a different kanban folder
|
|
58
123
|
* @deprecated Use updateStoryStatus() instead. Will be removed in v2.0
|
|
59
124
|
*/
|
|
60
|
-
export function moveStory(story, toFolder, sdlcRoot) {
|
|
125
|
+
export async function moveStory(story, toFolder, sdlcRoot) {
|
|
61
126
|
console.warn('moveStory() is deprecated. Use updateStoryStatus() instead. Will be removed in v2.0');
|
|
62
127
|
const targetFolder = path.join(sdlcRoot, toFolder);
|
|
63
128
|
// Ensure target folder exists
|
|
@@ -77,7 +142,7 @@ export function moveStory(story, toFolder, sdlcRoot) {
|
|
|
77
142
|
// Write to new location
|
|
78
143
|
const oldPath = story.path;
|
|
79
144
|
story.path = newPath;
|
|
80
|
-
writeStory(story);
|
|
145
|
+
await writeStory(story);
|
|
81
146
|
// Remove old file
|
|
82
147
|
if (fs.existsSync(oldPath) && oldPath !== newPath) {
|
|
83
148
|
fs.unlinkSync(oldPath);
|
|
@@ -91,7 +156,7 @@ export function moveStory(story, toFolder, sdlcRoot) {
|
|
|
91
156
|
* @param storyPath - Absolute path to the story file
|
|
92
157
|
* @param reason - Reason for blocking (e.g., "Max refinement attempts (2/2) reached")
|
|
93
158
|
*/
|
|
94
|
-
export function moveToBlocked(storyPath, reason) {
|
|
159
|
+
export async function moveToBlocked(storyPath, reason) {
|
|
95
160
|
// Security: Validate path BEFORE any file I/O operations
|
|
96
161
|
const resolvedPath = path.resolve(storyPath);
|
|
97
162
|
const storyDir = path.dirname(resolvedPath);
|
|
@@ -113,7 +178,7 @@ export function moveToBlocked(storyPath, reason) {
|
|
|
113
178
|
story.frontmatter.blocked_at = new Date().toISOString();
|
|
114
179
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
115
180
|
// Write back to same location
|
|
116
|
-
writeStory(story);
|
|
181
|
+
await writeStory(story);
|
|
117
182
|
}
|
|
118
183
|
/**
|
|
119
184
|
* Generate a unique story ID in sequential format (S-0001, S-0002, etc.)
|
|
@@ -166,13 +231,86 @@ export function slugify(title) {
|
|
|
166
231
|
.replace(/^-|-$/g, '')
|
|
167
232
|
.substring(0, 50);
|
|
168
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* Sanitize a title string for safe use in file paths and display.
|
|
236
|
+
* Removes dangerous characters that could be used for injection attacks.
|
|
237
|
+
*
|
|
238
|
+
* SECURITY: This function prevents command injection and path traversal through titles.
|
|
239
|
+
*
|
|
240
|
+
* @param title - Title string to sanitize
|
|
241
|
+
* @returns Sanitized title safe for use in paths and commands
|
|
242
|
+
*/
|
|
243
|
+
export function sanitizeTitle(title) {
|
|
244
|
+
if (!title)
|
|
245
|
+
return '';
|
|
246
|
+
let sanitized = title
|
|
247
|
+
// Remove shell metacharacters that could be used for command injection
|
|
248
|
+
.replace(/[`$()\\|&;<>]/g, '')
|
|
249
|
+
// Remove ANSI escape codes (colors, cursor control)
|
|
250
|
+
.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '')
|
|
251
|
+
// Remove OSC sequences (hyperlinks, window titles)
|
|
252
|
+
.replace(/\x1B\][^\x07]*\x07/g, '')
|
|
253
|
+
.replace(/\x1B\][^\x1B]*\x1B\\/g, '')
|
|
254
|
+
// Remove null bytes and control characters
|
|
255
|
+
.replace(/[\x00-\x1F\x7F-\x9F]/g, '');
|
|
256
|
+
// Normalize Unicode to prevent homograph attacks
|
|
257
|
+
sanitized = sanitized.normalize('NFC');
|
|
258
|
+
// Limit length to prevent storage issues
|
|
259
|
+
if (sanitized.length > 200) {
|
|
260
|
+
sanitized = sanitized.substring(0, 200);
|
|
261
|
+
}
|
|
262
|
+
return sanitized.trim();
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Extract title from file content using safe parsing.
|
|
266
|
+
* Priority: YAML frontmatter > H1 heading > null
|
|
267
|
+
*
|
|
268
|
+
* SECURITY: Uses regex-only approach to avoid YAML parser vulnerabilities.
|
|
269
|
+
*
|
|
270
|
+
* @param content - File content to extract title from
|
|
271
|
+
* @returns Extracted title or null if not found
|
|
272
|
+
*/
|
|
273
|
+
export function extractTitleFromContent(content) {
|
|
274
|
+
if (!content || content.trim().length === 0) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
// Try to extract from YAML frontmatter using safe regex (no full YAML parsing)
|
|
278
|
+
// Match: --- at start, then look for title: field, then --- to close
|
|
279
|
+
const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/m);
|
|
280
|
+
if (frontmatterMatch) {
|
|
281
|
+
// Look for title: field in the frontmatter block
|
|
282
|
+
const titleMatch = frontmatterMatch[1].match(/^title:\s*['"]?([^'"\n]+?)['"]?\s*$/m);
|
|
283
|
+
if (titleMatch && titleMatch[1]) {
|
|
284
|
+
const title = titleMatch[1].trim();
|
|
285
|
+
if (title.length > 0) {
|
|
286
|
+
return sanitizeTitle(title);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Fall back to first H1 heading (# Title)
|
|
291
|
+
// Use non-greedy matching with length limit to prevent ReDoS
|
|
292
|
+
const h1Match = content.match(/^#\s+(.{1,200}?)$/m);
|
|
293
|
+
if (h1Match && h1Match[1]) {
|
|
294
|
+
const title = h1Match[1].trim();
|
|
295
|
+
if (title.length > 0) {
|
|
296
|
+
return sanitizeTitle(title);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// No title found
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
169
302
|
/**
|
|
170
303
|
* Create a new story in the folder-per-story structure
|
|
171
304
|
*
|
|
172
305
|
* Creates stories/{id}/story.md with slug and priority in frontmatter.
|
|
173
306
|
* Priority uses gaps (10, 20, 30...) for easy insertion without renumbering.
|
|
307
|
+
*
|
|
308
|
+
* @param title - Story title
|
|
309
|
+
* @param sdlcRoot - Root path of .ai-sdlc folder
|
|
310
|
+
* @param options - Optional frontmatter fields
|
|
311
|
+
* @param content - Optional custom story content (if not provided, uses default template)
|
|
174
312
|
*/
|
|
175
|
-
export function createStory(title, sdlcRoot, options = {}) {
|
|
313
|
+
export async function createStory(title, sdlcRoot, options = {}, content) {
|
|
176
314
|
const storiesFolder = path.join(sdlcRoot, STORIES_FOLDER);
|
|
177
315
|
// Validate parent stories/ directory exists
|
|
178
316
|
if (!fs.existsSync(storiesFolder)) {
|
|
@@ -228,9 +366,22 @@ export function createStory(title, sdlcRoot, options = {}) {
|
|
|
228
366
|
plan_complete: false,
|
|
229
367
|
implementation_complete: false,
|
|
230
368
|
reviews_complete: false,
|
|
369
|
+
// Default content_type to 'code' for backward compatibility
|
|
370
|
+
// Stories that don't modify src/ should explicitly set content_type: 'configuration' or 'documentation'
|
|
371
|
+
content_type: 'code',
|
|
231
372
|
...options,
|
|
232
373
|
};
|
|
233
|
-
|
|
374
|
+
// Use custom content if provided, otherwise use default template
|
|
375
|
+
let storyContent;
|
|
376
|
+
if (content) {
|
|
377
|
+
// Security: Strip dangerous HTML tags from custom content
|
|
378
|
+
storyContent = content
|
|
379
|
+
.replace(/<script[^>]*>.*?<\/script>/gis, '')
|
|
380
|
+
.replace(/<iframe[^>]*>.*?<\/iframe>/gis, '');
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
// Default template
|
|
384
|
+
storyContent = `# ${title}
|
|
234
385
|
|
|
235
386
|
## Summary
|
|
236
387
|
|
|
@@ -251,13 +402,14 @@ export function createStory(title, sdlcRoot, options = {}) {
|
|
|
251
402
|
## Review Notes
|
|
252
403
|
|
|
253
404
|
<!-- Populated by review agents -->`;
|
|
405
|
+
}
|
|
254
406
|
const story = {
|
|
255
407
|
path: filePath,
|
|
256
408
|
slug,
|
|
257
409
|
frontmatter,
|
|
258
|
-
content,
|
|
410
|
+
content: storyContent,
|
|
259
411
|
};
|
|
260
|
-
writeStory(story);
|
|
412
|
+
await writeStory(story);
|
|
261
413
|
// Return story with canonical path for consistency
|
|
262
414
|
const canonicalPath = fs.realpathSync(filePath);
|
|
263
415
|
return { ...story, path: canonicalPath };
|
|
@@ -265,16 +417,16 @@ export function createStory(title, sdlcRoot, options = {}) {
|
|
|
265
417
|
/**
|
|
266
418
|
* Update story frontmatter field
|
|
267
419
|
*/
|
|
268
|
-
export function updateStoryField(story, field, value) {
|
|
420
|
+
export async function updateStoryField(story, field, value) {
|
|
269
421
|
story.frontmatter[field] = value;
|
|
270
422
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
271
|
-
writeStory(story);
|
|
423
|
+
await writeStory(story);
|
|
272
424
|
return story;
|
|
273
425
|
}
|
|
274
426
|
/**
|
|
275
427
|
* Append content to a section in the story
|
|
276
428
|
*/
|
|
277
|
-
export function appendToSection(story, section, content) {
|
|
429
|
+
export async function appendToSection(story, section, content) {
|
|
278
430
|
const sectionHeader = `## ${section}`;
|
|
279
431
|
const sectionIndex = story.content.indexOf(sectionHeader);
|
|
280
432
|
if (sectionIndex === -1) {
|
|
@@ -297,13 +449,13 @@ export function appendToSection(story, section, content) {
|
|
|
297
449
|
story.content.substring(insertPoint);
|
|
298
450
|
}
|
|
299
451
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
300
|
-
writeStory(story);
|
|
452
|
+
await writeStory(story);
|
|
301
453
|
return story;
|
|
302
454
|
}
|
|
303
455
|
/**
|
|
304
456
|
* Record a refinement attempt in the story's frontmatter
|
|
305
457
|
*/
|
|
306
|
-
export function recordRefinementAttempt(story, agentType, reviewFeedback) {
|
|
458
|
+
export async function recordRefinementAttempt(story, agentType, reviewFeedback) {
|
|
307
459
|
// Initialize refinement tracking if not present
|
|
308
460
|
if (!story.frontmatter.refinement_iterations) {
|
|
309
461
|
story.frontmatter.refinement_iterations = [];
|
|
@@ -320,7 +472,7 @@ export function recordRefinementAttempt(story, agentType, reviewFeedback) {
|
|
|
320
472
|
story.frontmatter.refinement_iterations.push(refinementRecord);
|
|
321
473
|
story.frontmatter.refinement_count = iteration;
|
|
322
474
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
323
|
-
writeStory(story);
|
|
475
|
+
await writeStory(story);
|
|
324
476
|
return story;
|
|
325
477
|
}
|
|
326
478
|
/**
|
|
@@ -341,7 +493,7 @@ export function canRetryRefinement(story, maxAttempts) {
|
|
|
341
493
|
/**
|
|
342
494
|
* Reset phase completion flags for rework
|
|
343
495
|
*/
|
|
344
|
-
export function resetPhaseCompletion(story, phase) {
|
|
496
|
+
export async function resetPhaseCompletion(story, phase) {
|
|
345
497
|
switch (phase) {
|
|
346
498
|
case 'research':
|
|
347
499
|
story.frontmatter.research_complete = false;
|
|
@@ -354,7 +506,7 @@ export function resetPhaseCompletion(story, phase) {
|
|
|
354
506
|
break;
|
|
355
507
|
}
|
|
356
508
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
357
|
-
writeStory(story);
|
|
509
|
+
await writeStory(story);
|
|
358
510
|
return story;
|
|
359
511
|
}
|
|
360
512
|
/**
|
|
@@ -372,9 +524,9 @@ export function getLatestReviewFeedback(story) {
|
|
|
372
524
|
/**
|
|
373
525
|
* Append refinement feedback to the story content
|
|
374
526
|
*/
|
|
375
|
-
export function appendRefinementNote(story, iteration, feedback) {
|
|
527
|
+
export async function appendRefinementNote(story, iteration, feedback) {
|
|
376
528
|
const refinementNote = `### Refinement Iteration ${iteration}\n\n${feedback}`;
|
|
377
|
-
return appendToSection(story, 'Review Notes', refinementNote);
|
|
529
|
+
return await appendToSection(story, 'Review Notes', refinementNote);
|
|
378
530
|
}
|
|
379
531
|
/**
|
|
380
532
|
* Get the effective maximum retries for a story (story-specific or config default)
|
|
@@ -403,18 +555,18 @@ export function isAtMaxRetries(story, config, maxIterationsOverride) {
|
|
|
403
555
|
/**
|
|
404
556
|
* Increment the retry count for a story
|
|
405
557
|
*/
|
|
406
|
-
export function incrementRetryCount(story) {
|
|
558
|
+
export async function incrementRetryCount(story) {
|
|
407
559
|
const currentCount = story.frontmatter.retry_count || 0;
|
|
408
560
|
story.frontmatter.retry_count = currentCount + 1;
|
|
409
561
|
story.frontmatter.last_restart_timestamp = new Date().toISOString();
|
|
410
562
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
411
|
-
writeStory(story);
|
|
563
|
+
await writeStory(story);
|
|
412
564
|
return story;
|
|
413
565
|
}
|
|
414
566
|
/**
|
|
415
567
|
* Reset RPIV cycle for a story (keep research, reset plan/implementation/reviews)
|
|
416
568
|
*/
|
|
417
|
-
export function resetRPIVCycle(story, reason) {
|
|
569
|
+
export async function resetRPIVCycle(story, reason) {
|
|
418
570
|
// Keep research_complete as true, reset other flags
|
|
419
571
|
story.frontmatter.plan_complete = false;
|
|
420
572
|
story.frontmatter.implementation_complete = false;
|
|
@@ -425,13 +577,13 @@ export function resetRPIVCycle(story, reason) {
|
|
|
425
577
|
// Increment retry count
|
|
426
578
|
const currentCount = story.frontmatter.retry_count || 0;
|
|
427
579
|
story.frontmatter.retry_count = currentCount + 1;
|
|
428
|
-
writeStory(story);
|
|
580
|
+
await writeStory(story);
|
|
429
581
|
return story;
|
|
430
582
|
}
|
|
431
583
|
/**
|
|
432
584
|
* Append a review attempt to the story's review history
|
|
433
585
|
*/
|
|
434
|
-
export function appendReviewHistory(story, attempt) {
|
|
586
|
+
export async function appendReviewHistory(story, attempt) {
|
|
435
587
|
if (!story.frontmatter.review_history) {
|
|
436
588
|
story.frontmatter.review_history = [];
|
|
437
589
|
}
|
|
@@ -442,7 +594,7 @@ export function appendReviewHistory(story, attempt) {
|
|
|
442
594
|
story.frontmatter.review_history = story.frontmatter.review_history.slice(-10);
|
|
443
595
|
}
|
|
444
596
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
445
|
-
writeStory(story);
|
|
597
|
+
await writeStory(story);
|
|
446
598
|
return story;
|
|
447
599
|
}
|
|
448
600
|
/**
|
|
@@ -457,23 +609,55 @@ export function getLatestReviewAttempt(story) {
|
|
|
457
609
|
/**
|
|
458
610
|
* Mark a story as complete (all workflow flags set to true)
|
|
459
611
|
*/
|
|
460
|
-
export function markStoryComplete(story) {
|
|
612
|
+
export async function markStoryComplete(story) {
|
|
461
613
|
story.frontmatter.research_complete = true;
|
|
462
614
|
story.frontmatter.plan_complete = true;
|
|
463
615
|
story.frontmatter.implementation_complete = true;
|
|
464
616
|
story.frontmatter.reviews_complete = true;
|
|
465
617
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
466
|
-
writeStory(story);
|
|
618
|
+
await writeStory(story);
|
|
467
619
|
return story;
|
|
468
620
|
}
|
|
621
|
+
/**
|
|
622
|
+
* Auto-complete story after review approval
|
|
623
|
+
* Handles marking story as complete and transitioning to done status
|
|
624
|
+
*
|
|
625
|
+
* @param story - The story to auto-complete
|
|
626
|
+
* @param config - The configuration containing reviewConfig settings
|
|
627
|
+
* @param reviewResult - The result from the review agent
|
|
628
|
+
* @returns Updated story if auto-completion occurred, original story otherwise
|
|
629
|
+
*/
|
|
630
|
+
export async function autoCompleteStoryAfterReview(story, config, reviewResult) {
|
|
631
|
+
// Only auto-complete if review was approved and config allows it
|
|
632
|
+
if (reviewResult.decision !== ReviewDecision.APPROVED) {
|
|
633
|
+
return story;
|
|
634
|
+
}
|
|
635
|
+
if (!config.reviewConfig.autoCompleteOnApproval) {
|
|
636
|
+
return story;
|
|
637
|
+
}
|
|
638
|
+
try {
|
|
639
|
+
// Mark all workflow flags as complete
|
|
640
|
+
story = await markStoryComplete(story);
|
|
641
|
+
// Update status to done if currently in-progress
|
|
642
|
+
if (story.frontmatter.status === 'in-progress') {
|
|
643
|
+
story = await updateStoryStatus(story, 'done');
|
|
644
|
+
}
|
|
645
|
+
return story;
|
|
646
|
+
}
|
|
647
|
+
catch (error) {
|
|
648
|
+
// Log error but don't fail the entire review operation
|
|
649
|
+
console.error('Failed to auto-complete story after review:', error);
|
|
650
|
+
return story;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
469
653
|
/**
|
|
470
654
|
* Snapshot max_retries from config to story frontmatter (for mid-cycle config change protection)
|
|
471
655
|
*/
|
|
472
|
-
export function snapshotMaxRetries(story, config) {
|
|
656
|
+
export async function snapshotMaxRetries(story, config) {
|
|
473
657
|
if (story.frontmatter.max_retries === undefined) {
|
|
474
658
|
story.frontmatter.max_retries = config.reviewConfig.maxRetries;
|
|
475
659
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
476
|
-
writeStory(story);
|
|
660
|
+
await writeStory(story);
|
|
477
661
|
}
|
|
478
662
|
return story;
|
|
479
663
|
}
|
|
@@ -517,22 +701,91 @@ export function isAtMaxImplementationRetries(story, config) {
|
|
|
517
701
|
/**
|
|
518
702
|
* Reset implementation retry count to 0
|
|
519
703
|
*/
|
|
520
|
-
export function resetImplementationRetryCount(story) {
|
|
704
|
+
export async function resetImplementationRetryCount(story) {
|
|
521
705
|
story.frontmatter.implementation_retry_count = 0;
|
|
522
706
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
523
|
-
writeStory(story);
|
|
707
|
+
await writeStory(story);
|
|
524
708
|
return story;
|
|
525
709
|
}
|
|
526
710
|
/**
|
|
527
711
|
* Increment the implementation retry count for a story
|
|
528
712
|
*/
|
|
529
|
-
export function incrementImplementationRetryCount(story) {
|
|
713
|
+
export async function incrementImplementationRetryCount(story) {
|
|
530
714
|
const currentCount = story.frontmatter.implementation_retry_count || 0;
|
|
531
715
|
story.frontmatter.implementation_retry_count = currentCount + 1;
|
|
532
716
|
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
533
|
-
writeStory(story);
|
|
717
|
+
await writeStory(story);
|
|
718
|
+
return story;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Global recovery circuit breaker limit
|
|
722
|
+
*/
|
|
723
|
+
const GLOBAL_RECOVERY_LIMIT = 10;
|
|
724
|
+
/**
|
|
725
|
+
* Get the current total recovery attempts for a story
|
|
726
|
+
*/
|
|
727
|
+
export function getTotalRecoveryAttempts(story) {
|
|
728
|
+
return story.frontmatter.total_recovery_attempts || 0;
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Check if a story has reached the global recovery limit
|
|
732
|
+
*/
|
|
733
|
+
export function isAtGlobalRecoveryLimit(story) {
|
|
734
|
+
const currentAttempts = getTotalRecoveryAttempts(story);
|
|
735
|
+
return currentAttempts >= GLOBAL_RECOVERY_LIMIT;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Increment the total recovery attempts counter for a story
|
|
739
|
+
*/
|
|
740
|
+
export async function incrementTotalRecoveryAttempts(story) {
|
|
741
|
+
const currentCount = story.frontmatter.total_recovery_attempts || 0;
|
|
742
|
+
story.frontmatter.total_recovery_attempts = currentCount + 1;
|
|
743
|
+
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
744
|
+
await writeStory(story);
|
|
745
|
+
return story;
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Reset the total recovery attempts counter to 0
|
|
749
|
+
*/
|
|
750
|
+
export async function resetTotalRecoveryAttempts(story) {
|
|
751
|
+
story.frontmatter.total_recovery_attempts = 0;
|
|
752
|
+
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
753
|
+
await writeStory(story);
|
|
534
754
|
return story;
|
|
535
755
|
}
|
|
756
|
+
/**
|
|
757
|
+
* Sanitize story ID for safe path construction.
|
|
758
|
+
* Prevents path traversal attacks by rejecting dangerous characters.
|
|
759
|
+
*
|
|
760
|
+
* SECURITY: This function is CRITICAL for preventing path traversal vulnerabilities.
|
|
761
|
+
* Use this before constructing ANY file paths with user-provided story IDs.
|
|
762
|
+
*
|
|
763
|
+
* @param storyId - Story ID to sanitize (e.g., 'S-0001')
|
|
764
|
+
* @returns Sanitized story ID safe for path construction
|
|
765
|
+
* @throws Error if storyId contains dangerous characters or patterns
|
|
766
|
+
*/
|
|
767
|
+
export function sanitizeStoryId(storyId) {
|
|
768
|
+
if (!storyId) {
|
|
769
|
+
throw new Error('Story ID cannot be empty');
|
|
770
|
+
}
|
|
771
|
+
// Reject path traversal attempts
|
|
772
|
+
if (storyId.includes('..')) {
|
|
773
|
+
throw new Error('Invalid story ID: contains path traversal sequence (..)');
|
|
774
|
+
}
|
|
775
|
+
// Reject path separators
|
|
776
|
+
if (storyId.includes('/') || storyId.includes('\\')) {
|
|
777
|
+
throw new Error('Invalid story ID: contains path separator');
|
|
778
|
+
}
|
|
779
|
+
// Reject absolute paths
|
|
780
|
+
if (path.isAbsolute(storyId)) {
|
|
781
|
+
throw new Error('Invalid story ID: cannot be an absolute path');
|
|
782
|
+
}
|
|
783
|
+
// Reject control characters and other dangerous characters
|
|
784
|
+
if (/[\x00-\x1F\x7F]/.test(storyId)) {
|
|
785
|
+
throw new Error('Invalid story ID: contains control characters');
|
|
786
|
+
}
|
|
787
|
+
return storyId;
|
|
788
|
+
}
|
|
536
789
|
/**
|
|
537
790
|
* Sanitize user-controlled text for safe display and storage.
|
|
538
791
|
* Removes ANSI escape sequences, control characters, and potential injection vectors.
|
|
@@ -675,6 +928,40 @@ export function getStory(sdlcRoot, storyId) {
|
|
|
675
928
|
}
|
|
676
929
|
return story;
|
|
677
930
|
}
|
|
931
|
+
/**
|
|
932
|
+
* Reset workflow state for a story (clear worktree metadata and set status based on completion flags)
|
|
933
|
+
* Used when cleaning up an existing worktree and restarting from scratch
|
|
934
|
+
*
|
|
935
|
+
* @param story - Story to reset
|
|
936
|
+
* @returns Updated story with cleared worktree metadata and reset status
|
|
937
|
+
*/
|
|
938
|
+
export async function resetWorkflowState(story) {
|
|
939
|
+
// Clear worktree metadata
|
|
940
|
+
delete story.frontmatter.worktree_path;
|
|
941
|
+
delete story.frontmatter.branch;
|
|
942
|
+
// Determine appropriate status based on completion flags
|
|
943
|
+
if (story.frontmatter.implementation_complete) {
|
|
944
|
+
// Implementation complete - ready for review
|
|
945
|
+
story.frontmatter.status = 'ready';
|
|
946
|
+
}
|
|
947
|
+
else if (story.frontmatter.plan_complete) {
|
|
948
|
+
// Plan complete - ready for implementation
|
|
949
|
+
story.frontmatter.status = 'ready';
|
|
950
|
+
}
|
|
951
|
+
else if (story.frontmatter.research_complete) {
|
|
952
|
+
// Only research complete - back to backlog
|
|
953
|
+
story.frontmatter.status = 'backlog';
|
|
954
|
+
}
|
|
955
|
+
else {
|
|
956
|
+
// No phases complete - back to backlog
|
|
957
|
+
story.frontmatter.status = 'backlog';
|
|
958
|
+
}
|
|
959
|
+
// Update timestamp
|
|
960
|
+
story.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
961
|
+
// Write changes to disk
|
|
962
|
+
await writeStory(story);
|
|
963
|
+
return story;
|
|
964
|
+
}
|
|
678
965
|
/**
|
|
679
966
|
* Unblock a story and set status back to in-progress
|
|
680
967
|
* In the new architecture, this only updates frontmatter - file path remains unchanged
|
|
@@ -684,7 +971,7 @@ export function getStory(sdlcRoot, storyId) {
|
|
|
684
971
|
* @param options - Optional configuration { resetRetries?: boolean }
|
|
685
972
|
* @returns The unblocked story
|
|
686
973
|
*/
|
|
687
|
-
export function unblockStory(storyId, sdlcRoot, options) {
|
|
974
|
+
export async function unblockStory(storyId, sdlcRoot, options) {
|
|
688
975
|
// Use the centralized getStory() function for lookup
|
|
689
976
|
const foundStory = findStoryById(sdlcRoot, storyId);
|
|
690
977
|
if (!foundStory) {
|
|
@@ -701,6 +988,7 @@ export function unblockStory(storyId, sdlcRoot, options) {
|
|
|
701
988
|
if (options?.resetRetries) {
|
|
702
989
|
foundStory.frontmatter.retry_count = 0;
|
|
703
990
|
foundStory.frontmatter.refinement_count = 0;
|
|
991
|
+
foundStory.frontmatter.total_recovery_attempts = 0;
|
|
704
992
|
}
|
|
705
993
|
// Determine appropriate status based on completion flags
|
|
706
994
|
let newStatus;
|
|
@@ -719,7 +1007,7 @@ export function unblockStory(storyId, sdlcRoot, options) {
|
|
|
719
1007
|
foundStory.frontmatter.status = newStatus;
|
|
720
1008
|
foundStory.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
721
1009
|
// Write back to same location
|
|
722
|
-
writeStory(foundStory);
|
|
1010
|
+
await writeStory(foundStory);
|
|
723
1011
|
return foundStory;
|
|
724
1012
|
}
|
|
725
1013
|
//# sourceMappingURL=story.js.map
|