ai-sdlc 0.2.1-alpha.1 → 0.3.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.
Files changed (51) hide show
  1. package/dist/agents/implementation.d.ts +30 -1
  2. package/dist/agents/implementation.d.ts.map +1 -1
  3. package/dist/agents/implementation.js +108 -17
  4. package/dist/agents/implementation.js.map +1 -1
  5. package/dist/agents/review.d.ts +27 -1
  6. package/dist/agents/review.d.ts.map +1 -1
  7. package/dist/agents/review.js +181 -19
  8. package/dist/agents/review.js.map +1 -1
  9. package/dist/agents/rework.d.ts.map +1 -1
  10. package/dist/agents/rework.js +3 -1
  11. package/dist/agents/rework.js.map +1 -1
  12. package/dist/agents/verification.d.ts.map +1 -1
  13. package/dist/agents/verification.js +26 -12
  14. package/dist/agents/verification.js.map +1 -1
  15. package/dist/cli/commands.d.ts +7 -0
  16. package/dist/cli/commands.d.ts.map +1 -1
  17. package/dist/cli/commands.js +539 -46
  18. package/dist/cli/commands.js.map +1 -1
  19. package/dist/cli/daemon.d.ts.map +1 -1
  20. package/dist/cli/daemon.js +5 -0
  21. package/dist/cli/daemon.js.map +1 -1
  22. package/dist/cli/runner.d.ts.map +1 -1
  23. package/dist/cli/runner.js +17 -2
  24. package/dist/cli/runner.js.map +1 -1
  25. package/dist/core/client.d.ts +19 -1
  26. package/dist/core/client.d.ts.map +1 -1
  27. package/dist/core/client.js +191 -5
  28. package/dist/core/client.js.map +1 -1
  29. package/dist/core/config.d.ts +9 -1
  30. package/dist/core/config.d.ts.map +1 -1
  31. package/dist/core/config.js +47 -0
  32. package/dist/core/config.js.map +1 -1
  33. package/dist/core/process-manager.d.ts +15 -0
  34. package/dist/core/process-manager.d.ts.map +1 -0
  35. package/dist/core/process-manager.js +132 -0
  36. package/dist/core/process-manager.js.map +1 -0
  37. package/dist/core/story.d.ts +24 -0
  38. package/dist/core/story.d.ts.map +1 -1
  39. package/dist/core/story.js +74 -0
  40. package/dist/core/story.js.map +1 -1
  41. package/dist/core/worktree.d.ts +111 -2
  42. package/dist/core/worktree.d.ts.map +1 -1
  43. package/dist/core/worktree.js +310 -2
  44. package/dist/core/worktree.js.map +1 -1
  45. package/dist/index.js +10 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/types/index.d.ts +47 -0
  48. package/dist/types/index.d.ts.map +1 -1
  49. package/dist/types/index.js.map +1 -1
  50. package/package.json +2 -2
  51. package/templates/story.md +5 -0
@@ -2,10 +2,11 @@ import ora from 'ora';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
4
  import * as readline from 'readline';
5
+ import { spawnSync } from 'child_process';
5
6
  import { getSdlcRoot, loadConfig, initConfig, validateWorktreeBasePath, DEFAULT_WORKTREE_CONFIG } from '../core/config.js';
6
7
  import { initializeKanban, kanbanExists, assessState, getBoardStats, findStoryBySlug, findStoriesByStatus } from '../core/kanban.js';
7
- import { createStory, parseStory, resetRPIVCycle, isAtMaxRetries, unblockStory, getStory, findStoryById, updateStoryField, writeStory, sanitizeStoryId, autoCompleteStoryAfterReview } from '../core/story.js';
8
- import { GitWorktreeService } from '../core/worktree.js';
8
+ import { createStory, parseStory, resetRPIVCycle, isAtMaxRetries, unblockStory, getStory, findStoryById, updateStoryField, writeStory, sanitizeStoryId, autoCompleteStoryAfterReview, incrementImplementationRetryCount, getEffectiveMaxImplementationRetries, isAtMaxImplementationRetries, updateStoryStatus } from '../core/story.js';
9
+ import { GitWorktreeService, getLastCompletedPhase, getNextPhase } from '../core/worktree.js';
9
10
  import { ReviewDecision } from '../types/index.js';
10
11
  import { getThemedChalk } from '../core/theme.js';
11
12
  import { saveWorkflowState, loadWorkflowState, clearWorkflowState, generateWorkflowId, calculateStoryHash, hasWorkflowState, } from '../core/workflow-state.js';
@@ -18,6 +19,13 @@ import { validateGitState } from '../core/git-utils.js';
18
19
  import { StoryLogger } from '../core/story-logger.js';
19
20
  import { detectConflicts } from '../core/conflict-detector.js';
20
21
  import { getLogger } from '../core/logger.js';
