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.
Files changed (101) hide show
  1. package/README.md +53 -1058
  2. package/dist/agents/implementation.d.ts +6 -0
  3. package/dist/agents/implementation.d.ts.map +1 -1
  4. package/dist/agents/implementation.js +87 -13
  5. package/dist/agents/implementation.js.map +1 -1
  6. package/dist/agents/planning.d.ts.map +1 -1
  7. package/dist/agents/planning.js +22 -3
  8. package/dist/agents/planning.js.map +1 -1
  9. package/dist/agents/refinement.d.ts.map +1 -1
  10. package/dist/agents/refinement.js +22 -3
  11. package/dist/agents/refinement.js.map +1 -1
  12. package/dist/agents/research.d.ts +85 -1
  13. package/dist/agents/research.d.ts.map +1 -1
  14. package/dist/agents/research.js +506 -16
  15. package/dist/agents/research.js.map +1 -1
  16. package/dist/agents/review.d.ts +67 -2
  17. package/dist/agents/review.d.ts.map +1 -1
  18. package/dist/agents/review.js +477 -68
  19. package/dist/agents/review.js.map +1 -1
  20. package/dist/agents/rework.d.ts.map +1 -1
  21. package/dist/agents/rework.js +22 -3
  22. package/dist/agents/rework.js.map +1 -1
  23. package/dist/agents/state-assessor.d.ts +3 -3
  24. package/dist/agents/state-assessor.d.ts.map +1 -1
  25. package/dist/agents/state-assessor.js +6 -6
  26. package/dist/agents/state-assessor.js.map +1 -1
  27. package/dist/agents/test-pattern-detector.d.ts +49 -0
  28. package/dist/agents/test-pattern-detector.d.ts.map +1 -0
  29. package/dist/agents/test-pattern-detector.js +273 -0
  30. package/dist/agents/test-pattern-detector.js.map +1 -0
  31. package/dist/agents/verification.d.ts +11 -0
  32. package/dist/agents/verification.d.ts.map +1 -1
  33. package/dist/agents/verification.js +74 -1
  34. package/dist/agents/verification.js.map +1 -1
  35. package/dist/cli/commands/migrate.js +1 -1
  36. package/dist/cli/commands/migrate.js.map +1 -1
  37. package/dist/cli/commands.d.ts +43 -3
  38. package/dist/cli/commands.d.ts.map +1 -1
  39. package/dist/cli/commands.js +588 -150
  40. package/dist/cli/commands.js.map +1 -1
  41. package/dist/cli/daemon.d.ts.map +1 -1
  42. package/dist/cli/daemon.js +20 -3
  43. package/dist/cli/daemon.js.map +1 -1
  44. package/dist/cli/runner.d.ts.map +1 -1
  45. package/dist/cli/runner.js +18 -6
  46. package/dist/cli/runner.js.map +1 -1
  47. package/dist/core/auth.d.ts +43 -0
  48. package/dist/core/auth.d.ts.map +1 -1
  49. package/dist/core/auth.js +105 -1
  50. package/dist/core/auth.js.map +1 -1
  51. package/dist/core/client.d.ts +6 -0
  52. package/dist/core/client.d.ts.map +1 -1
  53. package/dist/core/client.js +57 -3
  54. package/dist/core/client.js.map +1 -1
  55. package/dist/core/config.d.ts +5 -1
  56. package/dist/core/config.d.ts.map +1 -1
  57. package/dist/core/config.js +27 -0
  58. package/dist/core/config.js.map +1 -1
  59. package/dist/core/conflict-detector.d.ts +108 -0
  60. package/dist/core/conflict-detector.d.ts.map +1 -0
  61. package/dist/core/conflict-detector.js +413 -0
  62. package/dist/core/conflict-detector.js.map +1 -0
  63. package/dist/core/git-utils.d.ts +10 -1
  64. package/dist/core/git-utils.d.ts.map +1 -1
  65. package/dist/core/git-utils.js +55 -4
  66. package/dist/core/git-utils.js.map +1 -1
  67. package/dist/core/index.d.ts +17 -0
  68. package/dist/core/index.d.ts.map +1 -0
  69. package/dist/core/index.js +17 -0
  70. package/dist/core/index.js.map +1 -0
  71. package/dist/core/kanban.d.ts +1 -1
  72. package/dist/core/kanban.d.ts.map +1 -1
  73. package/dist/core/kanban.js +3 -3
  74. package/dist/core/kanban.js.map +1 -1
  75. package/dist/core/logger.d.ts +92 -0
  76. package/dist/core/logger.d.ts.map +1 -0
  77. package/dist/core/logger.js +221 -0
  78. package/dist/core/logger.js.map +1 -0
  79. package/dist/core/story-logger.d.ts +102 -0
  80. package/dist/core/story-logger.d.ts.map +1 -0
  81. package/dist/core/story-logger.js +265 -0
  82. package/dist/core/story-logger.js.map +1 -0
  83. package/dist/core/story.d.ts +79 -20
  84. package/dist/core/story.d.ts.map +1 -1
  85. package/dist/core/story.js +221 -39
  86. package/dist/core/story.js.map +1 -1
  87. package/dist/core/workflow-state.d.ts +45 -6
  88. package/dist/core/workflow-state.d.ts.map +1 -1
  89. package/dist/core/workflow-state.js +201 -12
  90. package/dist/core/workflow-state.js.map +1 -1
  91. package/dist/core/worktree.d.ts +9 -0
  92. package/dist/core/worktree.d.ts.map +1 -1
  93. package/dist/core/worktree.js +52 -1
  94. package/dist/core/worktree.js.map +1 -1
  95. package/dist/index.js +112 -6
  96. package/dist/index.js.map +1 -1
  97. package/dist/types/index.d.ts +123 -1
  98. package/dist/types/index.d.ts.map +1 -1
  99. package/dist/types/index.js +1 -0
  100. package/dist/types/index.js.map +1 -1
  101. package/package.json +3 -1
