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.
@@ -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":"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"}
1
+ {"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../src/cli/commands.ts"],"names":[],"mappings":"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"}
@@ -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
- // Resolve story by ID to get current path (handles moves between folders)
807
- let resolvedPath;
973
+ // Initialize per-story logger
974
+ const maxLogs = config.logging?.maxFiles ?? 5;
975
+ let logger = null;
976
+ let spinner = null;
808
977
  try {
809
- const story = getStory(sdlcRoot, action.storyId);
810
- resolvedPath = story.path;
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
- console.log(c.error(`Error: Story not found for action "${action.type}"`));
814
- console.log(c.dim(` Story ID: ${action.storyId}`));
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.fail(c.error(`Failed: ${formatAction(action, true, c)}`));
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 = error instanceof Error ? error.message : String(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(message + ' (y/N): ', (answer) => {
1763
+ rl.question(sanitizedMessage + ' (y/N): ', (answer) => {
1555
1764
  rl.close();
1556
1765
  resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
1557
1766
  });