22
+ /**
23
+ * Branch divergence threshold for warnings
24
+ * When a worktree branch has diverged by more than this number of commits
25
+ * from the base branch (ahead or behind), a warning will be displayed
26
+ * suggesting the user rebase to sync with latest changes.
27
+ */
28
+ const DIVERGENCE_WARNING_THRESHOLD = 10;
21
29
  /**
22
30
  * Initialize the .ai-sdlc folder structure
23
31
  */
@@ -380,6 +388,51 @@ function displayGitValidationResult(result, c) {
380
388
  }
381
389
  }
382
390
  }
391
+ /**
392
+ * Display detailed information about an existing worktree
393
+ */
394
+ function displayExistingWorktreeInfo(status, c) {
395
+ console.log();
396
+ console.log(c.warning('A worktree already exists for this story:'));
397
+ console.log();
398
+ console.log(c.bold(' Worktree Path:'), status.path);
399
+ console.log(c.bold(' Branch: '), status.branch);
400
+ if (status.lastCommit) {
401
+ console.log(c.bold(' Last Commit: '), `${status.lastCommit.hash.substring(0, 7)} - ${status.lastCommit.message}`);
402
+ console.log(c.bold(' Committed: '), status.lastCommit.timestamp);
403
+ }
404
+ const statusLabel = status.workingDirectoryStatus === 'clean'
405
+ ? c.success('clean')
406
+ : c.warning(status.workingDirectoryStatus);
407
+ console.log(c.bold(' Working Dir: '), statusLabel);
408
+ if (status.modifiedFiles.length > 0) {
409
+ console.log();
410
+ console.log(c.warning(' Modified files:'));
411
+ for (const file of status.modifiedFiles.slice(0, 5)) {
412
+ console.log(c.dim(` M ${file}`));
413
+ }
414
+ if (status.modifiedFiles.length > 5) {
415
+ console.log(c.dim(` ... and ${status.modifiedFiles.length - 5} more`));
416
+ }
417
+ }
418
+ if (status.untrackedFiles.length > 0) {
419
+ console.log();
420
+ console.log(c.warning(' Untracked files:'));
421
+ for (const file of status.untrackedFiles.slice(0, 5)) {
422
+ console.log(c.dim(` ? ${file}`));
423
+ }
424
+ if (status.untrackedFiles.length > 5) {
425
+ console.log(c.dim(` ... and ${status.untrackedFiles.length - 5} more`));
426
+ }
427
+ }
428
+ console.log();
429
+ console.log(c.info('To resume work in this worktree:'));
430
+ console.log(c.dim(` cd ${status.path}`));
431
+ console.log();
432
+ console.log(c.info('To remove the worktree and start fresh:'));
433
+ console.log(c.dim(` ai-sdlc worktrees remove ${status.storyId}`));
434
+ console.log();
435
+ }
383
436
  // ANSI escape sequence patterns for sanitization
384
437
  const ANSI_CSI_PATTERN = /\x1B\[[0-9;]*[a-zA-Z]/g;
385
438
  const ANSI_OSC_BEL_PATTERN = /\x1B\][^\x07]*\x07/g;