@@ -1,12 +1,14 @@
1
- import { execSync, spawn } from 'child_process';
1
+ import { execSync, spawn, spawnSync } from 'child_process';
2
2
  import path from 'path';
3
3
  import fs from 'fs';
4
4
  import { z } from 'zod';
5
- import { parseStory, updateStoryStatus, appendToSection, updateStoryField, isAtMaxRetries, appendReviewHistory, snapshotMaxRetries, getEffectiveMaxRetries } from '../core/story.js';
5
+ import { parseStory, updateStoryStatus, appendToSection, updateStoryField, isAtMaxRetries, appendReviewHistory, snapshotMaxRetries, getEffectiveMaxRetries, getEffectiveMaxImplementationRetries } from '../core/story.js';
6
6
  import { runAgentQuery } from '../core/client.js';
7
+ import { getLogger } from '../core/logger.js';
7
8
  import { loadConfig, DEFAULT_TIMEOUTS } from '../core/config.js';
8
9
  import { ReviewDecision, ReviewSeverity } from '../types/index.js';
9
10
  import { sanitizeInput, truncateText } from '../cli/formatting.js';
11
+ import { detectTestDuplicationPatterns } from './test-pattern-detector.js';
10
12
  /**
11
13
  * Security: Validate Git branch name to prevent command injection
12
14
  * Only allows alphanumeric characters, hyphens, underscores, and forward slashes
@@ -93,7 +95,9 @@ const ReviewIssueSchema = z.object({
93
95
  // This handles LLM responses that return {"line": null} instead of omitting the field
94
96
  file: z.string().nullish().transform(v => v ?? undefined),
95
97
  line: z.number().int().positive().nullish().transform(v => v ?? undefined),
96
- suggestedFix: z.string().max(2000).nullish().transform(v => v ?? undefined),
98
+ suggestedFix: z.string().max(5000).nullish().transform(v => v ?? undefined),
99
+ // Perspectives field for unified review (optional for backward compatibility)
100
+ perspectives: z.array(z.enum(['code', 'security', 'po'])).optional(),
97
101
  });
98
102
  const ReviewResponseSchema = z.object({
99
103
  passed: z.boolean(),
@@ -252,7 +256,8 @@ Output your review as a JSON object with this structure:
252
256
  "description": "Detailed description of the issue",
253
257
  "file": "path/to/file.ts" (if applicable),
254
258
  "line": 42 (if applicable),
255
- "suggestedFix": "How to fix this issue"
259
+ "suggestedFix": "How to fix this issue",
260
+ "perspectives": ["code", "security", "po"] (which perspectives this issue relates to)
256
261
  }
257
262
  ]
258
263
  }
@@ -265,6 +270,70 @@ Severity guidelines:
265
270
 
266
271
  If no issues found, return: {"passed": true, "issues": []}
267
272
  `;
273
+ /**
274
+ * Unified Review Prompt - combines code, security, and product owner perspectives
275
+ * into a single collaborative review to eliminate duplicate issues.
276
+ */
277
+ const UNIFIED_REVIEW_PROMPT = `You are a senior engineering team conducting a comprehensive collaborative review.
278
+
279
+ You must evaluate the implementation from THREE perspectives simultaneously, but produce ONE unified set of issues:
280
+
281
+ ## Perspective 1: Code Quality (Senior Developer)
282
+ Evaluate:
283
+ - Code quality and maintainability
284
+ - Following best practices and design patterns
285
+ - Potential bugs or logic errors
286
+ - Test coverage adequacy and test quality
287
+ - Error handling completeness
288
+ - Performance considerations
289
+
290
+ ## Perspective 2: Security (Security Engineer)
291
+ Evaluate:
292
+ - OWASP Top 10 vulnerabilities
293
+ - Input validation and sanitization
294
+ - Authentication and authorization issues
295
+ - Data exposure risks
296
+ - Command injection vulnerabilities
297
+ - Secure coding practices
298
+
299
+ ## Perspective 3: Requirements (Product Owner)
300
+ Evaluate:
301
+ - Does it meet the acceptance criteria stated in the story?
302
+ - Is the user experience appropriate and intuitive?
303
+ - Are edge cases and error scenarios handled?
304
+ - Is documentation adequate for users and maintainers?
305
+ - Does the implementation align with the story goals?
306
+
307
+ ## CRITICAL DEDUPLICATION INSTRUCTIONS:
308
+
309
+ 1. **DO NOT repeat the same underlying issue from different perspectives**
310
+ - If multiple perspectives notice the same problem, list it ONCE
311
+ - Use the \`perspectives\` array to indicate which perspectives it affects
312
+
313
+ 2. **Prioritize by actual impact, not by how many perspectives notice it**
314
+ - A issue seen by all 3 perspectives is still just ONE issue
315
+ - Focus on the distinct, actionable problems that need fixing
316
+
317
+ 3. **If the fundamental problem is "no implementation exists" or "functionality completely missing":**
318
+ - Report this as ONE blocker issue, not three separate issues
319
+ - Use perspectives: ["code", "security", "po"] to show all perspectives agree
320
+
321
+ 4. **Combine related issues into single, comprehensive descriptions:**
322
+ - Instead of: "No tests" (code) + "Untested security" (security) + "No validation tests" (po)
323
+ - Write: "No tests exist for the implementation" with perspectives: ["code", "security", "po"]
324
+
325
+ 5. **Each issue should have a clear, single suggested fix**
326
+ - Avoid vague suggestions like "improve everything"
327
+ - Be specific and actionable
328
+
329
+ ${REVIEW_OUTPUT_FORMAT}
330
+
331
+ Remember: Your goal is to produce a clean, deduplicated list of actual distinct problems, not to maximize issue count.`;
332
+ /**
333
+ * Legacy prompts - kept for reference only
334
+ * @deprecated These are replaced by UNIFIED_REVIEW_PROMPT which combines all three perspectives.
335
+ * The unified prompt reduces LLM calls from 3 to 1 and eliminates duplicate issues.
336
+ */
268
337
  const CODE_REVIEW_PROMPT = `You are a senior code reviewer. Review the implementation for:
269
338
  1. Code quality and maintainability
270
339
  2. Following best practices
@@ -272,6 +341,9 @@ const CODE_REVIEW_PROMPT = `You are a senior code reviewer. Review the implement
272
341
  4. Test coverage adequacy
