ai-sdlc 0.2.0-alpha.9 → 0.2.0
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 +6 -0
- package/dist/agents/implementation.d.ts.map +1 -1
- package/dist/agents/implementation.js +87 -13
- package/dist/agents/implementation.js.map +1 -1
- package/dist/agents/planning.d.ts.map +1 -1
- package/dist/agents/planning.js +22 -3
- 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 +67 -2
- package/dist/agents/review.d.ts.map +1 -1
- package/dist/agents/review.js +477 -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/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 +43 -3
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +588 -150
- package/dist/cli/commands.js.map +1 -1
- package/dist/cli/daemon.d.ts.map +1 -1
- package/dist/cli/daemon.js +20 -3
- package/dist/cli/daemon.js.map +1 -1
- package/dist/cli/runner.d.ts.map +1 -1
- package/dist/cli/runner.js +18 -6
- 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 +5 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +27 -0
- 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 +10 -1
- package/dist/core/git-utils.d.ts.map +1 -1
- package/dist/core/git-utils.js +55 -4
- package/dist/core/git-utils.js.map +1 -1
- package/dist/core/index.d.ts +17 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +17 -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/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 +79 -20
- package/dist/core/story.d.ts.map +1 -1
- package/dist/core/story.js +221 -39
- 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 +201 -12
- package/dist/core/workflow-state.js.map +1 -1
- package/dist/core/worktree.d.ts +9 -0
- package/dist/core/worktree.d.ts.map +1 -1
- package/dist/core/worktree.js +52 -1
- package/dist/core/worktree.js.map +1 -1
- package/dist/index.js +112 -6
- package/dist/index.js.map +1 -1
- package/dist/types/index.d.ts +123 -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/cli/commands.js
CHANGED
|
@@ -3,8 +3,8 @@ import fs from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import * as readline from 'readline';
|
|
5
5
|
import { getSdlcRoot, loadConfig, initConfig, validateWorktreeBasePath, DEFAULT_WORKTREE_CONFIG } from '../core/config.js';
|
|
6
|
-
import { initializeKanban, kanbanExists, assessState, getBoardStats, findStoryBySlug } from '../core/kanban.js';
|
|
7
|
-
import { createStory, parseStory, resetRPIVCycle, isAtMaxRetries, unblockStory, getStory, findStoryById, updateStoryField, writeStory } from '../core/story.js';
|
|
6
|
+
import { initializeKanban, kanbanExists, assessState, getBoardStats, findStoryBySlug, findStoriesByStatus } from '../core/kanban.js';
|
|
7
|
+
import { createStory, parseStory, resetRPIVCycle, isAtMaxRetries, unblockStory, getStory, findStoryById, updateStoryField, writeStory, sanitizeStoryId } from '../core/story.js';
|
|
8
8
|
import { GitWorktreeService } from '../core/worktree.js';
|
|
9
9
|
import { ReviewDecision } from '../types/index.js';
|
|
10
10
|
import { getThemedChalk } from '../core/theme.js';
|
|
@@ -15,6 +15,9 @@ import { migrateToFolderPerStory } from './commands/migrate.js';
|
|
|
15
15
|
import { generateReviewSummary } from '../agents/review.js';
|
|
16
16
|
import { getTerminalWidth } from './formatting.js';
|
|
17
17
|
import { validateGitState } from '../core/git-utils.js';
|
|
18
|
+
import { StoryLogger } from '../core/story-logger.js';
|
|
19
|
+
import { detectConflicts } from '../core/conflict-detector.js';
|
|
20
|
+
import { getLogger } from '../core/logger.js';
|
|
18
21
|
/**
|
|
19
22
|
* Initialize the .ai-sdlc folder structure
|
|
20
23
|
*/
|
|
@@ -52,7 +55,7 @@ export async function status(options) {
|
|
|
52
55
|
console.log(c.warning('ai-sdlc not initialized. Run `ai-sdlc init` first.'));
|
|
53
56
|
return;
|
|
54
57
|
}
|
|
55
|
-
const assessment = assessState(sdlcRoot);
|
|
58
|
+
const assessment = await assessState(sdlcRoot);
|
|
56
59
|
const stats = getBoardStats(sdlcRoot);
|
|
57
60
|
console.log();
|
|
58
61
|
console.log(c.bold('═══ AI SDLC Board ═══'));
|
|
@@ -117,10 +120,58 @@ export async function status(options) {
|
|
|
117
120
|
console.log(c.success('No pending actions. Board is up to date!'));
|
|
118
121
|
}
|
|
119
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Validate file path for security (path traversal, symlinks, allowed directories)
|
|
125
|
+
*/
|
|
126
|
+
function validateFilePath(filePath) {
|
|
127
|
+
const resolvedPath = path.resolve(filePath);
|
|
128
|
+
const allowedDir = path.resolve(process.cwd());
|
|
129
|
+
// Check path traversal: resolved path must be within current directory
|
|
130
|
+
if (!resolvedPath.startsWith(allowedDir + path.sep) && resolvedPath !== allowedDir) {
|
|
131
|
+
throw new Error('Security: File path must be within current directory (path traversal detected)');
|
|
132
|
+
}
|
|
133
|
+
// Check if file exists before checking if it's a symlink
|
|
134
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
135
|
+
throw new Error(`File not found: ${path.basename(filePath)}`);
|
|
136
|
+
}
|
|
137
|
+
// Check for symbolic links (security risk)
|
|
138
|
+
const stats = fs.lstatSync(resolvedPath);
|
|
139
|
+
if (stats.isSymbolicLink()) {
|
|
140
|
+
throw new Error('Security: Symbolic links are not allowed');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Validate file extension against whitelist
|
|
145
|
+
*/
|
|
146
|
+
function validateFileExtension(filePath) {
|
|
147
|
+
const allowedExtensions = ['.md', '.txt', '.markdown'];
|
|
148
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
149
|
+
if (!allowedExtensions.includes(ext)) {
|
|
150
|
+
throw new Error(`Invalid file type: only ${allowedExtensions.join(', ')} files are allowed`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Validate file size (10MB maximum)
|
|
155
|
+
*/
|
|
156
|
+
function validateFileSize(filePath) {
|
|
157
|
+
const maxSize = 10 * 1024 * 1024; // 10MB
|
|
158
|
+
const stats = fs.statSync(filePath);
|
|
159
|
+
if (stats.size > maxSize) {
|
|
160
|
+
const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
|
|
161
|
+
throw new Error(`File too large: ${sizeMB}MB (maximum 10MB)`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Sanitize file content (strip null bytes, validate UTF-8)
|
|
166
|
+
*/
|
|
167
|
+
function sanitizeFileContent(content) {
|
|
168
|
+
// Strip null bytes that could truncate strings
|
|
169
|
+
return content.replace(/\0/g, '');
|
|
170
|
+
}
|
|
120
171
|
/**
|
|
121
172
|
* Add a new story to the backlog
|
|
122
173
|
*/
|
|
123
|
-
export async function add(title) {
|
|
174
|
+
export async function add(title, options) {
|
|
124
175
|
const spinner = ora('Creating story...').start();
|
|
125
176
|
try {
|
|
126
177
|
const config = loadConfig();
|
|
@@ -130,10 +181,83 @@ export async function add(title) {
|
|
|
130
181
|
spinner.fail('ai-sdlc not initialized. Run `ai-sdlc init` first.');
|
|
131
182
|
return;
|
|
132
183
|
}
|
|
133
|
-
|
|
184
|
+
// Validate that either title or file is provided (not both, not neither)
|
|
185
|
+
if (!title && !options?.file) {
|
|
186
|
+
spinner.fail('Error: Must provide either a title or --file option');
|
|
187
|
+
console.log(c.dim('Usage:'));
|
|
188
|
+
console.log(c.dim(' ai-sdlc add "Story Title"'));
|
|
189
|
+
console.log(c.dim(' ai-sdlc add --file story.md'));
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
if (title && options?.file) {
|
|
193
|
+
spinner.fail('Error: Cannot provide both title and --file option');
|
|
194
|
+
console.log(c.dim('Use either:'));
|
|
195
|
+
console.log(c.dim(' ai-sdlc add "Story Title"'));
|
|
196
|
+
console.log(c.dim(' ai-sdlc add --file story.md'));
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
let storyTitle;
|
|
200
|
+
let storyContent;
|
|
201
|
+
// Handle file input with security validation
|
|
202
|
+
if (options?.file) {
|
|
203
|
+
spinner.text = 'Reading file...';
|
|
204
|
+
const filePath = options.file;
|
|
205
|
+
try {
|
|
206
|
+
// Security validations
|
|
207
|
+
validateFilePath(filePath);
|
|
208
|
+
validateFileExtension(filePath);
|
|
209
|
+
// Read file (includes existence check via fs.readFileSync)
|
|
210
|
+
const resolvedPath = path.resolve(filePath);
|
|
211
|
+
// Validate file size before reading
|
|
212
|
+
validateFileSize(resolvedPath);
|
|
213
|
+
// Read and sanitize content
|
|
214
|
+
const rawContent = fs.readFileSync(resolvedPath, 'utf-8');
|
|
215
|
+
storyContent = sanitizeFileContent(rawContent);
|
|
216
|
+
// Extract title from content or use filename
|
|
217
|
+
const { extractTitleFromContent } = await import('../core/story.js');
|
|
218
|
+
const extractedTitle = extractTitleFromContent(storyContent);
|
|
219
|
+
if (extractedTitle) {
|
|
220
|
+
storyTitle = extractedTitle;
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
// Fall back to filename without extension
|
|
224
|
+
storyTitle = path.basename(filePath, path.extname(filePath));
|
|
225
|
+
}
|
|
226
|
+
spinner.text = `Creating story from ${path.basename(filePath)}...`;
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
spinner.fail('Failed to read file');
|
|
230
|
+
if (error instanceof Error) {
|
|
231
|
+
// Sanitize error messages to avoid leaking system paths
|
|
232
|
+
if (error.message.startsWith('Security:') || error.message.startsWith('Invalid file type:') || error.message.startsWith('File too large:')) {
|
|
233
|
+
console.log(c.error(error.message));
|
|
234
|
+
}
|
|
235
|
+
else if (error.message.includes('ENOENT')) {
|
|
236
|
+
console.log(c.error(`File not found: ${path.basename(filePath)}`));
|
|
237
|
+
}
|
|
238
|
+
else if (error.message.includes('EACCES') || error.message.includes('EPERM')) {
|
|
239
|
+
console.log(c.error(`Permission denied: ${path.basename(filePath)}`));
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
console.log(c.error(`Unable to read file: ${path.basename(filePath)}`));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
// Traditional title-only input
|
|
250
|
+
storyTitle = title;
|
|
251
|
+
}
|
|
252
|
+
// Create the story
|
|
253
|
+
const story = await createStory(storyTitle, sdlcRoot, {}, storyContent);
|
|
134
254
|
spinner.succeed(c.success(`Created: ${story.path}`));
|
|
135
255
|
console.log(c.dim(` ID: ${story.frontmatter.id}`));
|
|
256
|
+
console.log(c.dim(` Title: ${story.frontmatter.title}`));
|
|
136
257
|
console.log(c.dim(` Slug: ${story.slug}`));
|
|
258
|
+
if (options?.file) {
|
|
259
|
+
console.log(c.dim(` Source: ${path.basename(options.file)}`));
|
|
260
|
+
}
|
|
137
261
|
console.log();
|
|
138
262
|
console.log(c.info('Next step:'), `ai-sdlc run`);
|
|
139
263
|
}
|
|
@@ -218,6 +342,23 @@ const GIT_MODIFYING_ACTIONS = ['implement', 'review', 'create_pr'];
|
|
|
218
342
|
function requiresGitValidation(actions) {
|
|
219
343
|
return actions.some(action => GIT_MODIFYING_ACTIONS.includes(action.type));
|
|
220
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* Determine if worktree mode should be used based on CLI flags, story frontmatter, and config.
|
|
347
|
+
* Priority order:
|
|
348
|
+
* 1. CLI --no-worktree flag (explicit disable)
|
|
349
|
+
* 2. CLI --worktree flag (explicit enable)
|
|
350
|
+
* 3. Story frontmatter.worktree_path exists (auto-enable for resuming)
|
|
351
|
+
* 4. Config worktree.enabled (default behavior)
|
|
352
|
+
*/
|
|
353
|
+
export function determineWorktreeMode(options, worktreeConfig, targetStory) {
|
|
354
|
+
if (options.worktree === false)
|
|
355
|
+
return false;
|
|
356
|
+
if (options.worktree === true)
|
|
357
|
+
return true;
|
|
358
|
+
if (targetStory?.frontmatter.worktree_path)
|
|
359
|
+
return true;
|
|
360
|
+
return worktreeConfig.enabled;
|
|
361
|
+
}
|
|
221
362
|
/**
|
|
222
363
|
* Display git validation errors and warnings
|
|
223
364
|
*/
|
|
@@ -239,6 +380,160 @@ function displayGitValidationResult(result, c) {
|
|
|
239
380
|
}
|
|
240
381
|
}
|
|
241
382
|
}
|
|
383
|
+
// ANSI escape sequence patterns for sanitization
|
|
384
|
+
const ANSI_CSI_PATTERN = /\x1B\[[0-9;]*[a-zA-Z]/g;
|
|
385
|
+
const ANSI_OSC_BEL_PATTERN = /\x1B\][^\x07]*\x07/g;
|
|
386
|
+
const ANSI_OSC_ESC_PATTERN = /\x1B\][^\x1B]*\x1B\\/g;
|
|
387
|
+
const ANSI_SINGLE_CHAR_PATTERN = /\x1B./g;
|
|
388
|
+
const CONTROL_CHARS_PATTERN = /[\x00-\x1F\x7F-\x9F]/g;
|
|
389
|
+
/**
|
|
390
|
+
* Sanitize a string for safe display in the terminal.
|
|
391
|
+
* Strips ANSI escape sequences (CSI, OSC, single-char), control characters,
|
|
392
|
+
* and truncates extremely long strings to prevent DoS attacks.
|
|
393
|
+
*
|
|
394
|
+
* This uses the same comprehensive ANSI stripping patterns as sanitizeReasonText
|
|
395
|
+
* from src/core/story.ts for consistency.
|
|
396
|
+
*
|
|
397
|
+
* @param str - The string to sanitize
|
|
398
|
+
* @returns Sanitized string safe for terminal display (max 500 chars)
|
|
399
|
+
*/
|
|
400
|
+
function sanitizeForDisplay(str) {
|
|
401
|
+
const cleaned = str
|
|
402
|
+
.replace(ANSI_CSI_PATTERN, '') // CSI sequences (e.g., \x1B[31m)
|
|
403
|
+
.replace(ANSI_OSC_BEL_PATTERN, '') // OSC with BEL terminator (e.g., \x1B]...\x07)
|
|
404
|
+
.replace(ANSI_OSC_ESC_PATTERN, '') // OSC with ESC\ terminator (e.g., \x1B]...\x1B\\)
|
|
405
|
+
.replace(ANSI_SINGLE_CHAR_PATTERN, '') // Single-char escapes (e.g., \x1BH)
|
|
406
|
+
.replace(CONTROL_CHARS_PATTERN, ''); // Control characters (0x00-0x1F, 0x7F-0x9F)
|
|
407
|
+
// Truncate extremely long strings (DoS protection)
|
|
408
|
+
return cleaned.length > 500 ? cleaned.slice(0, 497) + '...' : cleaned;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Perform pre-flight conflict check before starting work on a story in a worktree.
|
|
412
|
+
* Warns about potential file conflicts with active stories and prompts for confirmation.
|
|
413
|
+
*
|
|
414
|
+
* **Race Condition (TOCTOU):** Multiple users can pass this check simultaneously
|
|
415
|
+
* before branches are created. This is an accepted risk - the window is small
|
|
416
|
+
* (~100ms) and git will catch conflicts during merge/PR creation. Adding file
|
|
417
|
+
* locks would significantly increase complexity for minimal security gain.
|
|
418
|
+
*
|
|
419
|
+
* **Security Notes:**
|
|
420
|
+
* - sdlcRoot is normalized and validated (absolute path, no null bytes, max 1024 chars)
|
|
421
|
+
* - All display output is sanitized to prevent terminal injection attacks
|
|
422
|
+
* - Story IDs are validated with sanitizeStoryId() then stripped with sanitizeForDisplay()
|
|
423
|
+
* - Error messages are generic to prevent information leakage
|
|
424
|
+
*
|
|
425
|
+
* @param targetStory - The story to check for conflicts
|
|
426
|
+
* @param sdlcRoot - Root directory of the .ai-sdlc folder (must be absolute, validated)
|
|
427
|
+
* @param options - Command options (force flag)
|
|
428
|
+
* @param options.force - Skip conflict check if true
|
|
429
|
+
* @returns PreFlightResult indicating whether to proceed and any warnings
|
|
430
|
+
* @throws Error if sdlcRoot is invalid (not absolute, null bytes, too long)
|
|
431
|
+
*/
|
|
432
|
+
export async function preFlightConflictCheck(targetStory, sdlcRoot, options) {
|
|
433
|
+
const config = loadConfig();
|
|
434
|
+
const c = getThemedChalk(config);
|
|
435
|
+
// Skip if --force flag
|
|
436
|
+
if (options.force) {
|
|
437
|
+
console.log(c.warning('⚠️ Skipping conflict check (--force)'));
|
|
438
|
+
return { proceed: true, warnings: ['Conflict check skipped'] };
|
|
439
|
+
}
|
|
440
|
+
// Validate sdlcRoot parameter (normalize first to prevent bypass attacks)
|
|
441
|
+
const normalizedPath = path.normalize(sdlcRoot);
|
|
442
|
+
if (!path.isAbsolute(normalizedPath)) {
|
|
443
|
+
throw new Error('Invalid project path');
|
|
444
|
+
}
|
|
445
|
+
if (normalizedPath.includes('\0')) {
|
|
446
|
+
throw new Error('Invalid project path');
|
|
447
|
+
}
|
|
448
|
+
if (normalizedPath.length > 1024) {
|
|
449
|
+
throw new Error('Invalid project path');
|
|
450
|
+
}
|
|
451
|
+
// Check if target story is already in-progress
|
|
452
|
+
if (targetStory.frontmatter.status === 'in-progress') {
|
|
453
|
+
console.log(c.error('❌ Story is already in-progress'));
|
|
454
|
+
return { proceed: false, warnings: ['Story already in progress'] };
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
// Query for all in-progress stories (excluding target)
|
|
458
|
+
// Use normalizedPath for all subsequent operations
|
|
459
|
+
const activeStories = findStoriesByStatus(normalizedPath, 'in-progress')
|
|
460
|
+
.filter(s => s.frontmatter.id !== targetStory.frontmatter.id);
|
|
461
|
+
if (activeStories.length === 0) {
|
|
462
|
+
console.log(c.success('✓ Conflict check: No overlapping files with active stories'));
|
|
463
|
+
return { proceed: true, warnings: [] };
|
|
464
|
+
}
|
|
465
|
+
// Run conflict detection (use normalizedPath)
|
|
466
|
+
const workingDir = path.dirname(normalizedPath);
|
|
467
|
+
const result = detectConflicts([targetStory, ...activeStories], workingDir, 'main');
|
|
468
|
+
// Filter conflicts involving target story
|
|
469
|
+
const relevantConflicts = result.conflicts.filter(conflict => conflict.storyA === targetStory.frontmatter.id || conflict.storyB === targetStory.frontmatter.id);
|
|
470
|
+
// Filter out 'none' severity conflicts (keep all displayable conflicts including low)
|
|
471
|
+
const displayableConflicts = relevantConflicts.filter(conflict => conflict.severity !== 'none');
|
|
472
|
+
if (displayableConflicts.length === 0) {
|
|
473
|
+
console.log(c.success('✓ Conflict check: No overlapping files with active stories'));
|
|
474
|
+
return { proceed: true, warnings: [] };
|
|
475
|
+
}
|
|
476
|
+
// Sort conflicts by severity (high -> medium -> low)
|
|
477
|
+
const severityOrder = { high: 0, medium: 1, low: 2, none: 3 };
|
|
478
|
+
const sortedConflicts = displayableConflicts.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
|
479
|
+
// Display conflicts
|
|
480
|
+
console.log();
|
|
481
|
+
console.log(c.warning('⚠️ Potential conflicts detected:'));
|
|
482
|
+
console.log();
|
|
483
|
+
for (const conflict of sortedConflicts) {
|
|
484
|
+
const otherStoryId = conflict.storyA === targetStory.frontmatter.id ? conflict.storyB : conflict.storyA;
|
|
485
|
+
// Two-stage sanitization: validate structure, then strip for display
|
|
486
|
+
try {
|
|
487
|
+
const validatedTargetId = sanitizeStoryId(targetStory.frontmatter.id);
|
|
488
|
+
const validatedOtherId = sanitizeStoryId(otherStoryId);
|
|
489
|
+
const sanitizedTargetId = sanitizeForDisplay(validatedTargetId);
|
|
490
|
+
const sanitizedOtherId = sanitizeForDisplay(validatedOtherId);
|
|
491
|
+
console.log(c.warning(` ${sanitizedTargetId} may conflict with ${sanitizedOtherId}:`));
|
|
492
|
+
}
|
|
493
|
+
catch (error) {
|
|
494
|
+
// If validation fails, show generic error (defensive)
|
|
495
|
+
console.log(c.warning(` Story may have conflicting changes (invalid ID format)`));
|
|
496
|
+
}
|
|
497
|
+
// Display shared files
|
|
498
|
+
for (const file of conflict.sharedFiles) {
|
|
499
|
+
const severityLabel = conflict.severity === 'high' ? c.error('High') :
|
|
500
|
+
conflict.severity === 'medium' ? c.warning('Medium') :
|
|
501
|
+
c.info('Low');
|
|
502
|
+
const sanitizedFile = sanitizeForDisplay(file);
|
|
503
|
+
console.log(` - ${severityLabel}: ${sanitizedFile} (both stories modify this file)`);
|
|
504
|
+
}
|
|
505
|
+
// Display shared directories
|
|
506
|
+
for (const dir of conflict.sharedDirectories) {
|
|
507
|
+
const severityLabel = conflict.severity === 'high' ? c.error('High') :
|
|
508
|
+
conflict.severity === 'medium' ? c.warning('Medium') :
|
|
509
|
+
c.info('Low');
|
|
510
|
+
const sanitizedDir = sanitizeForDisplay(dir);
|
|
511
|
+
console.log(` - ${severityLabel}: ${sanitizedDir} (both stories modify files in this directory)`);
|
|
512
|
+
}
|
|
513
|
+
console.log();
|
|
514
|
+
const sanitizedRecommendation = sanitizeForDisplay(conflict.recommendation);
|
|
515
|
+
console.log(c.dim(` Recommendation: ${sanitizedRecommendation}`));
|
|
516
|
+
console.log();
|
|
517
|
+
}
|
|
518
|
+
// Non-interactive mode: default to declining
|
|
519
|
+
if (!process.stdin.isTTY) {
|
|
520
|
+
console.log(c.dim('Non-interactive mode: conflicts require --force to proceed'));
|
|
521
|
+
return { proceed: false, warnings: ['Conflicts detected'] };
|
|
522
|
+
}
|
|
523
|
+
// Interactive mode: prompt user
|
|
524
|
+
const shouldContinue = await confirmRemoval('Continue anyway?');
|
|
525
|
+
return {
|
|
526
|
+
proceed: shouldContinue,
|
|
527
|
+
warnings: shouldContinue ? ['User confirmed with conflicts'] : ['Conflicts detected']
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
// Fail-open: allow proceeding if conflict detection fails
|
|
532
|
+
console.log(c.warning('⚠️ Conflict detection unavailable'));
|
|
533
|
+
console.log(c.dim('Proceeding without conflict check...'));
|
|
534
|
+
return { proceed: true, warnings: ['Conflict detection failed'] };
|
|
535
|
+
}
|
|
536
|
+
}
|
|
242
537
|
/**
|
|
243
538
|
* Run the workflow (process one action or all)
|
|
244
539
|
*/
|
|
@@ -250,6 +545,26 @@ export async function run(options) {
|
|
|
250
545
|
: undefined;
|
|
251
546
|
let sdlcRoot = getSdlcRoot();
|
|
252
547
|
const c = getThemedChalk(config);
|
|
548
|
+
const logger = getLogger();
|
|
549
|
+
logger.debug('workflow', 'Run command initiated', {
|
|
550
|
+
auto: options.auto,
|
|
551
|
+
dryRun: options.dryRun,
|
|
552
|
+
continue: options.continue,
|
|
553
|
+
story: options.story,
|
|
554
|
+
step: options.step,
|
|
555
|
+
watch: options.watch,
|
|
556
|
+
worktree: options.worktree,
|
|
557
|
+
force: options.force,
|
|
558
|
+
});
|
|
559
|
+
// Migrate global workflow state to story-specific location if needed
|
|
560
|
+
// Only run when NOT continuing (to avoid interrupting resumed workflows)
|
|
561
|
+
if (!options.continue) {
|
|
562
|
+
const { migrateGlobalWorkflowState } = await import('../core/workflow-state.js');
|
|
563
|
+
const migrationResult = await migrateGlobalWorkflowState(sdlcRoot);
|
|
564
|
+
if (migrationResult.migrated) {
|
|
565
|
+
console.log(c.info(migrationResult.message));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
253
568
|
// Handle daemon/watch mode
|
|
254
569
|
if (options.watch) {
|
|
255
570
|
console.log(c.info('🚀 Starting daemon mode...'));
|
|
@@ -287,8 +602,15 @@ export async function run(options) {
|
|
|
287
602
|
let completedActions = [];
|
|
288
603
|
let storyContentHash;
|
|
289
604
|
if (options.continue) {
|
|
290
|
-
//
|
|
291
|
-
|
|
605
|
+
// Determine storyId for loading state
|
|
606
|
+
// If --story is provided, use it; otherwise, try to infer from existing state
|
|
607
|
+
let resumeStoryId;
|
|
608
|
+
// First try: use --story flag if provided
|
|
609
|
+
if (options.story) {
|
|
610
|
+
resumeStoryId = options.story;
|
|
611
|
+
}
|
|
612
|
+
// Try to load existing state (with or without storyId)
|
|
613
|
+
const existingState = await loadWorkflowState(sdlcRoot, resumeStoryId);
|
|
292
614
|
if (!existingState) {
|
|
293
615
|
console.log(c.error('Error: No checkpoint found.'));
|
|
294
616
|
console.log(c.dim('Remove --continue flag to start a new workflow.'));
|
|
@@ -334,15 +656,26 @@ export async function run(options) {
|
|
|
334
656
|
console.log();
|
|
335
657
|
}
|
|
336
658
|
else {
|
|
659
|
+
// Early validation of story ID format before any operations that use it
|
|
660
|
+
// This prevents sanitizeStoryId from throwing before we can show a nice error
|
|
661
|
+
if (options.story && !/^[a-z0-9_-]+$/i.test(options.story.toLowerCase().trim())) {
|
|
662
|
+
console.log(c.error('Invalid story ID format. Only letters, numbers, hyphens, and underscores are allowed.'));
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
337
665
|
// Check if there's an existing state and suggest --continue
|
|
338
|
-
|
|
666
|
+
// Check both global and story-specific state
|
|
667
|
+
const hasGlobalState = hasWorkflowState(sdlcRoot);
|
|
668
|
+
const hasStoryState = options.story ? hasWorkflowState(sdlcRoot, options.story) : false;
|
|
669
|
+
if ((hasGlobalState || hasStoryState) && !options.dryRun) {
|
|
339
670
|
console.log(c.info('Note: Found previous checkpoint. Use --continue to resume.'));
|
|
340
671
|
console.log();
|
|
341
672
|
}
|
|
342
673
|
// Start new workflow
|
|
343
674
|
workflowId = generateWorkflowId();
|
|
344
675
|
}
|
|
345
|
-
let assessment = assessState(sdlcRoot);
|
|
676
|
+
let assessment = await assessState(sdlcRoot);
|
|
677
|
+
// Hoist targetStory to outer scope so it can be reused for worktree checks
|
|
678
|
+
let targetStory = null;
|
|
346
679
|
// Filter actions by story if --story flag is provided
|
|
347
680
|
if (options.story) {
|
|
348
681
|
const normalizedInput = options.story.toLowerCase().trim();
|
|
@@ -353,7 +686,7 @@ export async function run(options) {
|
|
|
353
686
|
return;
|
|
354
687
|
}
|
|
355
688
|
// Try to find story by ID first, then by slug (case-insensitive)
|
|
356
|
-
|
|
689
|
+
targetStory = findStoryById(sdlcRoot, normalizedInput);
|
|
357
690
|
if (!targetStory) {
|
|
358
691
|
targetStory = findStoryBySlug(sdlcRoot, normalizedInput);
|
|
359
692
|
}
|
|
@@ -425,7 +758,8 @@ export async function run(options) {
|
|
|
425
758
|
}
|
|
426
759
|
// Clear state if workflow is complete
|
|
427
760
|
if (options.continue || hasWorkflowState(sdlcRoot)) {
|
|
428
|
-
|
|
761
|
+
// Using options.story - action not yet created in early exit path
|
|
762
|
+
await clearWorkflowState(sdlcRoot, options.story);
|
|
429
763
|
console.log(c.dim('Checkpoint cleared.'));
|
|
430
764
|
}
|
|
431
765
|
return;
|
|
@@ -466,105 +800,136 @@ export async function run(options) {
|
|
|
466
800
|
actionsToProcess = remainingActions;
|
|
467
801
|
if (actionsToProcess.length === 0) {
|
|
468
802
|
console.log(c.success('All actions from checkpoint already completed!'));
|
|
469
|
-
|
|
803
|
+
// Using options.story - action not yet created in early exit path
|
|
804
|
+
await clearWorkflowState(sdlcRoot, options.story);
|
|
470
805
|
console.log(c.dim('Checkpoint cleared.'));
|
|
471
806
|
return;
|
|
472
807
|
}
|
|
473
808
|
}
|
|
474
|
-
//
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
if (!gitValidation.valid) {
|
|
479
|
-
displayGitValidationResult(gitValidation, c);
|
|
480
|
-
return;
|
|
481
|
-
}
|
|
482
|
-
if (gitValidation.warnings.length > 0) {
|
|
483
|
-
displayGitValidationResult(gitValidation, c);
|
|
484
|
-
console.log();
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
// Handle worktree creation based on flags and config
|
|
809
|
+
// Handle worktree creation based on flags, config, and story frontmatter
|
|
810
|
+
// IMPORTANT: This must happen BEFORE git validation because:
|
|
811
|
+
// 1. Worktree mode allows running from protected branches (main/master)
|
|
812
|
+
// 2. The worktree will be created on a feature branch
|
|
488
813
|
let worktreePath;
|
|
489
814
|
let originalCwd;
|
|
815
|
+
let worktreeCreated = false;
|
|
490
816
|
// Determine if worktree should be used
|
|
491
|
-
// Priority: CLI flags > config > default (disabled)
|
|
817
|
+
// Priority: CLI flags > story frontmatter > config > default (disabled)
|
|
492
818
|
const worktreeConfig = config.worktree ?? DEFAULT_WORKTREE_CONFIG;
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
if (options.worktree === false)
|
|
496
|
-
return false;
|
|
497
|
-
// Explicit --worktree enables worktrees
|
|
498
|
-
if (options.worktree === true)
|
|
499
|
-
return true;
|
|
500
|
-
// Fall back to config default
|
|
501
|
-
return worktreeConfig.enabled;
|
|
502
|
-
})();
|
|
819
|
+
// Reuse targetStory from earlier lookup (DRY - avoids duplicate story lookup)
|
|
820
|
+
const shouldUseWorktree = determineWorktreeMode(options, worktreeConfig, targetStory);
|
|
503
821
|
// Validate that worktree mode requires --story
|
|
504
822
|
if (shouldUseWorktree && !options.story) {
|
|
505
823
|
if (options.worktree === true) {
|
|
506
|
-
// Explicit --worktree flag without --story is an error
|
|
507
824
|
console.log(c.error('Error: --worktree requires --story flag'));
|
|
508
825
|
return;
|
|
509
826
|
}
|
|
510
|
-
// Config-enabled worktree without --story just silently skips worktree
|
|
511
827
|
}
|
|
512
|
-
if (shouldUseWorktree && options.story) {
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
|
|
518
|
-
}
|
|
519
|
-
catch (error) {
|
|
520
|
-
console.log(c.error(`Configuration Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
521
|
-
console.log(c.dim('Fix worktree.basePath in .ai-sdlc.json or remove it to use default location'));
|
|
522
|
-
return;
|
|
523
|
-
}
|
|
524
|
-
const worktreeService = new GitWorktreeService(workingDir, resolvedBasePath);
|
|
525
|
-
// Validate git state for worktree creation
|
|
526
|
-
const validation = worktreeService.validateCanCreateWorktree();
|
|
527
|
-
if (!validation.valid) {
|
|
528
|
-
console.log(c.error(`Error: ${validation.error}`));
|
|
828
|
+
if (shouldUseWorktree && options.story && targetStory) {
|
|
829
|
+
// PRE-FLIGHT CHECK: Run conflict detection before creating worktree
|
|
830
|
+
const preFlightResult = await preFlightConflictCheck(targetStory, sdlcRoot, options);
|
|
831
|
+
if (!preFlightResult.proceed) {
|
|
832
|
+
console.log(c.error('❌ Aborting. Complete active stories first or use --force.'));
|
|
529
833
|
return;
|
|
530
834
|
}
|
|
531
|
-
//
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
console.log(
|
|
535
|
-
return;
|
|
835
|
+
// Log warnings if user proceeded despite conflicts (skip internal flag messages)
|
|
836
|
+
if (preFlightResult.warnings.length > 0 && !preFlightResult.warnings.includes('Conflict check skipped')) {
|
|
837
|
+
preFlightResult.warnings.forEach(w => console.log(c.dim(` ⚠ ${w}`)));
|
|
838
|
+
console.log();
|
|
536
839
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
840
|
+
const workingDir = path.dirname(sdlcRoot);
|
|
841
|
+
// Check if story already has an existing worktree (resume scenario)
|
|
842
|
+
const existingWorktreePath = targetStory.frontmatter.worktree_path;
|
|
843
|
+
if (existingWorktreePath && fs.existsSync(existingWorktreePath)) {
|
|
844
|
+
// Reuse existing worktree
|
|
541
845
|
originalCwd = process.cwd();
|
|
542
|
-
worktreePath =
|
|
543
|
-
storyId: targetStory.frontmatter.id,
|
|
544
|
-
slug: targetStory.slug,
|
|
545
|
-
baseBranch,
|
|
546
|
-
});
|
|
547
|
-
// Update story frontmatter with worktree path
|
|
548
|
-
const updatedStory = updateStoryField(targetStory, 'worktree_path', worktreePath);
|
|
549
|
-
await writeStory(updatedStory);
|
|
550
|
-
// Change to worktree directory
|
|
846
|
+
worktreePath = existingWorktreePath;
|
|
551
847
|
process.chdir(worktreePath);
|
|
552
|
-
// Recalculate sdlcRoot for the worktree context
|
|
553
|
-
// Since we've changed cwd to the worktree, getSdlcRoot() will now return the worktree's .ai-sdlc path
|
|
554
|
-
// This ensures all subsequent agent operations work within the isolated worktree
|
|
555
848
|
sdlcRoot = getSdlcRoot();
|
|
556
|
-
|
|
849
|
+
worktreeCreated = true;
|
|
850
|
+
console.log(c.success(`✓ Resuming in existing worktree: ${worktreePath}`));
|
|
557
851
|
console.log(c.dim(` Branch: ai-sdlc/${targetStory.frontmatter.id}-${targetStory.slug}`));
|
|
558
852
|
console.log();
|
|
559
853
|
}
|
|
560
|
-
|
|
561
|
-
//
|
|
562
|
-
|
|
854
|
+
else {
|
|
855
|
+
// Create new worktree
|
|
856
|
+
// Resolve worktree base path from config
|
|
857
|
+
let resolvedBasePath;
|
|
858
|
+
try {
|
|
859
|
+
resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
|
|
860
|
+
}
|
|
861
|
+
catch (error) {
|
|
862
|
+
console.log(c.error(`Configuration Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
863
|
+
console.log(c.dim('Fix worktree.basePath in .ai-sdlc.json or remove it to use default location'));
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
const worktreeService = new GitWorktreeService(workingDir, resolvedBasePath);
|
|
867
|
+
// Validate git state for worktree creation
|
|
868
|
+
const validation = worktreeService.validateCanCreateWorktree();
|
|
869
|
+
if (!validation.valid) {
|
|
870
|
+
console.log(c.error(`Error: ${validation.error}`));
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
try {
|
|
874
|
+
// Detect base branch
|
|
875
|
+
const baseBranch = worktreeService.detectBaseBranch();
|
|
876
|
+
// Create worktree
|
|
877
|
+
originalCwd = process.cwd();
|
|
878
|
+
worktreePath = worktreeService.create({
|
|
879
|
+
storyId: targetStory.frontmatter.id,
|
|
880
|
+
slug: targetStory.slug,
|
|
881
|
+
baseBranch,
|
|
882
|
+
});
|
|
883
|
+
// Change to worktree directory BEFORE updating story
|
|
884
|
+
// This ensures story updates happen in the worktree, not on main
|
|
885
|
+
// (allows parallel story launches from clean main)
|
|
886
|
+
process.chdir(worktreePath);
|
|
887
|
+
// Recalculate sdlcRoot for the worktree context
|
|
888
|
+
sdlcRoot = getSdlcRoot();
|
|
889
|
+
worktreeCreated = true;
|
|
890
|
+
// Now update story frontmatter with worktree path (writes to worktree copy)
|
|
891
|
+
// Re-resolve target story in worktree context
|
|
892
|
+
const worktreeStory = findStoryById(sdlcRoot, targetStory.frontmatter.id);
|
|
893
|
+
if (worktreeStory) {
|
|
894
|
+
const updatedStory = await updateStoryField(worktreeStory, 'worktree_path', worktreePath);
|
|
895
|
+
await writeStory(updatedStory);
|
|
896
|
+
// Update targetStory reference for downstream use
|
|
897
|
+
targetStory = updatedStory;
|
|
898
|
+
}
|
|
899
|
+
console.log(c.success(`✓ Created worktree at: ${worktreePath}`));
|
|
900
|
+
console.log(c.dim(` Branch: ai-sdlc/${targetStory.frontmatter.id}-${targetStory.slug}`));
|
|
901
|
+
console.log();
|
|
902
|
+
}
|
|
903
|
+
catch (error) {
|
|
904
|
+
// Restore directory on worktree creation failure
|
|
905
|
+
if (originalCwd) {
|
|
906
|
+
process.chdir(originalCwd);
|
|
907
|
+
}
|
|
908
|
+
console.log(c.error(`Failed to create worktree: ${error instanceof Error ? error.message : String(error)}`));
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
// Validate git state before processing actions that modify git
|
|
914
|
+
// Skip protected branch check if worktree mode is active (worktree is on feature branch)
|
|
915
|
+
// Exclude .ai-sdlc/** from clean check when worktree was created (story file was just updated)
|
|
916
|
+
if (!options.force && requiresGitValidation(actionsToProcess)) {
|
|
917
|
+
const workingDir = path.dirname(sdlcRoot);
|
|
918
|
+
const gitValidationOptions = worktreeCreated
|
|
919
|
+
? { skipBranchCheck: true, excludePatterns: ['.ai-sdlc/**'] }
|
|
920
|
+
: {};
|
|
921
|
+
const gitValidation = validateGitState(workingDir, gitValidationOptions);
|
|
922
|
+
if (!gitValidation.valid) {
|
|
923
|
+
displayGitValidationResult(gitValidation, c);
|
|
924
|
+
if (worktreeCreated && originalCwd) {
|
|
563
925
|
process.chdir(originalCwd);
|
|
564
926
|
}
|
|
565
|
-
console.log(c.error(`Failed to create worktree: ${error instanceof Error ? error.message : String(error)}`));
|
|
566
927
|
return;
|
|
567
928
|
}
|
|
929
|
+
if (gitValidation.warnings.length > 0) {
|
|
930
|
+
displayGitValidationResult(gitValidation, c);
|
|
931
|
+
console.log();
|
|
932
|
+
}
|
|
568
933
|
}
|
|
569
934
|
// Process actions with retry support for Full SDLC mode
|
|
570
935
|
let currentActions = [...actionsToProcess];
|
|
@@ -608,7 +973,8 @@ export async function run(options) {
|
|
|
608
973
|
console.log(c.info('You can:'));
|
|
609
974
|
console.log(c.dim(' 1. Fix issues manually and run again'));
|
|
610
975
|
console.log(c.dim(' 2. Reset retry count in the story frontmatter'));
|
|
611
|
-
|
|
976
|
+
// Using action.storyId - available from action loop context
|
|
977
|
+
await clearWorkflowState(sdlcRoot, action.storyId);
|
|
612
978
|
return;
|
|
613
979
|
}
|
|
614
980
|
// We can retry - reset RPIV cycle and loop back
|
|
@@ -624,7 +990,7 @@ export async function run(options) {
|
|
|
624
990
|
const summary = generateReviewSummary(reviewResult.issues, getTerminalWidth());
|
|
625
991
|
console.log(c.dim(` Summary: ${summary}`));
|
|
626
992
|
// Reset the RPIV cycle (this increments retry_count and resets flags)
|
|
627
|
-
resetRPIVCycle(story, reviewResult.feedback);
|
|
993
|
+
await resetRPIVCycle(story, reviewResult.feedback);
|
|
628
994
|
// Log what's being reset
|
|
629
995
|
console.log(c.dim(` → Reset plan_complete, implementation_complete, reviews_complete`));
|
|
630
996
|
console.log(c.dim(` → Retry count: ${currentRetry}/${maxRetriesDisplay}`));
|
|
@@ -673,7 +1039,7 @@ export async function run(options) {
|
|
|
673
1039
|
storyContentHash: calculateStoryHash(action.storyPath),
|
|
674
1040
|
},
|
|
675
1041
|
};
|
|
676
|
-
await saveWorkflowState(state, sdlcRoot);
|
|
1042
|
+
await saveWorkflowState(state, sdlcRoot, action.storyId);
|
|
677
1043
|
console.log(c.dim(` ✓ Progress saved (${completedActions.length} actions completed)`));
|
|
678
1044
|
}
|
|
679
1045
|
currentActionIndex++;
|
|
@@ -695,7 +1061,8 @@ export async function run(options) {
|
|
|
695
1061
|
console.log(c.dim(`Retry attempts: ${retryAttempt}`));
|
|
696
1062
|
}
|
|
697
1063
|
console.log(c.dim(`Story is now ready for PR creation.`));
|
|
698
|
-
|
|
1064
|
+
// Using action.storyId - available from action loop context
|
|
1065
|
+
await clearWorkflowState(sdlcRoot, action.storyId);
|
|
699
1066
|
console.log(c.dim('Checkpoint cleared.'));
|
|
700
1067
|
}
|
|
701
1068
|
else {
|
|
@@ -709,10 +1076,11 @@ export async function run(options) {
|
|
|
709
1076
|
}
|
|
710
1077
|
else {
|
|
711
1078
|
// Normal auto mode: re-assess state
|
|
712
|
-
const newAssessment = assessState(sdlcRoot);
|
|
1079
|
+
const newAssessment = await assessState(sdlcRoot);
|
|
713
1080
|
if (newAssessment.recommendedActions.length === 0) {
|
|
714
1081
|
console.log(c.success('\n✓ All actions completed!'));
|
|
715
|
-
|
|
1082
|
+
// Using action.storyId - available from action loop context
|
|
1083
|
+
await clearWorkflowState(sdlcRoot, action.storyId);
|
|
716
1084
|
console.log(c.dim('Checkpoint cleared.'));
|
|
717
1085
|
break;
|
|
718
1086
|
}
|
|
@@ -735,61 +1103,91 @@ export async function run(options) {
|
|
|
735
1103
|
async function executeAction(action, sdlcRoot) {
|
|
736
1104
|
const config = loadConfig();
|
|
737
1105
|
const c = getThemedChalk(config);
|
|
738
|
-
|
|
739
|
-
|
|
1106
|
+
const globalLogger = getLogger();
|
|
1107
|
+
const actionStartTime = Date.now();
|
|
1108
|
+
// Log action start to global logger
|
|
1109
|
+
globalLogger.info('action', `Starting action: ${action.type}`, {
|
|
1110
|
+
storyId: action.storyId,
|
|
1111
|
+
actionType: action.type,
|
|
1112
|
+
storyPath: action.storyPath,
|
|
1113
|
+
});
|
|
1114
|
+
// Initialize per-story logger
|
|
1115
|
+
const maxLogs = config.logging?.maxFiles ?? 5;
|
|
1116
|
+
let storyLogger = null;
|
|
1117
|
+
let spinner = null;
|
|
740
1118
|
try {
|
|
741
|
-
|
|
742
|
-
|
|
1119
|
+
storyLogger = new StoryLogger(action.storyId, sdlcRoot, maxLogs);
|
|
1120
|
+
storyLogger.log('INFO', `Starting action: ${action.type} for story ${action.storyId}`);
|
|
743
1121
|
}
|
|
744
1122
|
catch (error) {
|
|
745
|
-
|
|
746
|
-
console.
|
|
747
|
-
console.log(c.dim(` Original path: ${action.storyPath}`));
|
|
748
|
-
if (error instanceof Error) {
|
|
749
|
-
console.log(c.dim(` ${error.message}`));
|
|
750
|
-
}
|
|
751
|
-
return { success: false };
|
|
1123
|
+
// If logger initialization fails, continue without logging (console-only)
|
|
1124
|
+
console.warn(`Warning: Failed to initialize logger: ${error instanceof Error ? error.message : String(error)}`);
|
|
752
1125
|
}
|
|
753
|
-
// Update action path if it was stale
|
|
754
|
-
if (resolvedPath !== action.storyPath) {
|
|
755
|
-
console.log(c.warning(`Note: Story path updated (file was moved)`));
|
|
756
|
-
console.log(c.dim(` From: ${action.storyPath}`));
|
|
757
|
-
console.log(c.dim(` To: ${resolvedPath}`));
|
|
758
|
-
action.storyPath = resolvedPath;
|
|
759
|
-
}
|
|
760
|
-
// Store phase completion state BEFORE action execution (to detect transitions)
|
|
761
|
-
const storyBeforeAction = parseStory(action.storyPath);
|
|
762
|
-
const prevPhaseState = {
|
|
763
|
-
research_complete: storyBeforeAction.frontmatter.research_complete,
|
|
764
|
-
plan_complete: storyBeforeAction.frontmatter.plan_complete,
|
|
765
|
-
implementation_complete: storyBeforeAction.frontmatter.implementation_complete,
|
|
766
|
-
reviews_complete: storyBeforeAction.frontmatter.reviews_complete,
|
|
767
|
-
status: storyBeforeAction.frontmatter.status,
|
|
768
|
-
};
|
|
769
|
-
const spinner = ora(formatAction(action, true, c)).start();
|
|
770
|
-
const baseText = formatAction(action, true, c);
|
|
771
|
-
// Create agent progress callback for real-time updates
|
|
772
|
-
const onAgentProgress = (event) => {
|
|
773
|
-
switch (event.type) {
|
|
774
|
-
case 'session_start':
|
|
775
|
-
spinner.text = `${baseText} ${c.dim('(session started)')}`;
|
|
776
|
-
break;
|
|
777
|
-
case 'tool_start':
|
|
778
|
-
// Show which tool is being executed
|
|
779
|
-
const toolName = event.toolName || 'unknown';
|
|
780
|
-
const shortName = toolName.replace(/^(mcp__|Mcp)/, '').substring(0, 30);
|
|
781
|
-
spinner.text = `${baseText} ${c.dim(`→ ${shortName}`)}`;
|
|
782
|
-
break;
|
|
783
|
-
case 'tool_end':
|
|
784
|
-
// Keep showing the action, tool completed
|
|
785
|
-
spinner.text = baseText;
|
|
786
|
-
break;
|
|
787
|
-
case 'completion':
|
|
788
|
-
spinner.text = `${baseText} ${c.dim('(completing...)')}`;
|
|
789
|
-
break;
|
|
790
|
-
}
|
|
791
|
-
};
|
|
792
1126
|
try {
|
|
1127
|
+
// Resolve story by ID to get current path (handles moves between folders)
|
|
1128
|
+
let resolvedPath;
|
|
1129
|
+
try {
|
|
1130
|
+
const story = getStory(sdlcRoot, action.storyId);
|
|
1131
|
+
resolvedPath = story.path;
|
|
1132
|
+
}
|
|
1133
|
+
catch (error) {
|
|
1134
|
+
const errorMsg = `Error: Story not found for action "${action.type}"`;
|
|
1135
|
+
storyLogger?.log('ERROR', errorMsg);
|
|
1136
|
+
storyLogger?.log('ERROR', ` Story ID: ${action.storyId}`);
|
|
1137
|
+
storyLogger?.log('ERROR', ` Original path: ${action.storyPath}`);
|
|
1138
|
+
console.log(c.error(errorMsg));
|
|
1139
|
+
console.log(c.dim(` Story ID: ${action.storyId}`));
|
|
1140
|
+
console.log(c.dim(` Original path: ${action.storyPath}`));
|
|
1141
|
+
if (error instanceof Error) {
|
|
1142
|
+
storyLogger?.log('ERROR', ` ${error.message}`);
|
|
1143
|
+
console.log(c.dim(` ${error.message}`));
|
|
1144
|
+
}
|
|
1145
|
+
return { success: false };
|
|
1146
|
+
}
|
|
1147
|
+
// Update action path if it was stale
|
|
1148
|
+
if (resolvedPath !== action.storyPath) {
|
|
1149
|
+
storyLogger?.log('WARN', `Note: Story path updated (file was moved)`);
|
|
1150
|
+
storyLogger?.log('WARN', ` From: ${action.storyPath}`);
|
|
1151
|
+
storyLogger?.log('WARN', ` To: ${resolvedPath}`);
|
|
1152
|
+
console.log(c.warning(`Note: Story path updated (file was moved)`));
|
|
1153
|
+
console.log(c.dim(` From: ${action.storyPath}`));
|
|
1154
|
+
console.log(c.dim(` To: ${resolvedPath}`));
|
|
1155
|
+
action.storyPath = resolvedPath;
|
|
1156
|
+
}
|
|
1157
|
+
// Store phase completion state BEFORE action execution (to detect transitions)
|
|
1158
|
+
const storyBeforeAction = parseStory(action.storyPath);
|
|
1159
|
+
const prevPhaseState = {
|
|
1160
|
+
research_complete: storyBeforeAction.frontmatter.research_complete,
|
|
1161
|
+
plan_complete: storyBeforeAction.frontmatter.plan_complete,
|
|
1162
|
+
implementation_complete: storyBeforeAction.frontmatter.implementation_complete,
|
|
1163
|
+
reviews_complete: storyBeforeAction.frontmatter.reviews_complete,
|
|
1164
|
+
status: storyBeforeAction.frontmatter.status,
|
|
1165
|
+
};
|
|
1166
|
+
spinner = ora(formatAction(action, true, c)).start();
|
|
1167
|
+
const baseText = formatAction(action, true, c);
|
|
1168
|
+
// Create agent progress callback for real-time updates
|
|
1169
|
+
const onAgentProgress = (event) => {
|
|
1170
|
+
if (!spinner)
|
|
1171
|
+
return; // Guard against null spinner
|
|
1172
|
+
switch (event.type) {
|
|
1173
|
+
case 'session_start':
|
|
1174
|
+
spinner.text = `${baseText} ${c.dim('(session started)')}`;
|
|
1175
|
+
break;
|
|
1176
|
+
case 'tool_start':
|
|
1177
|
+
// Show which tool is being executed
|
|
1178
|
+
const toolName = event.toolName || 'unknown';
|
|
1179
|
+
const shortName = toolName.replace(/^(mcp__|Mcp)/, '').substring(0, 30);
|
|
1180
|
+
spinner.text = `${baseText} ${c.dim(`→ ${shortName}`)}`;
|
|
1181
|
+
break;
|
|
1182
|
+
case 'tool_end':
|
|
1183
|
+
// Keep showing the action, tool completed
|
|
1184
|
+
spinner.text = baseText;
|
|
1185
|
+
break;
|
|
1186
|
+
case 'completion':
|
|
1187
|
+
spinner.text = `${baseText} ${c.dim('(completing...)')}`;
|
|
1188
|
+
break;
|
|
1189
|
+
}
|
|
1190
|
+
};
|
|
793
1191
|
// Import and run the appropriate agent
|
|
794
1192
|
let result;
|
|
795
1193
|
switch (action.type) {
|
|
@@ -813,6 +1211,8 @@ async function executeAction(action, sdlcRoot) {
|
|
|
813
1211
|
const { runReviewAgent } = await import('../agents/review.js');
|
|
814
1212
|
result = await runReviewAgent(action.storyPath, sdlcRoot, {
|
|
815
1213
|
onVerificationProgress: (phase, status, message) => {
|
|
1214
|
+
if (!spinner)
|
|
1215
|
+
return; // Guard against null spinner
|
|
816
1216
|
const phaseLabel = phase === 'build' ? 'Building' : 'Testing';
|
|
817
1217
|
switch (status) {
|
|
818
1218
|
case 'starting':
|
|
@@ -846,7 +1246,7 @@ async function executeAction(action, sdlcRoot) {
|
|
|
846
1246
|
// Update story status to done (no file move in new architecture)
|
|
847
1247
|
const { updateStoryStatus } = await import('../core/story.js');
|
|
848
1248
|
const storyToMove = parseStory(action.storyPath);
|
|
849
|
-
const updatedStory = updateStoryStatus(storyToMove, 'done');
|
|
1249
|
+
const updatedStory = await updateStoryStatus(storyToMove, 'done');
|
|
850
1250
|
result = {
|
|
851
1251
|
success: true,
|
|
852
1252
|
story: updatedStory,
|
|
@@ -861,17 +1261,34 @@ async function executeAction(action, sdlcRoot) {
|
|
|
861
1261
|
throw new Error(`Unknown action type: ${action.type}`);
|
|
862
1262
|
}
|
|
863
1263
|
// Check if agent succeeded
|
|
1264
|
+
const actionDuration = Date.now() - actionStartTime;
|
|
864
1265
|
if (result && !result.success) {
|
|
865
1266
|
spinner.fail(c.error(`Failed: ${formatAction(action, true, c)}`));
|
|
1267
|
+
storyLogger?.log('ERROR', `Action failed: ${formatAction(action, false, c)}`);
|
|
1268
|
+
globalLogger.warn('action', `Action failed: ${action.type}`, {
|
|
1269
|
+
storyId: action.storyId,
|
|
1270
|
+
actionType: action.type,
|
|
1271
|
+
durationMs: actionDuration,
|
|
1272
|
+
error: result.error,
|
|
1273
|
+
});
|
|
866
1274
|
if (result.error) {
|
|
1275
|
+
storyLogger?.log('ERROR', ` Error: ${result.error}`);
|
|
867
1276
|
console.error(c.error(` Error: ${result.error}`));
|
|
868
1277
|
}
|
|
869
1278
|
return { success: false };
|
|
870
1279
|
}
|
|
871
1280
|
spinner.succeed(c.success(formatAction(action, true, c)));
|
|
1281
|
+
storyLogger?.log('INFO', `Action completed successfully: ${formatAction(action, false, c)}`);
|
|
1282
|
+
globalLogger.info('action', `Action completed: ${action.type}`, {
|
|
1283
|
+
storyId: action.storyId,
|
|
1284
|
+
actionType: action.type,
|
|
1285
|
+
durationMs: actionDuration,
|
|
1286
|
+
changesCount: result?.changesMade?.length ?? 0,
|
|
1287
|
+
});
|
|
872
1288
|
// Show changes made
|
|
873
1289
|
if (result && result.changesMade.length > 0) {
|
|
874
1290
|
for (const change of result.changesMade) {
|
|
1291
|
+
storyLogger?.log('INFO', ` → ${change}`);
|
|
875
1292
|
console.log(c.dim(` → ${change}`));
|
|
876
1293
|
}
|
|
877
1294
|
}
|
|
@@ -927,14 +1344,28 @@ async function executeAction(action, sdlcRoot) {
|
|
|
927
1344
|
return { success: true };
|
|
928
1345
|
}
|
|
929
1346
|
catch (error) {
|
|
930
|
-
|
|
1347
|
+
const exceptionDuration = Date.now() - actionStartTime;
|
|
1348
|
+
if (spinner) {
|
|
1349
|
+
spinner.fail(c.error(`Failed: ${formatAction(action, true, c)}`));
|
|
1350
|
+
}
|
|
1351
|
+
else {
|
|
1352
|
+
console.error(c.error(`Failed: ${formatAction(action, true, c)}`));
|
|
1353
|
+
}
|
|
1354
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1355
|
+
storyLogger?.log('ERROR', `Exception during action execution: ${errorMessage}`);
|
|
1356
|
+
globalLogger.error('action', `Action exception: ${action.type}`, {
|
|
1357
|
+
storyId: action.storyId,
|
|
1358
|
+
actionType: action.type,
|
|
1359
|
+
durationMs: exceptionDuration,
|
|
1360
|
+
error: errorMessage,
|
|
1361
|
+
});
|
|
931
1362
|
console.error(error);
|
|
932
1363
|
// Show phase checklist with error indication (if file still exists)
|
|
933
1364
|
try {
|
|
934
1365
|
const story = parseStory(action.storyPath);
|
|
935
1366
|
console.log(c.dim(` Progress: ${renderPhaseChecklist(story, c)}`));
|
|
936
1367
|
// Update story with error
|
|
937
|
-
story.frontmatter.last_error =
|
|
1368
|
+
story.frontmatter.last_error = errorMessage;
|
|
938
1369
|
}
|
|
939
1370
|
catch {
|
|
940
1371
|
// File may have been moved - skip progress display
|
|
@@ -942,6 +1373,10 @@ async function executeAction(action, sdlcRoot) {
|
|
|
942
1373
|
// Don't throw - let the workflow continue if in auto mode
|
|
943
1374
|
return { success: false };
|
|
944
1375
|
}
|
|
1376
|
+
finally {
|
|
1377
|
+
// Always close logger, even if action fails or throws
|
|
1378
|
+
storyLogger?.close();
|
|
1379
|
+
}
|
|
945
1380
|
}
|
|
946
1381
|
/**
|
|
947
1382
|
* Get phase information for an action type
|
|
@@ -1349,7 +1784,7 @@ function isEmptySection(content) {
|
|
|
1349
1784
|
/**
|
|
1350
1785
|
* Unblock a story from the blocked folder and move it back to the workflow
|
|
1351
1786
|
*/
|
|
1352
|
-
export function unblock(storyId, options) {
|
|
1787
|
+
export async function unblock(storyId, options) {
|
|
1353
1788
|
const spinner = ora('Unblocking story...').start();
|
|
1354
1789
|
const config = loadConfig();
|
|
1355
1790
|
const c = getThemedChalk(config);
|
|
@@ -1360,7 +1795,7 @@ export function unblock(storyId, options) {
|
|
|
1360
1795
|
return;
|
|
1361
1796
|
}
|
|
1362
1797
|
// Unblock the story (using renamed import to avoid naming conflict)
|
|
1363
|
-
const unblockedStory = unblockStory(storyId, sdlcRoot, options);
|
|
1798
|
+
const unblockedStory = await unblockStory(storyId, sdlcRoot, options);
|
|
1364
1799
|
// Determine destination folder from updated path
|
|
1365
1800
|
const destinationFolder = unblockedStory.path.match(/\/([^/]+)\/[^/]+\.md$/)?.[1] || 'unknown';
|
|
1366
1801
|
spinner.succeed(c.success(`Unblocked story ${storyId}, moved to ${destinationFolder}/`));
|
|
@@ -1478,12 +1913,15 @@ export async function migrate(options) {
|
|
|
1478
1913
|
* Helper function to prompt for removal confirmation
|
|
1479
1914
|
*/
|
|
1480
1915
|
async function confirmRemoval(message) {
|
|
1916
|
+
// Sanitize message to prevent terminal injection attacks
|
|
1917
|
+
// Use consistent sanitizeForDisplay() for all terminal output
|
|
1918
|
+
const sanitizedMessage = sanitizeForDisplay(message);
|
|
1481
1919
|
const rl = readline.createInterface({
|
|
1482
1920
|
input: process.stdin,
|
|
1483
1921
|
output: process.stdout,
|
|
1484
1922
|
});
|
|
1485
1923
|
return new Promise((resolve) => {
|
|
1486
|
-
rl.question(
|
|
1924
|
+
rl.question(sanitizedMessage + ' (y/N): ', (answer) => {
|
|
1487
1925
|
rl.close();
|
|
1488
1926
|
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
1489
1927
|
});
|
|
@@ -1503,7 +1941,7 @@ async function handleWorktreeCleanup(story, config, c) {
|
|
|
1503
1941
|
// Check if worktree exists
|
|
1504
1942
|
if (!fs.existsSync(worktreePath)) {
|
|
1505
1943
|
console.log(c.warning(` Note: Worktree path no longer exists: ${worktreePath}`));
|
|
1506
|
-
const updated = updateStoryField(story, 'worktree_path', undefined);
|
|
1944
|
+
const updated = await updateStoryField(story, 'worktree_path', undefined);
|
|
1507
1945
|
await writeStory(updated);
|
|
1508
1946
|
console.log(c.dim(' Cleared worktree_path from frontmatter'));
|
|
1509
1947
|
return;
|
|
@@ -1532,14 +1970,14 @@ async function handleWorktreeCleanup(story, config, c) {
|
|
|
1532
1970
|
}
|
|
1533
1971
|
const service = new GitWorktreeService(workingDir, resolvedBasePath);
|
|
1534
1972
|
service.remove(worktreePath);
|
|
1535
|
-
const updated = updateStoryField(story, 'worktree_path', undefined);
|
|
1973
|
+
const updated = await updateStoryField(story, 'worktree_path', undefined);
|
|
1536
1974
|
await writeStory(updated);
|
|
1537
1975
|
console.log(c.success(' ✓ Worktree removed'));
|
|
1538
1976
|
}
|
|
1539
1977
|
catch (error) {
|
|
1540
1978
|
console.log(c.warning(` Failed to remove worktree: ${error instanceof Error ? error.message : String(error)}`));
|
|
1541
1979
|
// Clear frontmatter anyway (user may have manually deleted)
|
|
1542
|
-
const updated = updateStoryField(story, 'worktree_path', undefined);
|
|
1980
|
+
const updated = await updateStoryField(story, 'worktree_path', undefined);
|
|
1543
1981
|
await writeStory(updated);
|
|
1544
1982
|
}
|
|
1545
1983
|
}
|
|
@@ -1654,9 +2092,9 @@ export async function addWorktree(storyId) {
|
|
|
1654
2092
|
baseBranch,
|
|
1655
2093
|
});
|
|
1656
2094
|
// Update story frontmatter
|
|
1657
|
-
const updatedStory = updateStoryField(story, 'worktree_path', worktreePath);
|
|
2095
|
+
const updatedStory = await updateStoryField(story, 'worktree_path', worktreePath);
|
|
1658
2096
|
const branchName = service.getBranchName(story.frontmatter.id, story.slug);
|
|
1659
|
-
const storyWithBranch = updateStoryField(updatedStory, 'branch', branchName);
|
|
2097
|
+
const storyWithBranch = await updateStoryField(updatedStory, 'branch', branchName);
|
|
1660
2098
|
await writeStory(storyWithBranch);
|
|
1661
2099
|
spinner.succeed(c.success(`Created worktree for ${story.frontmatter.id}`));
|
|
1662
2100
|
console.log(c.dim(` Path: ${worktreePath}`));
|
|
@@ -1723,7 +2161,7 @@ export async function removeWorktree(storyId, options) {
|
|
|
1723
2161
|
// Remove the worktree
|
|
1724
2162
|
service.remove(worktreePath);
|
|
1725
2163
|
// Clear worktree_path from frontmatter
|
|
1726
|
-
const updatedStory = updateStoryField(story, 'worktree_path', undefined);
|
|
2164
|
+
const updatedStory = await updateStoryField(story, 'worktree_path', undefined);
|
|
1727
2165
|
await writeStory(updatedStory);
|
|
1728
2166
|
spinner.succeed(c.success(`Removed worktree for ${story.frontmatter.id}`));
|
|
1729
2167
|
console.log(c.dim(` Path: ${worktreePath}`));
|