@@ -448,8 +501,8 @@ export async function preFlightConflictCheck(targetStory, sdlcRoot, options) {
448
501
  if (normalizedPath.length > 1024) {
449
502
  throw new Error('Invalid project path');
450
503
  }
451
- // Check if target story is already in-progress
452
- if (targetStory.frontmatter.status === 'in-progress') {
504
+ // Check if target story is already in-progress (allow if resuming existing worktree)
505
+ if (targetStory.frontmatter.status === 'in-progress' && !targetStory.frontmatter.worktree_path) {
453
506
  console.log(c.error('❌ Story is already in-progress'));
454
507
  return { proceed: false, warnings: ['Story already in progress'] };
455
508
  }
@@ -554,6 +607,7 @@ export async function run(options) {
554
607
  step: options.step,
555
608
  watch: options.watch,
556
609
  worktree: options.worktree,
610
+ clean: options.clean,
557
611
  force: options.force,
558
612
  });
559
613
  // Migrate global workflow state to story-specific location if needed
@@ -839,17 +893,154 @@ export async function run(options) {
839
893
  }
840
894
  const workingDir = path.dirname(sdlcRoot);
841
895
  // Check if story already has an existing worktree (resume scenario)
896
+ // Note: We check only if existingWorktreePath is set, not if it exists.
897
+ // The validation logic will handle missing directories/branches.
842
898
  const existingWorktreePath = targetStory.frontmatter.worktree_path;
843
- if (existingWorktreePath && fs.existsSync(existingWorktreePath)) {
899
+ if (existingWorktreePath) {
900
+ // Validate worktree before resuming
901
+ const resolvedBasePath = validateWorktreeBasePath(worktreeConfig.basePath, workingDir);
902
+ // Security validation: ensure worktree_path is within the configured base directory
903
+ const absoluteWorktreePath = path.resolve(existingWorktreePath);
904
+ const absoluteBasePath = path.resolve(resolvedBasePath);
905
+ if (!absoluteWorktreePath.startsWith(absoluteBasePath)) {
906
+ console.log(c.error('Security Error: worktree_path is outside configured base directory'));
907
+ console.log(c.dim(` Worktree path: ${absoluteWorktreePath}`));
908
+ console.log(c.dim(` Expected base: ${absoluteBasePath}`));
909
+ return;
910
+ }
911
+ // Warn if story is marked as done but has an existing worktree
912
+ if (targetStory.frontmatter.status === 'done') {
913
+ console.log(c.warning('⚠ Story is marked as done but has an existing worktree'));
914
+ console.log(c.dim(' This may be a stale worktree that should be cleaned up.'));
915
+ console.log();
916
+ // Prompt user for confirmation to proceed
917
+ const rl = readline.createInterface({
918
+ input: process.stdin,
919
+ output: process.stdout,
920
+ });
921
+ const answer = await new Promise((resolve) => {
922
+ rl.question(c.dim('Continue with this worktree? (y/N): '), (ans) => {
923
+ rl.close();
924
+ resolve(ans.toLowerCase().trim());
925
+ });
926
+ });
927
+ if (answer !== 'y' && answer !== 'yes') {
928
+ console.log(c.dim('Aborted. Consider removing the worktree_path from the story frontmatter.'));
929
+ return;
930
+ }
931
+ console.log();
932
+ }
933
+ const worktreeService = new GitWorktreeService(workingDir, resolvedBasePath);
934
+ const branchName = worktreeService.getBranchName(targetStory.frontmatter.id, targetStory.slug);
935
+ const validation = worktreeService.validateWorktreeForResume(existingWorktreePath, branchName);
936
+ if (!validation.canResume) {
937
+ console.log(c.error('Cannot resume worktree:'));
938
+ validation.issues.forEach(issue => console.log(c.dim(` ✗ ${issue}`)));
939
+ if (validation.requiresRecreation) {
940
+ const branchExists = !validation.issues.includes('Branch does not exist');
941
+ const dirMissing = validation.issues.includes('Worktree directory does not exist');
942
+ const dirExists = !dirMissing;
943
+ // Case 1: Directory missing but branch exists - recreate worktree from existing branch
944
+ // Case 2: Directory exists but branch missing - recreate with new branch
945
+ if ((branchExists && dirMissing) || (!branchExists && dirExists)) {
946
+ const reason = branchExists
947
+ ? 'Branch exists - automatically recreating worktree directory'
948
+ : 'Directory exists - automatically recreating worktree with new branch';
949
+ console.log(c.dim(`\n✓ ${reason}`));
950
+ try {
951
+ // Remove the old worktree reference if it exists
952
+ const removeResult = spawnSync('git', ['worktree', 'remove', existingWorktreePath, '--force'], {
953
+ cwd: workingDir,
954
+ encoding: 'utf-8',
955
+ shell: false,
956
+ stdio: ['ignore', 'pipe', 'pipe'],
957
+ });
958
+ // Create the worktree at the same path
959
+ // If branch exists, checkout that branch; otherwise create a new branch
960
+ const baseBranch = worktreeService.detectBaseBranch();
961
+ const worktreeAddArgs = branchExists
962
+ ? ['worktree', 'add', existingWorktreePath, branchName]
963
+ : ['worktree', 'add', '-b', branchName, existingWorktreePath, baseBranch];
964
+ const addResult = spawnSync('git', worktreeAddArgs, {
965
+ cwd: workingDir,
966
+ encoding: 'utf-8',
967
+ shell: false,
968
+ stdio: ['ignore', 'pipe', 'pipe'],
969
+ });
970
+ if (addResult.status !== 0) {
971
+ throw new Error(`Failed to recreate worktree: ${addResult.stderr}`);
972
+ }
973
+ // Install dependencies in the recreated worktree
974
+ worktreeService.installDependencies(existingWorktreePath);
975
+ console.log(c.success(`✓ Worktree recreated at ${existingWorktreePath}`));
976
+ getLogger().info('worktree', `Recreated worktree for ${targetStory.frontmatter.id} at ${existingWorktreePath}`);
977
+ }
978
+ catch (error) {
979
+ console.log(c.error(`Failed to recreate worktree: ${error instanceof Error ? error.message : String(error)}`));
980
+ console.log(c.dim('Please manually remove the worktree_path from the story frontmatter and try again.'));
981
+ return;
982
+ }
983
+ }
984
+ else {
985
+ console.log(c.dim('\nWorktree needs manual intervention. Please remove the worktree_path from the story frontmatter and try again.'));
986
+ return;
987
+ }
988
+ }
989
+ else {
990
+ return;
991
+ }
992
+ }
844
993
  // Reuse existing worktree
845
994
  originalCwd = process.cwd();
846
995
  worktreePath = existingWorktreePath;
847
996
  process.chdir(worktreePath);
848
997
  sdlcRoot = getSdlcRoot();
849
998
  worktreeCreated = true;
999
+ // Re-load story from worktree context to get current state
1000
+ const worktreeStory = findStoryById(sdlcRoot, targetStory.frontmatter.id);
1001
+ if (worktreeStory) {
1002
+ targetStory = worktreeStory;
1003
+ }
1004
+ // Get phase information for resume context
1005
+ const lastPhase = getLastCompletedPhase(targetStory);
1006
+ const nextPhase = getNextPhase(targetStory);
1007
+ // Get worktree status for uncommitted changes info
1008
+ const worktreeInfo = {
1009
+ path: existingWorktreePath,
1010
+ branch: branchName,
1011
+ storyId: targetStory.frontmatter.id,
1012
+ exists: true,
1013
+ };
1014
+ const worktreeStatus = worktreeService.getWorktreeStatus(worktreeInfo);
1015
+ // Check branch divergence
1016
+ const divergence = worktreeService.checkBranchDivergence(branchName);
850
1017
  console.log(c.success(`✓ Resuming in existing worktree: ${worktreePath}`));
851
- console.log(c.dim(` Branch: ai-sdlc/${targetStory.frontmatter.id}-${targetStory.slug}`));
1018
+ console.log(c.dim(` Branch: ${branchName}`));
1019
+ if (lastPhase) {
1020
+ console.log(c.dim(` Last completed phase: ${lastPhase}`));
1021
+ }
1022
+ if (nextPhase) {
1023
+ console.log(c.dim(` Next phase: ${nextPhase}`));
1024
+ }
1025
+ // Display uncommitted changes if present
1026
+ if (worktreeStatus.workingDirectoryStatus !== 'clean') {
1027
+ const totalChanges = worktreeStatus.modifiedFiles.length + worktreeStatus.untrackedFiles.length;
1028
+ console.log(c.dim(` Uncommitted changes: ${totalChanges} file(s)`));
1029
+ if (worktreeStatus.modifiedFiles.length > 0) {
1030
+ console.log(c.dim(` Modified: ${worktreeStatus.modifiedFiles.slice(0, 3).join(', ')}${worktreeStatus.modifiedFiles.length > 3 ? '...' : ''}`));
1031
+ }
1032
+ if (worktreeStatus.untrackedFiles.length > 0) {
1033
+ console.log(c.dim(` Untracked: ${worktreeStatus.untrackedFiles.slice(0, 3).join(', ')}${worktreeStatus.untrackedFiles.length > 3 ? '...' : ''}`));
1034
+ }
1035
+ }
1036
+ // Warn if branch has diverged significantly
1037
+ if (divergence.diverged && (divergence.ahead > DIVERGENCE_WARNING_THRESHOLD || divergence.behind > DIVERGENCE_WARNING_THRESHOLD)) {
1038
+ console.log(c.warning(` ⚠ Branch has diverged from base: ${divergence.ahead} ahead, ${divergence.behind} behind`));
1039
+ console.log(c.dim(` Consider rebasing to sync with latest changes`));
1040
+ }
852
1041
  console.log();
1042
+ // Log resume event
1043
+ getLogger().info('worktree', `Resumed worktree for ${targetStory.frontmatter.id} at ${worktreePath}`);
853
1044
  }
854
1045
  else {
855
1046
  // Create new worktree
@@ -864,49 +1055,260 @@ export async function run(options) {
864
1055
  return;
865
1056
  }
866
1057
  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;
1058
+ // Check for existing worktree NOT recorded in story frontmatter
1059
+ // This catches scenarios where workflow was interrupted after worktree creation
1060
+ // but before the story file was updated
1061
+ const existingWorktree = worktreeService.findByStoryId(targetStory.frontmatter.id);
1062
+ let shouldCreateNewWorktree = !existingWorktree || !existingWorktree.exists;
1063
+ if (existingWorktree && existingWorktree.exists) {
1064
+ // Handle --clean flag: cleanup and restart
1065
+ if (options.clean) {
1066
+ console.log(c.warning('Existing worktree found - cleaning up before restart...'));
1067
+ console.log();
1068
+ const worktreeStatus = worktreeService.getWorktreeStatus(existingWorktree);
1069
+ const unpushedResult = worktreeService.hasUnpushedCommits(existingWorktree.path);
1070
+ const commitCount = worktreeService.getCommitCount(existingWorktree.path);
1071
+ const branchOnRemote = worktreeService.branchExistsOnRemote(existingWorktree.branch);
1072
+ // Display summary of what will be deleted
1073
+ console.log(c.bold('Cleanup Summary:'));
1074
+ console.log(c.dim('─'.repeat(60)));
1075
+ console.log(`${c.dim('Worktree Path:')} ${worktreeStatus.path}`);
1076
+ console.log(`${c.dim('Branch:')} ${worktreeStatus.branch}`);
1077
+ console.log(`${c.dim('Total Commits:')} ${commitCount}`);
1078
+ console.log(`${c.dim('Unpushed Commits:')} ${unpushedResult.hasUnpushed ? c.warning(unpushedResult.count.toString()) : c.success('0')}`);
1079
+ console.log(`${c.dim('Modified Files:')} ${worktreeStatus.modifiedFiles.length > 0 ? c.warning(worktreeStatus.modifiedFiles.length.toString()) : c.success('0')}`);
1080
+ console.log(`${c.dim('Untracked Files:')} ${worktreeStatus.untrackedFiles.length > 0 ? c.warning(worktreeStatus.untrackedFiles.length.toString()) : c.success('0')}`);
1081
+ console.log(`${c.dim('Remote Branch:')} ${branchOnRemote ? c.warning('EXISTS') : c.dim('none')}`);
1082
+ console.log();
1083
+ // Warn about data loss
1084
+ if (worktreeStatus.modifiedFiles.length > 0 || worktreeStatus.untrackedFiles.length > 0 || unpushedResult.hasUnpushed) {
1085
+ console.log(c.error('⚠ WARNING: This will DELETE all uncommitted and unpushed work!'));
1086
+ console.log();
1087
+ }
1088
+ // Check for --force flag to skip confirmation
1089
+ const forceCleanup = options.force;
1090
+ if (!forceCleanup) {
1091
+ // Prompt for confirmation
1092
+ const confirmed = await new Promise((resolve) => {
1093
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1094
+ rl.question(c.warning('Are you sure you want to proceed? (y/N): '), (answer) => {
1095
+ rl.close();
1096
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
1097
+ });
1098
+ });
1099
+ if (!confirmed) {
1100
+ console.log(c.info('Cleanup cancelled.'));
1101
+ return;
1102
+ }
1103
+ }
1104
+ console.log();
1105
+ const cleanupSpinner = ora('Cleaning up worktree...').start();
1106
+ try {
1107
+ // Remove worktree (force remove to handle uncommitted changes)
1108
+ const forceRemove = worktreeStatus.modifiedFiles.length > 0 || worktreeStatus.untrackedFiles.length > 0;
1109
+ worktreeService.remove(existingWorktree.path, forceRemove);
1110
+ cleanupSpinner.text = 'Worktree removed, deleting branch...';
1111
+ // Delete local branch
1112
+ worktreeService.deleteBranch(existingWorktree.branch, true);
1113
+ // Optionally delete remote branch if it exists
1114
+ if (branchOnRemote) {
1115
+ if (!forceCleanup) {
1116
+ cleanupSpinner.stop();
1117
+ const deleteRemote = await new Promise((resolve) => {
1118
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1119
+ rl.question(c.warning('Branch exists on remote. Delete it too? (y/N): '), (answer) => {
1120
+ rl.close();
1121
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
1122
+ });
1123
+ });
1124
+ if (deleteRemote) {
1125
+ cleanupSpinner.start('Deleting remote branch...');
1126
+ worktreeService.deleteRemoteBranch(existingWorktree.branch);
1127
+ }
1128
+ cleanupSpinner.start();
1129
+ }
1130
+ else {
1131
+ // --force provided, skip remote deletion by default (safer)
1132
+ cleanupSpinner.text = 'Skipping remote branch deletion (use manual cleanup if needed)';
1133
+ }
1134
+ }
1135
+ // Reset story workflow state
1136
+ cleanupSpinner.text = 'Resetting story state...';
1137
+ const { resetWorkflowState } = await import('../core/story.js');
1138
+ targetStory = await resetWorkflowState(targetStory);
1139
+ // Clear workflow checkpoint if exists
1140
+ if (hasWorkflowState(sdlcRoot, targetStory.frontmatter.id)) {
1141
+ await clearWorkflowState(sdlcRoot, targetStory.frontmatter.id);
1142
+ }
1143
+ cleanupSpinner.succeed(c.success('✓ Cleanup complete - ready to create fresh worktree'));
1144
+ console.log();
1145
+ }
1146
+ catch (error) {
1147
+ cleanupSpinner.fail(c.error('Cleanup failed'));
1148
+ console.log(c.error(`Error: ${error instanceof Error ? error.message : String(error)}`));
1149
+ return;
1150
+ }
1151
+ // After cleanup, create a fresh worktree
1152
+ shouldCreateNewWorktree = true;
1153
+ }
1154
+ else {
1155
+ // Not cleaning - resume in existing worktree (S-0063 feature)
1156
+ getLogger().info('worktree', `Detected existing worktree for ${targetStory.frontmatter.id} at ${existingWorktree.path}`);
1157
+ // Validate the existing worktree before resuming
1158
+ const branchName = worktreeService.getBranchName(targetStory.frontmatter.id, targetStory.slug);
1159
+ const validation = worktreeService.validateWorktreeForResume(existingWorktree.path, branchName);
1160
+ if (!validation.canResume) {
1161
+ console.log(c.error('Detected existing worktree but cannot resume:'));
1162
+ validation.issues.forEach(issue => console.log(c.dim(` ✗ ${issue}`)));
1163
+ if (validation.requiresRecreation) {
1164
+ const branchExists = !validation.issues.includes('Branch does not exist');
1165
+ const dirMissing = validation.issues.includes('Worktree directory does not exist');
1166
+ const dirExists = !dirMissing;
1167
+ // Case 1: Directory missing but branch exists - recreate worktree from existing branch
1168
+ // Case 2: Directory exists but branch missing - recreate with new branch
1169
+ if ((branchExists && dirMissing) || (!branchExists && dirExists)) {
1170
+ const reason = branchExists
1171
+ ? 'Branch exists - automatically recreating worktree directory'
1172
+ : 'Directory exists - automatically recreating worktree with new branch';
1173
+ console.log(c.dim(`\n✓ ${reason}`));
1174
+ try {
1175
+ // Remove the old worktree reference if it exists
1176
+ const removeResult = spawnSync('git', ['worktree', 'remove', existingWorktree.path, '--force'], {
1177
+ cwd: workingDir,
1178
+ encoding: 'utf-8',
1179
+ shell: false,
1180
+ stdio: ['ignore', 'pipe', 'pipe'],
1181
+ });
1182
+ // Create the worktree at the same path
1183
+ // If branch exists, checkout that branch; otherwise create a new branch
1184
+ const baseBranch = worktreeService.detectBaseBranch();
1185
+ const worktreeAddArgs = branchExists
1186
+ ? ['worktree', 'add', existingWorktree.path, branchName]
1187
+ : ['worktree', 'add', '-b', branchName, existingWorktree.path, baseBranch];
1188
+ const addResult = spawnSync('git', worktreeAddArgs, {
1189
+ cwd: workingDir,
1190
+ encoding: 'utf-8',
1191
+ shell: false,
1192
+ stdio: ['ignore', 'pipe', 'pipe'],
1193
+ });
1194
+ if (addResult.status !== 0) {
1195
+ throw new Error(`Failed to recreate worktree: ${addResult.stderr}`);
1196
+ }
1197
+ // Install dependencies in the recreated worktree
1198
+ worktreeService.installDependencies(existingWorktree.path);
1199
+ console.log(c.success(`✓ Worktree recreated at ${existingWorktree.path}`));
1200
+ getLogger().info('worktree', `Recreated worktree for ${targetStory.frontmatter.id} at ${existingWorktree.path}`);
1201
+ }
1202
+ catch (error) {
1203
+ console.log(c.error(`Failed to recreate worktree: ${error instanceof Error ? error.message : String(error)}`));
1204
+ console.log(c.dim('Please manually remove it with:'));
1205
+ console.log(c.dim(` git worktree remove ${existingWorktree.path}`));
1206
+ return;
1207
+ }
1208
+ }
1209
+ else {
1210
+ console.log(c.dim('\nWorktree needs manual intervention. Please remove it manually with:'));
1211
+ console.log(c.dim(` git worktree remove ${existingWorktree.path}`));
1212
+ return;
1213
+ }
1214
+ }
1215
+ else {
1216
+ return;
1217
+ }
1218
+ }
1219
+ // Automatically resume in the existing worktree
1220
+ originalCwd = process.cwd();
1221
+ worktreePath = existingWorktree.path;
1222
+ process.chdir(worktreePath);
1223
+ sdlcRoot = getSdlcRoot();
1224
+ worktreeCreated = true;
1225
+ // Update story frontmatter with worktree path (sync state)
1226
+ const worktreeStory = findStoryById(sdlcRoot, targetStory.frontmatter.id);
1227
+ if (worktreeStory) {
1228
+ const updatedStory = await updateStoryField(worktreeStory, 'worktree_path', worktreePath);
1229
+ await writeStory(updatedStory);
1230
+ targetStory = updatedStory;
1231
+ }
1232
+ // Get phase information for resume context
1233
+ const lastPhase = getLastCompletedPhase(targetStory);
1234
+ const nextPhase = getNextPhase(targetStory);
1235
+ // Get worktree status for uncommitted changes info
1236
+ const worktreeStatus = worktreeService.getWorktreeStatus(existingWorktree);
1237
+ // Check branch divergence
1238
+ const divergence = worktreeService.checkBranchDivergence(branchName);
1239
+ console.log(c.success(`✓ Resuming in existing worktree: ${worktreePath}`));
1240
+ console.log(c.dim(` Branch: ${branchName}`));
1241
+ console.log(c.dim(` (Worktree path synced to story frontmatter)`));
1242
+ if (lastPhase) {
1243
+ console.log(c.dim(` Last completed phase: ${lastPhase}`));
1244
+ }
1245
+ if (nextPhase) {
1246
+ console.log(c.dim(` Next phase: ${nextPhase}`));
1247
+ }
1248
+ // Display uncommitted changes if present
1249
+ if (worktreeStatus.workingDirectoryStatus !== 'clean') {
1250
+ const totalChanges = worktreeStatus.modifiedFiles.length + worktreeStatus.untrackedFiles.length;
1251
+ console.log(c.dim(` Uncommitted changes: ${totalChanges} file(s)`));
1252
+ if (worktreeStatus.modifiedFiles.length > 0) {
1253
+ console.log(c.dim(` Modified: ${worktreeStatus.modifiedFiles.slice(0, 3).join(', ')}${worktreeStatus.modifiedFiles.length > 3 ? '...' : ''}`));
1254
+ }
1255
+ if (worktreeStatus.untrackedFiles.length > 0) {
1256
+ console.log(c.dim(` Untracked: ${worktreeStatus.untrackedFiles.slice(0, 3).join(', ')}${worktreeStatus.untrackedFiles.length > 3 ? '...' : ''}`));
1257
+ }
1258
+ }
1259
+ // Warn if branch has diverged significantly
1260
+ if (divergence.diverged && (divergence.ahead > DIVERGENCE_WARNING_THRESHOLD || divergence.behind > DIVERGENCE_WARNING_THRESHOLD)) {
1261
+ console.log(c.warning(` ⚠ Branch has diverged from base: ${divergence.ahead} ahead, ${divergence.behind} behind`));
1262
+ console.log(c.dim(` Consider rebasing to sync with latest changes`));
1263
+ }
1264
+ console.log();
898
1265
  }
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
1266
  }
903
- catch (error) {
904
- // Restore directory on worktree creation failure
905
- if (originalCwd) {
906
- process.chdir(originalCwd);
1267
+ if (shouldCreateNewWorktree) {
1268
+ // Validate git state for worktree creation
1269
+ const validation = worktreeService.validateCanCreateWorktree();
1270
+ if (!validation.valid) {
1271
+ console.log(c.error(`Error: ${validation.error}`));
1272
+ return;
1273
+ }
1274
+ try {
1275
+ // Detect base branch
1276
+ const baseBranch = worktreeService.detectBaseBranch();
1277
+ // Create worktree
1278
+ originalCwd = process.cwd();
1279
+ worktreePath = worktreeService.create({
1280
+ storyId: targetStory.frontmatter.id,
1281
+ slug: targetStory.slug,
1282
+ baseBranch,
1283
+ });
1284
+ // Change to worktree directory BEFORE updating story
1285
+ // This ensures story updates happen in the worktree, not on main
1286
+ // (allows parallel story launches from clean main)
1287
+ process.chdir(worktreePath);
1288
+ // Recalculate sdlcRoot for the worktree context
1289
+ sdlcRoot = getSdlcRoot();
1290
+ worktreeCreated = true;
1291
+ // Now update story frontmatter with worktree path (writes to worktree copy)
1292
+ // Re-resolve target story in worktree context
1293
+ const worktreeStory = findStoryById(sdlcRoot, targetStory.frontmatter.id);
1294
+ if (worktreeStory) {
1295
+ const updatedStory = await updateStoryField(worktreeStory, 'worktree_path', worktreePath);
1296
+ await writeStory(updatedStory);
1297
+ // Update targetStory reference for downstream use
1298
+ targetStory = updatedStory;
1299
+ }
1300
+ console.log(c.success(`✓ Created worktree at: ${worktreePath}`));
1301
+ console.log(c.dim(` Branch: ai-sdlc/${targetStory.frontmatter.id}-${targetStory.slug}`));
1302
+ console.log();
1303
+ }
1304
+ catch (error) {
1305
+ // Restore directory on worktree creation failure
1306
+ if (originalCwd) {
1307
+ process.chdir(originalCwd);
1308
+ }
1309
+ console.log(c.error(`Failed to create worktree: ${error instanceof Error ? error.message : String(error)}`));
1310
+ return;
907
1311
  }
908
- console.log(c.error(`Failed to create worktree: ${error instanceof Error ? error.message : String(error)}`));
909
- return;
910
1312
  }
911
1313
  }
912
1314
  }
@@ -1017,6 +1419,56 @@ export async function run(options) {
1017
1419
  return;
1018
1420
  }
1019
1421
  }
1422
+ else if (reviewResult.decision === ReviewDecision.RECOVERY) {
1423
+ // Implementation recovery: reset implementation_complete and increment implementation retry count
1424
+ // This is distinct from REJECTED which resets the entire RPIV cycle
1425
+ const story = parseStory(action.storyPath);
1426
+ const config = loadConfig();
1427
+ const retryCount = story.frontmatter.implementation_retry_count || 0;
1428
+ const maxRetries = getEffectiveMaxImplementationRetries(story, config);
1429
+ const maxRetriesDisplay = Number.isFinite(maxRetries) ? maxRetries : '∞';
1430
+ console.log();
1431
+ console.log(c.warning(`🔄 Implementation recovery triggered (attempt ${retryCount + 1}/${maxRetriesDisplay})`));
1432
+ console.log(c.dim(` Reason: ${story.frontmatter.last_restart_reason || 'No source code changes detected'}`));
1433
+ // Increment implementation retry count
1434
+ await incrementImplementationRetryCount(story);
1435
+ // Check if we've exceeded max implementation retries after incrementing
1436
+ const freshStory = parseStory(action.storyPath);
1437
+ if (isAtMaxImplementationRetries(freshStory, config)) {
1438
+ console.log();
1439
+ console.log(c.error('═'.repeat(50)));
1440
+ console.log(c.error(`✗ Implementation recovery failed - maximum retries reached`));
1441
+ console.log(c.error('═'.repeat(50)));
1442
+ console.log(c.dim(`Story has reached the maximum implementation retry limit (${maxRetries}).`));
1443
+ console.log(c.warning('Marking story as blocked. Manual intervention required.'));
1444
+ // Mark story as blocked
1445
+ await updateStoryStatus(freshStory, 'blocked');
1446
+ console.log(c.info('Story status updated to: blocked'));
1447
+ await clearWorkflowState(sdlcRoot, action.storyId);
1448
+ process.exit(1);
1449
+ }
1450
+ // Regenerate actions to restart from implementation phase
1451
+ const newActions = generateFullSDLCActions(freshStory, c);
1452
+ if (newActions.length > 0) {
1453
+ currentActions = newActions;
1454
+ currentActionIndex = 0;
1455
+ console.log(c.info(` → Restarting from ${newActions[0].type} phase`));
1456
+ console.log();
1457
+ continue; // Restart the loop with new actions
1458
+ }
1459
+ else {
1460
+ console.log(c.error('Error: No actions generated for recovery. Manual intervention required.'));
1461
+ process.exit(1);
1462
+ }
1463
+ }
1464
+ else if (reviewResult.decision === ReviewDecision.FAILED) {
1465
+ // Review agent failed - don't increment retry count
1466
+ console.log();
1467
+ console.log(c.error(`✗ Review process failed: ${reviewResult.error || 'Unknown error'}`));
1468
+ console.log(c.warning('This does not count as a retry attempt. You can retry manually.'));
1469
+ await clearWorkflowState(sdlcRoot, action.storyId);
1470
+ process.exit(1);
1471
+ }
1020
1472
  }
1021
1473
  // Save checkpoint after successful action
1022
1474
  if (actionResult.success) {
@@ -1243,6 +1695,38 @@ async function executeAction(action, sdlcRoot) {
1243
1695
  if (reviewResult.decision === ReviewDecision.APPROVED && config.reviewConfig.autoCompleteOnApproval) {
1244
1696
  spinner.text = c.success('Review approved - auto-completing story');
1245
1697
  storyLogger?.log('INFO', `Story auto-completed after review approval: "${story.frontmatter.title}"`);
1698
+ // Auto-create PR in automated mode
1699
+ const workflowState = await loadWorkflowState(sdlcRoot, story.frontmatter.id);
1700
+ const isAutoMode = workflowState?.context.options.auto ?? false;
1701
+ if (isAutoMode || config.reviewConfig.autoCreatePROnApproval) {
1702
+ try {
1703
+ // Create PR (this will automatically commit any uncommitted changes)
1704
+ spinner.text = c.dim('Creating pull request...');
1705
+ const { createPullRequest } = await import('../agents/review.js');
1706
+ const prResult = await createPullRequest(action.storyPath, sdlcRoot);
1707
+ if (prResult.success) {
1708
+ spinner.text = c.success('Review approved - PR created');
1709
+ storyLogger?.log('INFO', `PR created successfully for ${story.frontmatter.id}`);
1710
+ }
1711
+ else {
1712
+ // PR creation failed - mark as blocked
1713
+ const { updateStoryStatus } = await import('../core/story.js');
1714
+ const blockedStory = await updateStoryStatus(story, 'blocked');
1715
+ await writeStory(blockedStory);
1716
+ spinner.text = c.warning('Review approved but PR creation failed - story marked as blocked');
1717
+ storyLogger?.log('WARN', `PR creation failed for ${story.frontmatter.id}: ${prResult.error || 'Unknown error'}`);
1718
+ }
1719
+ }
1720
+ catch (error) {
1721
+ // Error during PR creation - mark as blocked
1722
+ const { updateStoryStatus } = await import('../core/story.js');
1723
+ const blockedStory = await updateStoryStatus(story, 'blocked');
1724
+ await writeStory(blockedStory);
1725
+ const errorMsg = error instanceof Error ? error.message : String(error);
1726
+ spinner.text = c.warning(`Review approved but auto-PR failed: ${errorMsg}`);
1727
+ storyLogger?.log('ERROR', `Auto-PR failed for ${story.frontmatter.id}: ${errorMsg}`);
1728
+ }
1729
+ }
1246
1730
  // Handle worktree cleanup if story has a worktree
1247
1731
  if (story.frontmatter.worktree_path) {
1248
1732
  await handleWorktreeCleanup(story, config, c);
@@ -2000,6 +2484,15 @@ async function handleWorktreeCleanup(story, config, c) {
2000
2484
  await writeStory(updated);
2001
2485
  }
2002
2486
  }
2487
+ /**
2488
+ * Security: Escape shell arguments for safe use in commands
2489
+ * For use with execSync when shell execution is required
2490
+ * @internal Exported for testing
2491
+ */
2492
+ export function escapeShellArg(arg) {
2493
+ // Replace single quotes with '\'' and wrap in single quotes
2494
+ return `'${arg.replace(/'/g, "'\\''")}'`;
2495
+ }
2003
2496
  /**
2004
2497
  * List all ai-sdlc managed worktrees
2005
2498
  */