273
342
 
274
343
  ${REVIEW_OUTPUT_FORMAT}`;
344
+ /**
345
+ * @deprecated Use UNIFIED_REVIEW_PROMPT instead
346
+ */
275
347
  const SECURITY_REVIEW_PROMPT = `You are a security specialist. Review the implementation for:
276
348
  1. OWASP Top 10 vulnerabilities
277
349
  2. Input validation issues
@@ -279,6 +351,9 @@ const SECURITY_REVIEW_PROMPT = `You are a security specialist. Review the implem
279
351
  4. Data exposure risks
280
352
 
281
353
  ${REVIEW_OUTPUT_FORMAT}`;
354
+ /**
355
+ * @deprecated Use UNIFIED_REVIEW_PROMPT instead
356
+ */
282
357
  const PO_REVIEW_PROMPT = `You are a product owner validating the implementation. Check:
283
358
  1. Does it meet the acceptance criteria?
284
359
  2. Is the user experience appropriate?
@@ -316,6 +391,7 @@ function parseReviewResponse(response, reviewType) {
316
391
  file: issue.file,
317
392
  line: issue.line,
318
393
  suggestedFix: issue.suggestedFix,
394
+ perspectives: issue.perspectives,
319
395
  }));
320
396
  return {
321
397
  passed: validated.passed !== false && issues.filter(i => i.severity === 'blocker' || i.severity === 'critical').length === 0,
@@ -383,8 +459,35 @@ function determineReviewSeverity(issues) {
383
459
  return ReviewSeverity.LOW;
384
460
  }
385
461
  }
462
+ /**
463
+ * Derive individual perspective pass/fail status from issues
464
+ *
465
+ * For backward compatibility with ReviewAttempt structure, determines whether
466
+ * each perspective (code, security, po) would pass based on issues flagged
467
+ * for that perspective.
468
+ *
469
+ * A perspective fails if it has any blocker or critical issues.
470
+ *
471
+ * @param issues - Array of review issues with perspectives field
472
+ * @returns Object with pass/fail status for each perspective
473
+ */
474
+ export function deriveIndividualPassFailFromPerspectives(issues) {
475
+ // Check if any blocker/critical issues exist for each perspective
476
+ const codeIssues = issues.filter(i => i.perspectives?.includes('code') &&
477
+ (i.severity === 'blocker' || i.severity === 'critical'));
478
+ const securityIssues = issues.filter(i => i.perspectives?.includes('security') &&
479
+ (i.severity === 'blocker' || i.severity === 'critical'));
480
+ const poIssues = issues.filter(i => i.perspectives?.includes('po') &&
481
+ (i.severity === 'blocker' || i.severity === 'critical'));
482
+ return {
483
+ codeReviewPassed: codeIssues.length === 0,
484
+ securityReviewPassed: securityIssues.length === 0,
485
+ poReviewPassed: poIssues.length === 0,
486
+ };
487
+ }
386
488
  /**
387
489
  * Aggregate issues from multiple reviews and determine overall pass/fail
490
+ * @deprecated No longer used with unified review. Kept for reference only.
388
491
  */
389
492
  function aggregateReviews(codeResult, securityResult, poResult) {
390
493
  const allIssues = [...codeResult.issues, ...securityResult.issues, ...poResult.issues];
@@ -399,6 +502,7 @@ function aggregateReviews(codeResult, securityResult, poResult) {
399
502
  }
400
503
  /**
401
504
  * Format issues for display in review notes
505
+ * Shows perspectives (code, security, po) when available
402
506
  */
403
507
  function formatIssuesForDisplay(issues) {
404
508
  if (issues.length === 0) {
@@ -417,7 +521,11 @@ function formatIssuesForDisplay(issues) {
417
521
  const icon = severity === 'blocker' ? '🛑' : severity === 'critical' ? '⚠️' : severity === 'major' ? '📋' : 'ℹ️';
418
522
  output += `\n#### ${icon} ${severity.toUpperCase()} (${issueList.length})\n\n`;
419
523
  for (const issue of issueList) {
420
- output += `**${issue.category}**: ${issue.description}\n`;
524
+ // Format perspectives indicator if present
525
+ const perspectivesTag = issue.perspectives && issue.perspectives.length > 0
526
+ ? ` [${issue.perspectives.join(', ')}]`
527
+ : '';
528
+ output += `**${issue.category}**${perspectivesTag}: ${issue.description}\n`;
421
529
  if (issue.file) {
422
530
  output += ` - File: \`${issue.file}\`${issue.line ? `:${issue.line}` : ''}\n`;
423
531
  }
@@ -429,6 +537,41 @@ function formatIssuesForDisplay(issues) {
429
537
  }
430
538
  return output;
431
539
  }
