ai-sdlc 0.2.0-alpha.20 → 0.2.0-alpha.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands.d.ts +26 -1
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +264 -55
- package/dist/cli/commands.js.map +1 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -0
- package/dist/core/index.js.map +1 -1
- 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/index.js +69 -2
- package/dist/index.js.map +1 -1
- package/dist/types/index.d.ts +13 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/commands.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Story, ActionType } from '../types/index.js';
|
|
1
|
+
import { Story, ActionType, PreFlightResult } from '../types/index.js';
|
|
2
2
|
/**
|
|
3
3
|
* Initialize the .ai-sdlc folder structure
|
|
4
4
|
*/
|
|
@@ -26,6 +26,31 @@ export declare function determineWorktreeMode(options: {
|
|
|
26
26
|
}, worktreeConfig: {
|
|
27
27
|
enabled: boolean;
|
|
28
28
|
}, targetStory: Story | null): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Perform pre-flight conflict check before starting work on a story in a worktree.
|
|
31
|
+
* Warns about potential file conflicts with active stories and prompts for confirmation.
|
|
32
|
+
*
|
|
33
|
+
* **Race Condition (TOCTOU):** Multiple users can pass this check simultaneously
|
|
34
|
+
* before branches are created. This is an accepted risk - the window is small
|
|
35
|
+
* (~100ms) and git will catch conflicts during merge/PR creation. Adding file
|
|
36
|
+
* locks would significantly increase complexity for minimal security gain.
|
|
37
|
+
*
|
|
38
|
+
* **Security Notes:**
|
|
39
|
+
* - sdlcRoot is normalized and validated (absolute path, no null bytes, max 1024 chars)
|
|
40
|
+
* - All display output is sanitized to prevent terminal injection attacks
|
|
41
|
+
* - Story IDs are validated with sanitizeStoryId() then stripped with sanitizeForDisplay()
|
|
42
|
+
* - Error messages are generic to prevent information leakage
|
|
43
|
+
*
|
|
44
|
+
* @param targetStory - The story to check for conflicts
|
|
45
|
+
* @param sdlcRoot - Root directory of the .ai-sdlc folder (must be absolute, validated)
|
|
46
|
+
* @param options - Command options (force flag)
|
|
47
|
+
* @param options.force - Skip conflict check if true
|
|
48
|
+
* @returns PreFlightResult indicating whether to proceed and any warnings
|
|
49
|
+
* @throws Error if sdlcRoot is invalid (not absolute, null bytes, too long)
|
|
50
|
+
*/
|
|
51
|
+
export declare function preFlightConflictCheck(targetStory: Story, sdlcRoot: string, options: {
|
|
52
|
+
force?: boolean;
|
|
53
|
+
}): Promise<PreFlightResult>;
|
|
29
54
|
/**
|
|
30
55
|
* Run the workflow (process one action or all)
|
|
31
56
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../src/cli/commands.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,KAAK,EAAU,UAAU,EAA0H,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../src/cli/commands.ts"],"names":[],"mappings":"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"}
|
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,8 @@ 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';
|
|
18
20
|
/**
|
|
19
21
|
* Initialize the .ai-sdlc folder structure
|
|
20
22
|
*/
|
|
@@ -256,6 +258,160 @@ function displayGitValidationResult(result, c) {
|
|
|
256
258
|
}
|
|
257
259
|
}
|
|
258
260
|
}
|
|
261
|
+
// ANSI escape sequence patterns for sanitization
|
|
262
|
+
const ANSI_CSI_PATTERN = /\x1B\[[0-9;]*[a-zA-Z]/g;
|
|
263
|
+
const ANSI_OSC_BEL_PATTERN = /\x1B\][^\x07]*\x07/g;
|
|
264
|
+
const ANSI_OSC_ESC_PATTERN = /\x1B\][^\x1B]*\x1B\\/g;
|
|
265
|
+
const ANSI_SINGLE_CHAR_PATTERN = /\x1B./g;
|
|
266
|
+
const CONTROL_CHARS_PATTERN = /[\x00-\x1F\x7F-\x9F]/g;
|
|
267
|
+
/**
|
|
268
|
+
* Sanitize a string for safe display in the terminal.
|
|
269
|
+
* Strips ANSI escape sequences (CSI, OSC, single-char), control characters,
|
|
270
|
+
* and truncates extremely long strings to prevent DoS attacks.
|
|
271
|
+
*
|
|
272
|
+
* This uses the same comprehensive ANSI stripping patterns as sanitizeReasonText
|
|
273
|
+
* from src/core/story.ts for consistency.
|
|
274
|
+
*
|
|
275
|
+
* @param str - The string to sanitize
|
|
276
|
+
* @returns Sanitized string safe for terminal display (max 500 chars)
|
|
277
|
+
*/
|
|
278
|
+
function sanitizeForDisplay(str) {
|
|
279
|
+
const cleaned = str
|
|
280
|
+
.replace(ANSI_CSI_PATTERN, '') // CSI sequences (e.g., \x1B[31m)
|
|
281
|
+
.replace(ANSI_OSC_BEL_PATTERN, '') // OSC with BEL terminator (e.g., \x1B]...\x07)
|
|
282
|
+
.replace(ANSI_OSC_ESC_PATTERN, '') // OSC with ESC\ terminator (e.g., \x1B]...\x1B\\)
|
|
283
|
+
.replace(ANSI_SINGLE_CHAR_PATTERN, '') // Single-char escapes (e.g., \x1BH)
|
|
284
|
+
.replace(CONTROL_CHARS_PATTERN, ''); // Control characters (0x00-0x1F, 0x7F-0x9F)
|
|
285
|
+
// Truncate extremely long strings (DoS protection)
|
|
286
|
+
return cleaned.length > 500 ? cleaned.slice(0, 497) + '...' : cleaned;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Perform pre-flight conflict check before starting work on a story in a worktree.
|
|
290
|
+
* Warns about potential file conflicts with active stories and prompts for confirmation.
|
|
291
|
+
*
|
|
292
|
+
* **Race Condition (TOCTOU):** Multiple users can pass this check simultaneously
|
|
293
|
+
* before branches are created. This is an accepted risk - the window is small
|
|
294
|
+
* (~100ms) and git will catch conflicts during merge/PR creation. Adding file
|
|
295
|
+
* locks would significantly increase complexity for minimal security gain.
|
|
296
|
+
*
|
|
297
|
+
* **Security Notes:**
|
|
298
|
+
* - sdlcRoot is normalized and validated (absolute path, no null bytes, max 1024 chars)
|
|
299
|
+
* - All display output is sanitized to prevent terminal injection attacks
|
|
300
|
+
* - Story IDs are validated with sanitizeStoryId() then stripped with sanitizeForDisplay()
|
|
301
|
+
* - Error messages are generic to prevent information leakage
|
|
302
|
+
*
|
|
303
|
+
* @param targetStory - The story to check for conflicts
|
|
304
|
+
* @param sdlcRoot - Root directory of the .ai-sdlc folder (must be absolute, validated)
|
|
305
|
+
* @param options - Command options (force flag)
|
|
306
|
+
* @param options.force - Skip conflict check if true
|
|
307
|
+
* @returns PreFlightResult indicating whether to proceed and any warnings
|
|
308
|
+
* @throws Error if sdlcRoot is invalid (not absolute, null bytes, too long)
|
|
309
|
+
*/
|
|
310
|
+
export async function preFlightConflictCheck(targetStory, sdlcRoot, options) {
|
|
311
|
+
const config = loadConfig();
|
|
312
|
+
const c = getThemedChalk(config);
|
|
313
|
+
// Skip if --force flag
|
|
314
|
+
if (options.force) {
|
|
315
|
+
console.log(c.warning('⚠️ Skipping conflict check (--force)'));
|
|
316
|
+
return { proceed: true, warnings: ['Conflict check skipped'] };
|
|
317
|
+
}
|
|
318
|
+
// Validate sdlcRoot parameter (normalize first to prevent bypass attacks)
|
|
319
|
+
const normalizedPath = path.normalize(sdlcRoot);
|
|
320
|
+
if (!path.isAbsolute(normalizedPath)) {
|
|
321
|
+
throw new Error('Invalid project path');
|
|
322
|
+
}
|
|
323
|
+
if (normalizedPath.includes('\0')) {
|
|
324
|
+
throw new Error('Invalid project path');
|
|
325
|
+
}
|
|
326
|
+
if (normalizedPath.length > 1024) {
|
|
327
|
+
throw new Error('Invalid project path');
|
|
328
|
+
}
|
|
329
|
+
// Check if target story is already in-progress
|
|
330
|
+
if (targetStory.frontmatter.status === 'in-progress') {
|
|
331
|
+
console.log(c.error('❌ Story is already in-progress'));
|
|
332
|
+
return { proceed: false, warnings: ['Story already in progress'] };
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
// Query for all in-progress stories (excluding target)
|
|
336
|
+
// Use normalizedPath for all subsequent operations
|
|
337
|
+
const activeStories = findStoriesByStatus(normalizedPath, 'in-progress')
|
|
338
|
+
.filter(s => s.frontmatter.id !== targetStory.frontmatter.id);
|
|
339
|
+
if (activeStories.length === 0) {
|
|
340
|
+
console.log(c.success('✓ Conflict check: No overlapping files with active stories'));
|
|
341
|
+
return { proceed: true, warnings: [] };
|
|
342
|
+
}
|
|
343
|
+
// Run conflict detection (use normalizedPath)
|
|
344
|
+
const workingDir = path.dirname(normalizedPath);
|
|
345
|
+
const result = detectConflicts([targetStory, ...activeStories], workingDir, 'main');
|
|
346
|
+
// Filter conflicts involving target story
|
|
347
|
+
const relevantConflicts = result.conflicts.filter(conflict => conflict.storyA === targetStory.frontmatter.id || conflict.storyB === targetStory.frontmatter.id);
|
|
348
|
+
// Filter out 'none' severity conflicts (keep all displayable conflicts including low)
|
|
349
|
+
const displayableConflicts = relevantConflicts.filter(conflict => conflict.severity !== 'none');
|
|
350
|
+
if (displayableConflicts.length === 0) {
|
|
351
|
+
console.log(c.success('✓ Conflict check: No overlapping files with active stories'));
|
|
352
|
+
return { proceed: true, warnings: [] };
|
|
353
|
+
}
|
|
354
|
+
// Sort conflicts by severity (high -> medium -> low)
|
|
355
|
+
const severityOrder = { high: 0, medium: 1, low: 2, none: 3 };
|
|
356
|
+
const sortedConflicts = displayableConflicts.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
|
357
|
+
// Display conflicts
|
|
358
|
+
console.log();
|
|
359
|
+
console.log(c.warning('⚠️ Potential conflicts detected:'));
|
|
360
|
+
console.log();
|
|
361
|
+
for (const conflict of sortedConflicts) {
|
|
362
|
+
const otherStoryId = conflict.storyA === targetStory.frontmatter.id ? conflict.storyB : conflict.storyA;
|
|
363
|
+
// Two-stage sanitization: validate structure, then strip for display
|
|
364
|
+
try {
|
|
365
|
+
const validatedTargetId = sanitizeStoryId(targetStory.frontmatter.id);
|
|
366
|
+
const validatedOtherId = sanitizeStoryId(otherStoryId);
|
|
367
|
+
const sanitizedTargetId = sanitizeForDisplay(validatedTargetId);
|
|
368
|
+
const sanitizedOtherId = sanitizeForDisplay(validatedOtherId);
|
|
369
|
+
console.log(c.warning(` ${sanitizedTargetId} may conflict with ${sanitizedOtherId}:`));
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
// If validation fails, show generic error (defensive)
|
|
373
|
+
console.log(c.warning(` Story may have conflicting changes (invalid ID format)`));
|
|
374
|
+
}
|
|
375
|
+
// Display shared files
|
|
376
|
+
for (const file of conflict.sharedFiles) {
|
|
377
|
+
const severityLabel = conflict.severity === 'high' ? c.error('High') :
|
|
378
|
+
conflict.severity === 'medium' ? c.warning('Medium') :
|
|
379
|
+
c.info('Low');
|
|
380
|
+
const sanitizedFile = sanitizeForDisplay(file);
|
|
381
|
+
console.log(` - ${severityLabel}: ${sanitizedFile} (both stories modify this file)`);
|
|
382
|
+
}
|
|
383
|
+
// Display shared directories
|
|
384
|
+
for (const dir of conflict.sharedDirectories) {
|
|
385
|
+
const severityLabel = conflict.severity === 'high' ? c.error('High') :
|
|
386
|
+
conflict.severity === 'medium' ? c.warning('Medium') :
|
|
387
|
+
c.info('Low');
|
|
388
|
+
const sanitizedDir = sanitizeForDisplay(dir);
|
|
389
|
+
console.log(` - ${severityLabel}: ${sanitizedDir} (both stories modify files in this directory)`);
|
|
390
|
+
}
|
|
391
|
+
console.log();
|
|
392
|
+
const sanitizedRecommendation = sanitizeForDisplay(conflict.recommendation);
|
|
393
|
+
console.log(c.dim(` Recommendation: ${sanitizedRecommendation}`));
|
|
394
|
+
console.log();
|
|
395
|
+
}
|
|
396
|
+
// Non-interactive mode: default to declining
|
|
397
|
+
if (!process.stdin.isTTY) {
|
|
398
|
+
console.log(c.dim('Non-interactive mode: conflicts require --force to proceed'));
|
|
399
|
+
return { proceed: false, warnings: ['Conflicts detected'] };
|
|
400
|
+
}
|
|
401
|
+
// Interactive mode: prompt user
|
|
402
|
+
const shouldContinue = await confirmRemoval('Continue anyway?');
|
|
403
|
+
return {
|
|
404
|
+
proceed: shouldContinue,
|
|
405
|
+
warnings: shouldContinue ? ['User confirmed with conflicts'] : ['Conflicts detected']
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
// Fail-open: allow proceeding if conflict detection fails
|
|
410
|
+
console.log(c.warning('⚠️ Conflict detection unavailable'));
|
|
411
|
+
console.log(c.dim('Proceeding without conflict check...'));
|
|
412
|
+
return { proceed: true, warnings: ['Conflict detection failed'] };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
259
415
|
/**
|
|
260
416
|
* Run the workflow (process one action or all)
|
|
261
417
|
*/
|
|
@@ -537,6 +693,17 @@ export async function run(options) {
|
|
|
537
693
|
}
|
|
538
694
|
}
|
|
539
695
|
if (shouldUseWorktree && options.story && targetStory) {
|
|
696
|
+
// PRE-FLIGHT CHECK: Run conflict detection before creating worktree
|
|
697
|
+
const preFlightResult = await preFlightConflictCheck(targetStory, sdlcRoot, options);
|
|
698
|
+
if (!preFlightResult.proceed) {
|
|
699
|
+
console.log(c.error('❌ Aborting. Complete active stories first or use --force.'));
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
// Log warnings if user proceeded despite conflicts (skip internal flag messages)
|
|
703
|
+
if (preFlightResult.warnings.length > 0 && !preFlightResult.warnings.includes('Conflict check skipped')) {
|
|
704
|
+
preFlightResult.warnings.forEach(w => console.log(c.dim(` ⚠ ${w}`)));
|
|
705
|
+
console.log();
|
|
706
|
+
}
|
|
540
707
|
const workingDir = path.dirname(sdlcRoot);
|
|
541
708
|
// Check if story already has an existing worktree (resume scenario)
|
|
542
709
|
const existingWorktreePath = targetStory.frontmatter.worktree_path;
|
|
@@ -803,61 +970,83 @@ export async function run(options) {
|
|
|
803
970
|
async function executeAction(action, sdlcRoot) {
|
|
804
971
|
const config = loadConfig();
|
|
805
972
|
const c = getThemedChalk(config);
|
|
806
|
-
//
|
|
807
|
-
|
|
973
|
+
// Initialize per-story logger
|
|
974
|
+
const maxLogs = config.logging?.maxFiles ?? 5;
|
|
975
|
+
let logger = null;
|
|
976
|
+
let spinner = null;
|
|
808
977
|
try {
|
|
809
|
-
|
|
810
|
-
|
|
978
|
+
logger = new StoryLogger(action.storyId, sdlcRoot, maxLogs);
|
|
979
|
+
logger.log('INFO', `Starting action: ${action.type} for story ${action.storyId}`);
|
|
811
980
|
}
|
|
812
981
|
catch (error) {
|
|
813
|
-
|
|
814
|
-
console.
|
|
815
|
-
console.log(c.dim(` Original path: ${action.storyPath}`));
|
|
816
|
-
if (error instanceof Error) {
|
|
817
|
-
console.log(c.dim(` ${error.message}`));
|
|
818
|
-
}
|
|
819
|
-
return { success: false };
|
|
982
|
+
// If logger initialization fails, continue without logging (console-only)
|
|
983
|
+
console.warn(`Warning: Failed to initialize logger: ${error instanceof Error ? error.message : String(error)}`);
|
|
820
984
|
}
|
|
821
|
-
// Update action path if it was stale
|
|
822
|
-
if (resolvedPath !== action.storyPath) {
|
|
823
|
-
console.log(c.warning(`Note: Story path updated (file was moved)`));
|
|
824
|
-
console.log(c.dim(` From: ${action.storyPath}`));
|
|
825
|
-
console.log(c.dim(` To: ${resolvedPath}`));
|
|
826
|
-
action.storyPath = resolvedPath;
|
|
827
|
-
}
|
|
828
|
-
// Store phase completion state BEFORE action execution (to detect transitions)
|
|
829
|
-
const storyBeforeAction = parseStory(action.storyPath);
|
|
830
|
-
const prevPhaseState = {
|
|
831
|
-
research_complete: storyBeforeAction.frontmatter.research_complete,
|
|
832
|
-
plan_complete: storyBeforeAction.frontmatter.plan_complete,
|
|
833
|
-
implementation_complete: storyBeforeAction.frontmatter.implementation_complete,
|
|
834
|
-
reviews_complete: storyBeforeAction.frontmatter.reviews_complete,
|
|
835
|
-
status: storyBeforeAction.frontmatter.status,
|
|
836
|
-
};
|
|
837
|
-
const spinner = ora(formatAction(action, true, c)).start();
|
|
838
|
-
const baseText = formatAction(action, true, c);
|
|
839
|
-
// Create agent progress callback for real-time updates
|
|
840
|
-
const onAgentProgress = (event) => {
|
|
841
|
-
switch (event.type) {
|
|
842
|
-
case 'session_start':
|
|
843
|
-
spinner.text = `${baseText} ${c.dim('(session started)')}`;
|
|
844
|
-
break;
|
|
845
|
-
case 'tool_start':
|
|
846
|
-
// Show which tool is being executed
|
|
847
|
-
const toolName = event.toolName || 'unknown';
|
|
848
|
-
const shortName = toolName.replace(/^(mcp__|Mcp)/, '').substring(0, 30);
|
|
849
|
-
spinner.text = `${baseText} ${c.dim(`→ ${shortName}`)}`;
|
|
850
|
-
break;
|
|
851
|
-
case 'tool_end':
|
|
852
|
-
// Keep showing the action, tool completed
|
|
853
|
-
spinner.text = baseText;
|
|
854
|
-
break;
|
|
855
|
-
case 'completion':
|
|
856
|
-
spinner.text = `${baseText} ${c.dim('(completing...)')}`;
|
|
857
|
-
break;
|
|
858
|
-
}
|
|
859
|
-
};
|
|
860
985
|
try {
|
|
986
|
+
// Resolve story by ID to get current path (handles moves between folders)
|
|
987
|
+
let resolvedPath;
|
|
988
|
+
try {
|
|
989
|
+
const story = getStory(sdlcRoot, action.storyId);
|
|
990
|
+
resolvedPath = story.path;
|
|
991
|
+
}
|
|
992
|
+
catch (error) {
|
|
993
|
+
const errorMsg = `Error: Story not found for action "${action.type}"`;
|
|
994
|
+
logger?.log('ERROR', errorMsg);
|
|
995
|
+
logger?.log('ERROR', ` Story ID: ${action.storyId}`);
|
|
996
|
+
logger?.log('ERROR', ` Original path: ${action.storyPath}`);
|
|
997
|
+
console.log(c.error(errorMsg));
|
|
998
|
+
console.log(c.dim(` Story ID: ${action.storyId}`));
|
|
999
|
+
console.log(c.dim(` Original path: ${action.storyPath}`));
|
|
1000
|
+
if (error instanceof Error) {
|
|
1001
|
+
logger?.log('ERROR', ` ${error.message}`);
|
|
1002
|
+
console.log(c.dim(` ${error.message}`));
|
|
1003
|
+
}
|
|
1004
|
+
return { success: false };
|
|
1005
|
+
}
|
|
1006
|
+
// Update action path if it was stale
|
|
1007
|
+
if (resolvedPath !== action.storyPath) {
|
|
1008
|
+
logger?.log('WARN', `Note: Story path updated (file was moved)`);
|
|
1009
|
+
logger?.log('WARN', ` From: ${action.storyPath}`);
|
|
1010
|
+
logger?.log('WARN', ` To: ${resolvedPath}`);
|
|
1011
|
+
console.log(c.warning(`Note: Story path updated (file was moved)`));
|
|
1012
|
+
console.log(c.dim(` From: ${action.storyPath}`));
|
|
1013
|
+
console.log(c.dim(` To: ${resolvedPath}`));
|
|
1014
|
+
action.storyPath = resolvedPath;
|
|
1015
|
+
}
|
|
1016
|
+
// Store phase completion state BEFORE action execution (to detect transitions)
|
|
1017
|
+
const storyBeforeAction = parseStory(action.storyPath);
|
|
1018
|
+
const prevPhaseState = {
|
|
1019
|
+
research_complete: storyBeforeAction.frontmatter.research_complete,
|
|
1020
|
+
plan_complete: storyBeforeAction.frontmatter.plan_complete,
|
|
1021
|
+
implementation_complete: storyBeforeAction.frontmatter.implementation_complete,
|
|
1022
|
+
reviews_complete: storyBeforeAction.frontmatter.reviews_complete,
|
|
1023
|
+
status: storyBeforeAction.frontmatter.status,
|
|
1024
|
+
};
|
|
1025
|
+
spinner = ora(formatAction(action, true, c)).start();
|
|
1026
|
+
const baseText = formatAction(action, true, c);
|
|
1027
|
+
// Create agent progress callback for real-time updates
|
|
1028
|
+
const onAgentProgress = (event) => {
|
|
1029
|
+
if (!spinner)
|
|
1030
|
+
return; // Guard against null spinner
|
|
1031
|
+
switch (event.type) {
|
|
1032
|
+
case 'session_start':
|
|
1033
|
+
spinner.text = `${baseText} ${c.dim('(session started)')}`;
|
|
1034
|
+
break;
|
|
1035
|
+
case 'tool_start':
|
|
1036
|
+
// Show which tool is being executed
|
|
1037
|
+
const toolName = event.toolName || 'unknown';
|
|
1038
|
+
const shortName = toolName.replace(/^(mcp__|Mcp)/, '').substring(0, 30);
|
|
1039
|
+
spinner.text = `${baseText} ${c.dim(`→ ${shortName}`)}`;
|
|
1040
|
+
break;
|
|
1041
|
+
case 'tool_end':
|
|
1042
|
+
// Keep showing the action, tool completed
|
|
1043
|
+
spinner.text = baseText;
|
|
1044
|
+
break;
|
|
1045
|
+
case 'completion':
|
|
1046
|
+
spinner.text = `${baseText} ${c.dim('(completing...)')}`;
|
|
1047
|
+
break;
|
|
1048
|
+
}
|
|
1049
|
+
};
|
|
861
1050
|
// Import and run the appropriate agent
|
|
862
1051
|
let result;
|
|
863
1052
|
switch (action.type) {
|
|
@@ -881,6 +1070,8 @@ async function executeAction(action, sdlcRoot) {
|
|
|
881
1070
|
const { runReviewAgent } = await import('../agents/review.js');
|
|
882
1071
|
result = await runReviewAgent(action.storyPath, sdlcRoot, {
|
|
883
1072
|
onVerificationProgress: (phase, status, message) => {
|
|
1073
|
+
if (!spinner)
|
|
1074
|
+
return; // Guard against null spinner
|
|
884
1075
|
const phaseLabel = phase === 'build' ? 'Building' : 'Testing';
|
|
885
1076
|
switch (status) {
|
|
886
1077
|
case 'starting':
|
|
@@ -931,15 +1122,19 @@ async function executeAction(action, sdlcRoot) {
|
|
|
931
1122
|
// Check if agent succeeded
|
|
932
1123
|
if (result && !result.success) {
|
|
933
1124
|
spinner.fail(c.error(`Failed: ${formatAction(action, true, c)}`));
|
|
1125
|
+
logger?.log('ERROR', `Action failed: ${formatAction(action, false, c)}`);
|
|
934
1126
|
if (result.error) {
|
|
1127
|
+
logger?.log('ERROR', ` Error: ${result.error}`);
|
|
935
1128
|
console.error(c.error(` Error: ${result.error}`));
|
|
936
1129
|
}
|
|
937
1130
|
return { success: false };
|
|
938
1131
|
}
|
|
939
1132
|
spinner.succeed(c.success(formatAction(action, true, c)));
|
|
1133
|
+
logger?.log('INFO', `Action completed successfully: ${formatAction(action, false, c)}`);
|
|
940
1134
|
// Show changes made
|
|
941
1135
|
if (result && result.changesMade.length > 0) {
|
|
942
1136
|
for (const change of result.changesMade) {
|
|
1137
|
+
logger?.log('INFO', ` → ${change}`);
|
|
943
1138
|
console.log(c.dim(` → ${change}`));
|
|
944
1139
|
}
|
|
945
1140
|
}
|
|
@@ -995,14 +1190,21 @@ async function executeAction(action, sdlcRoot) {
|
|
|
995
1190
|
return { success: true };
|
|
996
1191
|
}
|
|
997
1192
|
catch (error) {
|
|
998
|
-
spinner
|
|
1193
|
+
if (spinner) {
|
|
1194
|
+
spinner.fail(c.error(`Failed: ${formatAction(action, true, c)}`));
|
|
1195
|
+
}
|
|
1196
|
+
else {
|
|
1197
|
+
console.error(c.error(`Failed: ${formatAction(action, true, c)}`));
|
|
1198
|
+
}
|
|
1199
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1200
|
+
logger?.log('ERROR', `Exception during action execution: ${errorMessage}`);
|
|
999
1201
|
console.error(error);
|
|
1000
1202
|
// Show phase checklist with error indication (if file still exists)
|
|
1001
1203
|
try {
|
|
1002
1204
|
const story = parseStory(action.storyPath);
|
|
1003
1205
|
console.log(c.dim(` Progress: ${renderPhaseChecklist(story, c)}`));
|
|
1004
1206
|
// Update story with error
|
|
1005
|
-
story.frontmatter.last_error =
|
|
1207
|
+
story.frontmatter.last_error = errorMessage;
|
|
1006
1208
|
}
|
|
1007
1209
|
catch {
|
|
1008
1210
|
// File may have been moved - skip progress display
|
|
@@ -1010,6 +1212,10 @@ async function executeAction(action, sdlcRoot) {
|
|
|
1010
1212
|
// Don't throw - let the workflow continue if in auto mode
|
|
1011
1213
|
return { success: false };
|
|
1012
1214
|
}
|
|
1215
|
+
finally {
|
|
1216
|
+
// Always close logger, even if action fails or throws
|
|
1217
|
+
logger?.close();
|
|
1218
|
+
}
|
|
1013
1219
|
}
|
|
1014
1220
|
/**
|
|
1015
1221
|
* Get phase information for an action type
|
|
@@ -1546,12 +1752,15 @@ export async function migrate(options) {
|
|
|
1546
1752
|
* Helper function to prompt for removal confirmation
|
|
1547
1753
|
*/
|
|
1548
1754
|
async function confirmRemoval(message) {
|
|
1755
|
+
// Sanitize message to prevent terminal injection attacks
|
|
1756
|
+
// Use consistent sanitizeForDisplay() for all terminal output
|
|
1757
|
+
const sanitizedMessage = sanitizeForDisplay(message);
|
|
1549
1758
|
const rl = readline.createInterface({
|
|
1550
1759
|
input: process.stdin,
|
|
1551
1760
|
output: process.stdout,
|
|
1552
1761
|
});
|
|
1553
1762
|
return new Promise((resolve) => {
|
|
1554
|
-
rl.question(
|
|
1763
|
+
rl.question(sanitizedMessage + ' (y/N): ', (answer) => {
|
|
1555
1764
|
rl.close();
|
|
1556
1765
|
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
1557
1766
|
});
|