540
+ /**
541
+ * Get source code changes from git diff
542
+ *
543
+ * Returns list of source files that have been modified (excludes tests and story files).
544
+ * Uses spawnSync for security (prevents command injection).
545
+ *
546
+ * @param workingDir - Working directory to run git diff in
547
+ * @returns Array of source file paths that have changed, or ['unknown'] if git fails
548
+ */
549
+ export function getSourceCodeChanges(workingDir) {
550
+ try {
551
+ // Security: Use spawnSync with explicit args (not shell) to prevent injection
552
+ const result = spawnSync('git', ['diff', '--name-only', 'HEAD~1'], {
553
+ cwd: workingDir,
554
+ encoding: 'utf-8',
555
+ stdio: ['ignore', 'pipe', 'pipe'],
556
+ });
557
+ if (result.status !== 0) {
558
+ // Git command failed - fail open (assume changes exist)
559
+ return ['unknown'];
560
+ }
561
+ const output = result.stdout.toString();
562
+ return output
563
+ .split('\n')
564
+ .filter(f => f.trim())
565
+ .filter(f => /\.(ts|tsx|js|jsx)$/.test(f)) // Source files only
566
+ .filter(f => !f.includes('.test.')) // Exclude test files
567
+ .filter(f => !f.includes('.spec.')) // Exclude spec files
568
+ .filter(f => !f.startsWith('.ai-sdlc/')); // Exclude story files
569
+ }
570
+ catch {
571
+ // If git diff fails, assume there are changes (fail open, not closed)
572
+ return ['unknown'];
573
+ }
574
+ }
432
575
  /**
433
576
  * Generate executive summary from review issues (1-3 sentences)
434
577
  *
@@ -526,9 +669,15 @@ export function generateReviewSummary(issues, terminalWidth) {
526
669
  * Now returns structured ReviewResult with pass/fail and issues.
527
670
  */
528
671
  export async function runReviewAgent(storyPath, sdlcRoot, options) {
672
+ const logger = getLogger();
673
+ const startTime = Date.now();
529
674
  const story = parseStory(storyPath);
530
675
  const changesMade = [];
531
676
  const workingDir = path.dirname(sdlcRoot);
677
+ logger.info('review', 'Starting review phase', {
678
+ storyId: story.frontmatter.id,
679
+ retryCount: story.frontmatter.retry_count || 0,
680
+ });
532
681
  // Security: Validate working directory before any operations
533
682
  try {
534
683
  validateWorkingDirectory(workingDir);
@@ -554,14 +703,14 @@ export async function runReviewAgent(storyPath, sdlcRoot, options) {
554
703
  const config = loadConfig(workingDir);
555
704
  try {
556
705
  // Snapshot max_retries from config (protects against mid-cycle config changes)
557
- snapshotMaxRetries(story, config);
706
+ await snapshotMaxRetries(story, config);
558
707
  // Check if story has reached max retries
559
708
  if (isAtMaxRetries(story, config)) {
560
709
  const retryCount = story.frontmatter.retry_count || 0;
561
710
  const maxRetries = getEffectiveMaxRetries(story, config);
562
711
  const maxRetriesDisplay = Number.isFinite(maxRetries) ? maxRetries : '∞';
563
712
  const errorMsg = `Story has reached maximum retry limit (${retryCount}/${maxRetriesDisplay}). Manual intervention required.`;
564
- updateStoryField(story, 'last_error', errorMsg);
713
+ await updateStoryField(story, 'last_error', errorMsg);
565
714
  changesMade.push(errorMsg);
566
715
  return {
567
716
  success: false,
@@ -579,6 +728,67 @@ export async function runReviewAgent(storyPath, sdlcRoot, options) {
579
728
  feedback: errorMsg,
580
729
  };
581
730
  }
731
+ // PRE-CHECK GATE: Detect documentation-only implementations before running expensive LLM reviews
732
+ const sourceChanges = getSourceCodeChanges(workingDir);
733
+ if (sourceChanges.length === 0) {
734
+ // No source code changes detected - check if we can recover
735
+ const retryCount = story.frontmatter.implementation_retry_count || 0;
736
+ const maxRetries = getEffectiveMaxImplementationRetries(story, config);
737
+ if (retryCount < maxRetries) {
738
+ // RECOVERABLE: Trigger implementation recovery
739
+ logger.warn('review', 'No source code changes detected - triggering implementation recovery', {
740
+ storyId: story.frontmatter.id,
741
+ retryCount,
742
+ maxRetries,
743
+ });
744
+ await updateStoryField(story, 'implementation_complete', false);
745
+ await updateStoryField(story, 'last_restart_reason', 'No source code changes detected. Implementation wrote documentation only.');
746
+ return {
747
+ success: true,
748
+ story: parseStory(storyPath),
749
+ changesMade: ['Detected documentation-only implementation', 'Triggered implementation recovery'],
750
+ passed: false,
751
+ decision: ReviewDecision.RECOVERY,
752
+ reviewType: 'pre-check',
753
+ issues: [{
754
+ severity: 'critical',
755
+ category: 'implementation',
756
+ description: 'No source code modifications detected. Re-running implementation phase.',
757
+ }],
758
+ feedback: 'Implementation recovery triggered - no source changes found.',
759
+ };
760
+ }
761
+ else {
762
+ // NON-RECOVERABLE: Max retries reached
763
+ const maxRetriesDisplay = Number.isFinite(maxRetries) ? maxRetries : '∞';
764
+ logger.error('review', 'No source code changes detected and max implementation retries reached', {
765
+ storyId: story.frontmatter.id,
766
+ retryCount,
767
+ maxRetries,
768
+ });
769
+ return {
770
+ success: true,
771
+ story: parseStory(storyPath),
772
+ changesMade: ['Detected documentation-only implementation', 'Max retries reached'],
773
+ passed: false,
774
+ decision: ReviewDecision.FAILED,
775
+ severity: ReviewSeverity.CRITICAL,
776
+ reviewType: 'pre-check',
777
+ issues: [{
778
+ severity: 'blocker',
779
+ category: 'implementation',
780
+ description: `Implementation phase wrote documentation/planning only - no source code was modified. This has occurred ${retryCount} time(s) (max: ${maxRetriesDisplay}). Manual intervention required.`,
781
+ suggestedFix: 'Review the story requirements and implementation plan. The agent may be confused about what needs to be built. Consider simplifying the story or providing more explicit guidance.',
782
+ }],
783
+ feedback: 'Implementation failed to produce code changes after multiple attempts.',
784
+ };
785
+ }
786
+ }
787
+ // Source changes exist - proceed with normal review flow
788
+ logger.info('review', 'Source code changes detected - proceeding with verification', {
789
+ storyId: story.frontmatter.id,
790
+ fileCount: sourceChanges.length,
791
+ });
582
792
  // Run build and tests BEFORE reviews (async with progress)
583
793
  changesMade.push('Running build and test verification...');
584
794
  const verification = await runVerificationAsync(workingDir, config, options?.onVerificationProgress);
@@ -646,60 +856,82 @@ export async function runReviewAgent(storyPath, sdlcRoot, options) {
646
856
  feedback: formatIssuesForDisplay(verificationIssues),
647
857
  };
648
858
  }
649
- // Verification passed - proceed with all reviews in parallel, passing verification context
650
- changesMade.push('Verification passed - proceeding with code/security/PO reviews');
651
- const [codeReview, securityReview, poReview] = await Promise.all([
652
- runSubReview(story, CODE_REVIEW_PROMPT, 'Code Review', workingDir, verificationContext),
653
- runSubReview(story, SECURITY_REVIEW_PROMPT, 'Security Review', workingDir, verificationContext),
654
- runSubReview(story, PO_REVIEW_PROMPT, 'Product Owner Review', workingDir, verificationContext),
655
- ]);
656
- // Parse each review response into structured issues
657
- const codeResult = parseReviewResponse(codeReview, 'Code Review');
658
- const securityResult = parseReviewResponse(securityReview, 'Security Review');
659
- const poResult = parseReviewResponse(poReview, 'Product Owner Review');
859
+ // Verification passed - proceed with unified collaborative review
860
+ changesMade.push('Verification passed - proceeding with unified collaborative review');
861
+ // Run test pattern detection if enabled
862
+ let testPatternIssues = [];
863
+ if (config.reviewConfig.detectTestAntipatterns !== false) {
864
+ try {
865
+ changesMade.push('Running test anti-pattern detection...');
866
+ testPatternIssues = await detectTestDuplicationPatterns(workingDir);
867
+ if (testPatternIssues.length > 0) {
868
+ changesMade.push(`Detected ${testPatternIssues.length} test anti-pattern(s)`);
869
+ }
870
+ else {
871
+ changesMade.push('No test anti-patterns detected');
872
+ }
873
+ }
874
+ catch (error) {
875
+ // Don't fail review if detection errors - just log and continue
876
+ const errorMsg = error instanceof Error ? error.message : String(error);
877
+ changesMade.push(`Test pattern detection error: ${errorMsg}`);
878
+ }
879
+ }
880
+ const unifiedReviewResponse = await runSubReview(story, UNIFIED_REVIEW_PROMPT, 'Unified Collaborative Review', workingDir, verificationContext);
881
+ // Parse unified review response into structured issues
882
+ const unifiedResult = parseReviewResponse(unifiedReviewResponse, 'Unified Review');
660
883
  // TDD Validation: Check TDD cycle completeness if TDD was enabled for this story
661
884
  const tddEnabled = story.frontmatter.tdd_enabled ?? config.tdd?.enabled ?? false;
662
885
  if (tddEnabled && story.frontmatter.tdd_test_history?.length) {
663
886
  const tddViolations = validateTDDCycles(story.frontmatter.tdd_test_history);
664
887
  if (tddViolations.length > 0) {
665
888
  const tddIssues = generateTDDIssues(tddViolations);
666
- codeResult.issues.push(...tddIssues);
667
- codeResult.passed = false;
889
+ unifiedResult.issues.push(...tddIssues);
890
+ unifiedResult.passed = false;
668
891
  changesMade.push(`TDD validation: ${tddViolations.length} violation(s) detected`);
669
892
  }
670
893
  else {
671
894
  changesMade.push('TDD validation: All cycles completed correctly');
672
895
  }
673
896
  }
674
- // Add verification issues to code result (they're code-quality related)
675
- codeResult.issues.unshift(...verificationIssues);
897
+ // Add test pattern issues to unified result (they're code-quality related)
898
+ if (testPatternIssues.length > 0) {
899
+ unifiedResult.issues.push(...testPatternIssues);
900
+ unifiedResult.passed = false;
901
+ }
902
+ // Add verification issues to unified result (they're code-quality related)
903
+ unifiedResult.issues.unshift(...verificationIssues);
676
904
  if (verificationIssues.length > 0) {
677
- codeResult.passed = false;
905
+ unifiedResult.passed = false;
678
906
  }
679
- // Aggregate all issues and determine overall pass/fail
680
- const { passed, allIssues, severity } = aggregateReviews(codeResult, securityResult, poResult);
681
- // Compile review notes with structured format
907
+ // Determine overall pass/fail from unified review
908
+ const allIssues = unifiedResult.issues;
909
+ const blockerCount = allIssues.filter(i => i.severity === 'blocker').length;
910
+ const criticalCount = allIssues.filter(i => i.severity === 'critical').length;
911
+ const passed = blockerCount === 0 && criticalCount < 2;
912
+ const severity = determineReviewSeverity(allIssues);
913
+ // Derive individual perspective pass/fail for backward compatibility
914
+ const { codeReviewPassed, securityReviewPassed, poReviewPassed } = deriveIndividualPassFailFromPerspectives(allIssues);
915
+ // Compile review notes with structured format for unified review
682
916
  const reviewNotes = `
683
- ### Code Review
684
- ${formatIssuesForDisplay(codeResult.issues)}
917
+ ### Unified Collaborative Review
685
918
 
686
- ### Security Review
687
- ${formatIssuesForDisplay(securityResult.issues)}
919
+ ${formatIssuesForDisplay(allIssues)}
688
920
 
689
- ### Product Owner Review
690
- ${formatIssuesForDisplay(poResult.issues)}
921
+ ### Perspective Summary
922
+ - Code Quality: ${codeReviewPassed ? '✅ Passed' : '❌ Failed'}
923
+ - Security: ${securityReviewPassed ? '✅ Passed' : '❌ Failed'}
924
+ - Requirements (PO): ${poReviewPassed ? '✅ Passed' : '❌ Failed'}
691
925
 
692
926
  ### Overall Result
693
927
  ${passed ? '✅ **PASSED** - All reviews approved' : '❌ **FAILED** - Issues must be addressed'}
694
928
 
695
929
  ---
696
- *Reviews completed: ${new Date().toISOString().split('T')[0]}*
930
+ *Review completed: ${new Date().toISOString().split('T')[0]}*
697
931
  `;
698
932
  // Append reviews to story
699
- appendToSection(story, 'Review Notes', reviewNotes);
700
- changesMade.push('Added code review notes');
701
- changesMade.push('Added security review notes');
702
- changesMade.push('Added product owner review notes');
933
+ await appendToSection(story, 'Review Notes', reviewNotes);
934
+ changesMade.push('Added unified collaborative review notes');
703
935
  // Determine decision
704
936
  const decision = passed ? ReviewDecision.APPROVED : ReviewDecision.REJECTED;
705
937
  // Create review attempt record (omit undefined fields to avoid YAML serialization errors)
@@ -709,21 +941,28 @@ ${passed ? '✅ **PASSED** - All reviews approved' : '❌ **FAILED** - Issues mu
709
941
  ...(passed ? {} : { severity }),
710
942
  feedback: passed ? 'All reviews passed' : formatIssuesForDisplay(allIssues),
711
943
  blockers: allIssues.filter(i => i.severity === 'blocker').map(i => i.description),
712
- codeReviewPassed: codeResult.passed,
713
- securityReviewPassed: securityResult.passed,
714
- poReviewPassed: poResult.passed,
944
+ codeReviewPassed,
945
+ securityReviewPassed,
946
+ poReviewPassed,
715
947
  };
716
948
  // Append to review history
717
- appendReviewHistory(story, reviewAttempt);
949
+ await appendReviewHistory(story, reviewAttempt);
718
950
  changesMade.push('Recorded review attempt in history');
719
951
  if (passed) {
720
- updateStoryField(story, 'reviews_complete', true);
952
+ await updateStoryField(story, 'reviews_complete', true);
721
953
  changesMade.push('Marked reviews_complete: true');
722
954
  }
723
955
  else {
724
956
  changesMade.push(`Reviews failed with ${allIssues.length} issue(s) - rework required`);
725
957
  // Don't mark reviews_complete, this will trigger rework
726
958
  }
959
+ logger.info('review', 'Review phase complete', {
960
+ storyId: story.frontmatter.id,
961
+ durationMs: Date.now() - startTime,
962
+ passed,
963
+ decision,
964
+ issueCount: allIssues.length,
965
+ });
727
966
  return {
728
967
  success: true,
729
968
  story: parseStory(storyPath),
@@ -739,6 +978,11 @@ ${passed ? '✅ **PASSED** - All reviews approved' : '❌ **FAILED** - Issues mu
739
978
  catch (error) {
740
979
  // Review agent failure - return FAILED decision (doesn't count as retry)
741
980
  const errorMsg = error instanceof Error ? error.message : String(error);
981
+ logger.error('review', 'Review phase failed', {
982
+ storyId: story.frontmatter.id,
983
+ durationMs: Date.now() - startTime,
984
+ error: errorMsg,
985
+ });
742
986
  return {
743
987
  success: false,
744
988
  story,
@@ -756,6 +1000,139 @@ ${passed ? '✅ **PASSED** - All reviews approved' : '❌ **FAILED** - Issues mu
756
1000
  };
757
1001
  }
758
1002
  }
1003
+ /**
1004
+ * Parse story content into sections by level-2 headers (##)
1005
+ * Returns array of {title, content} objects
1006
+ */
1007
+ export function parseContentSections(content) {
1008
+ const sections = [];
1009
+ const lines = content.split('\n');
1010
+ let currentSection = null;
1011
+ for (const line of lines) {
1012
+ const headerMatch = line.match(/^##\s+(.+)$/);
1013
+ if (headerMatch) {
1014
+ if (currentSection)
1015
+ sections.push(currentSection);
1016
+ currentSection = { title: headerMatch[1], content: '' };
1017
+ }
1018
+ else if (currentSection) {
1019
+ currentSection.content += line + '\n';
1020
+ }
1021
+ }
1022
+ if (currentSection)
1023
+ sections.push(currentSection);
1024
+ return sections;
1025
+ }
1026
+ /**
1027
+ * Remove unfinished checkboxes from content (per CLAUDE.md requirement)
1028
+ * Removes lines with `- [ ]` or `* [ ]` patterns
1029
+ * Preserves completed checkboxes `- [x]` and `- [X]`
1030
+ */
1031
+ export function removeUnfinishedCheckboxes(content) {
1032
+ const lines = content.split('\n');
1033
+ const filteredLines = [];
1034
+ for (let i = 0; i < lines.length; i++) {
1035
+ const line = lines[i];
1036
+ // Match unchecked boxes: - [ ] or * [ ] with optional leading whitespace
1037
+ const isUnchecked = /^\s*[-*] \[ \]/.test(line);
1038
+ if (!isUnchecked) {
1039
+ filteredLines.push(line);
1040
+ }
1041
+ }
1042
+ return filteredLines.join('\n');
1043
+ }
1044
+ /**
1045
+ * Generate GitHub blob URL for story file
1046
+ * Parses remote URL and constructs link to story in repository
1047
+ */
1048
+ export function getStoryFileURL(storyPath, branch, workingDir) {
1049
+ try {
1050
+ const remoteUrl = execSync('git remote get-url origin', { cwd: workingDir, encoding: 'utf-8' }).trim();
1051
+ // Parse owner/repo from URL
1052
+ // HTTPS: https://github.com/owner/repo.git
1053
+ // SSH: git@github.com:owner/repo.git
1054
+ const match = remoteUrl.match(/github\.com[:/]([^/]+)\/(.+?)(\.git)?$/);
1055
+ if (!match)
1056
+ return '';
1057
+ const [, owner, repo] = match;
1058
+ const relativePath = path.relative(workingDir, storyPath);
1059
+ return `https://github.com/${owner}/${repo}/blob/${branch}/${relativePath}`;
1060
+ }
1061
+ catch {
1062
+ return '';
1063
+ }
1064
+ }
1065
+ /**
1066
+ * Format PR description from story sections
1067
+ * Includes: Story ID, User Story, Summary, Acceptance Criteria, Implementation Summary
1068
+ * Removes unfinished checkboxes from all sections
1069
+ */
1070
+ export function formatPRDescription(story, storyFileUrl) {
1071
+ const sections = parseContentSections(story.content);
1072
+ // Extract key sections
1073
+ const userStory = sections.find(s => s.title === 'User Story')?.content || '';
1074
+ const summary = sections.find(s => s.title === 'Summary')?.content || '';
1075
+ const acceptanceCriteria = sections.find(s => s.title === 'Acceptance Criteria')?.content || '';
1076
+ const implementationSummary = sections.find(s => s.title === 'Implementation Summary')?.content || '';
1077
+ // Remove unfinished checkboxes from all sections
1078
+ const cleanAcceptanceCriteria = removeUnfinishedCheckboxes(acceptanceCriteria);
1079
+ const cleanImplementationSummary = removeUnfinishedCheckboxes(implementationSummary);
1080
+ // Build PR body
1081
+ let prBody = `## Story ID\n\n${story.frontmatter.id}\n\n`;
1082
+ if (userStory.trim()) {
1083
+ prBody += `## User Story\n\n${userStory.trim()}\n\n`;
1084
+ }
1085
+ if (summary.trim()) {
1086
+ prBody += `## Summary\n\n${summary.trim()}\n\n`;
1087
+ }
1088
+ if (cleanAcceptanceCriteria.trim()) {
1089
+ prBody += `## Acceptance Criteria\n\n${cleanAcceptanceCriteria.trim()}\n\n`;
1090
+ }
1091
+ if (cleanImplementationSummary.trim()) {
1092
+ prBody += `## Implementation Summary\n\n${cleanImplementationSummary.trim()}\n\n`;
1093
+ }
1094
+ // Add story file link
1095
+ if (storyFileUrl) {
1096
+ prBody += `---\n\n📋 [View Full Story](${storyFileUrl})\n`;
1097
+ }
1098
+ return prBody;
1099
+ }
1100
+ /**
1101
+ * Truncate PR body to respect GitHub's 65K character limit
1102
+ * Truncates Implementation Summary first (most verbose section)
1103
+ * Adds clear truncation indicator with story link
1104
+ */
1105
+ export function truncatePRBody(body, maxLength = 64000) {
1106
+ // Check if truncation needed
1107
+ if (body.length <= maxLength) {
1108
+ return body;
1109
+ }
1110
+ // Find Implementation Summary section
1111
+ const implSummaryMatch = body.match(/(## Implementation Summary\n\n)([\s\S]*?)(\n\n##|\n\n---|\n\n📋|$)/);
1112
+ if (implSummaryMatch) {
1113
+ const [fullMatch, header, content, trailer] = implSummaryMatch;
1114
+ const beforeImpl = body.substring(0, body.indexOf(fullMatch));
1115
+ const afterImpl = body.substring(body.indexOf(fullMatch) + fullMatch.length);
1116
+ // Calculate how much we need to remove
1117
+ const overhead = beforeImpl.length + header.length + trailer.length + afterImpl.length;
1118
+ const truncationIndicator = '\n\n⚠️ Implementation Summary truncated due to length. See full story for complete details.\n';
1119
+ const availableForContent = maxLength - overhead - truncationIndicator.length;
1120
+ if (availableForContent > 100) {
1121
+ // Truncate Implementation Summary at paragraph boundary
1122
+ let truncatedContent = content.substring(0, availableForContent);
1123
+ const lastParagraph = truncatedContent.lastIndexOf('\n\n');
1124
+ if (lastParagraph > 0) {
1125
+ truncatedContent = truncatedContent.substring(0, lastParagraph);
1126
+ }
1127
+ return beforeImpl + header + truncatedContent + truncationIndicator + trailer + afterImpl;
1128
+ }
1129
+ }
1130
+ // Fallback: simple truncation if no Implementation Summary found
1131
+ const truncatedBody = body.substring(0, maxLength - 200);
1132
+ const lastParagraph = truncatedBody.lastIndexOf('\n\n');
1133
+ const finalBody = lastParagraph > 0 ? truncatedBody.substring(0, lastParagraph) : truncatedBody;
1134
+ return finalBody + '\n\n⚠️ Description truncated due to length. See full story for complete details.\n';
1135
+ }
759
1136
  /**
760
1137
  * Run a sub-review with a specific prompt
761
1138
  */
@@ -782,7 +1159,7 @@ Provide your ${reviewType} feedback. Be specific and actionable.`;
782
1159
  /**
783
1160
  * Create a pull request for the completed story
784
1161
  */
785
- export async function createPullRequest(storyPath, sdlcRoot) {
1162
+ export async function createPullRequest(storyPath, sdlcRoot, options) {
786
1163
  let story = parseStory(storyPath);
787
1164
  const changesMade = [];
788
1165
  const workingDir = path.dirname(sdlcRoot);
@@ -819,7 +1196,7 @@ export async function createPullRequest(storyPath, sdlcRoot) {
819
1196
  catch {
820
1197
  changesMade.push('GitHub CLI not available - PR creation skipped');
821
1198
  // Still update to done for MVP
822
- story = updateStoryStatus(story, 'done');
1199
+ story = await updateStoryStatus(story, 'done');
823
1200
  changesMade.push('Updated status to done');
824
1201
  return {
825
1202
  success: true,
@@ -844,37 +1221,69 @@ export async function createPullRequest(storyPath, sdlcRoot) {
844
1221
  // Push branch (already validated)
845
1222
  execSync(`git push -u origin ${branchName}`, { cwd: workingDir, stdio: 'pipe' });
846
1223
  changesMade.push(`Pushed branch: ${branchName}`);
847
- // Create PR using gh CLI with safe arguments
848
- // Security: Use escaped arguments to prevent shell injection
1224
+ // Check if PR already exists for this branch
1225
+ try {
1226
+ const existingPROutput = execSync('gh pr view --json url', { cwd: workingDir, encoding: 'utf-8', stdio: 'pipe' });
1227
+ const prData = JSON.parse(existingPROutput);
1228
+ if (prData.url) {
1229
+ changesMade.push(`PR already exists: ${prData.url}`);
1230
+ // Update story with PR URL if missing
1231
+ if (!story.frontmatter.pr_url) {
1232
+ await updateStoryField(story, 'pr_url', prData.url);
1233
+ changesMade.push('Updated story with existing PR URL');
1234
+ }
1235
+ // Don't create duplicate - skip to status update
1236
+ story = await updateStoryStatus(story, 'done');
1237
+ changesMade.push('Updated status to done');
1238
+ return {
1239
+ success: true,
1240
+ story,
1241
+ changesMade,
1242
+ };
1243
+ }
1244
+ }
1245
+ catch {
1246
+ // No existing PR - proceed with creation
1247
+ }
1248
+ // Create PR using gh CLI with rich formatted body
1249
+ // Security: Use escaped arguments and heredoc to prevent shell injection
849
1250
  const prTitle = story.frontmatter.title;
850
- const prBody = `## Summary
851
-
852
- ${story.frontmatter.title}
853
-
854
- ## Story
855
-
856
- ${story.content.substring(0, 1000)}...
857
-
858
- ## Checklist
859
-
860
- - [x] Implementation complete
861
- - [x] Code review passed
862
- - [x] Security review passed
863
- - [x] Product owner approved
864
-
865
- ---
866
- *Created by ai-sdlc*`;
867
- const prOutput = execSync(`gh pr create --title ${escapeShellArg(prTitle)} --body ${escapeShellArg(prBody)}`, { cwd: workingDir, encoding: 'utf-8' });
1251
+ // Generate story file URL
1252
+ const storyFileUrl = getStoryFileURL(storyPath, branchName, workingDir);
1253
+ // Format rich PR description
1254
+ let prBody = formatPRDescription(story, storyFileUrl);
1255
+ // Truncate if needed to respect GitHub's 65K limit
1256
+ prBody = truncatePRBody(prBody);
1257
+ // Determine if draft PR should be created
1258
+ // Options parameter takes precedence, then config, default is false
1259
+ const config = loadConfig(workingDir);
1260
+ const createAsDraft = options?.draft ?? config.github?.createDraftPRs ?? false;
1261
+ const draftFlag = createAsDraft ? ' --draft' : '';
1262
+ // Use heredoc pattern for multi-line body to preserve formatting
1263
+ const ghCommand = `gh pr create --title ${escapeShellArg(prTitle)}${draftFlag} --body "$(cat <<'EOF'
1264
+ ${prBody}
1265
+ EOF
1266
+ )"`;
1267
+ const prOutput = execSync(ghCommand, { cwd: workingDir, encoding: 'utf-8' });
868
1268
  const prUrl = prOutput.trim();
869
- updateStoryField(story, 'pr_url', prUrl);
870
- changesMade.push(`Created PR: ${prUrl}`);
1269
+ await updateStoryField(story, 'pr_url', prUrl);
1270
+ const prTypeLabel = createAsDraft ? 'draft PR' : 'PR';
1271
+ changesMade.push(`Created ${prTypeLabel}: ${prUrl}`);
871
1272
  }
872
1273
  catch (error) {
873
1274
  const sanitizedError = sanitizeErrorMessage(error instanceof Error ? error.message : String(error), workingDir);
874
- changesMade.push(`PR creation failed: ${sanitizedError}`);
1275
+ // Provide actionable error messages for common issues
1276
+ let errorMessage = `PR creation failed: ${sanitizedError}`;
1277
+ if (sanitizedError.includes('authentication') || sanitizedError.includes('auth') || sanitizedError.includes('credentials')) {
1278
+ errorMessage = `GitHub authentication failed. Please authenticate using one of:
1279
+ 1. Set GITHUB_TOKEN env var: export GITHUB_TOKEN=ghp_xxx
1280
+ 2. Run: gh auth login
1281
+ 3. Check: gh auth status`;
1282
+ }
1283
+ changesMade.push(errorMessage);
875
1284
  }
876
1285
  // Update status to done
877
- story = updateStoryStatus(story, 'done');
1286
+ story = await updateStoryStatus(story, 'done');
878
1287
  changesMade.push('Updated status to done');
879
1288
  return {
880
1289
  success: true,