ai-sdlc 0.2.0-alpha.5 → 0.2.0-alpha.50

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 (130) 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 +151 -13
  5. package/dist/agents/implementation.js.map +1 -1
  6. package/dist/agents/index.d.ts +2 -0
  7. package/dist/agents/index.d.ts.map +1 -1
  8. package/dist/agents/index.js +2 -0
  9. package/dist/agents/index.js.map +1 -1
  10. package/dist/agents/orchestrator.d.ts +61 -0
  11. package/dist/agents/orchestrator.d.ts.map +1 -0
  12. package/dist/agents/orchestrator.js +443 -0
  13. package/dist/agents/orchestrator.js.map +1 -0
  14. package/dist/agents/planning.d.ts +1 -1
  15. package/dist/agents/planning.d.ts.map +1 -1
  16. package/dist/agents/planning.js +55 -4
  17. package/dist/agents/planning.js.map +1 -1
  18. package/dist/agents/refinement.d.ts.map +1 -1
  19. package/dist/agents/refinement.js +22 -3
  20. package/dist/agents/refinement.js.map +1 -1
  21. package/dist/agents/research.d.ts +85 -1
  22. package/dist/agents/research.d.ts.map +1 -1
  23. package/dist/agents/research.js +506 -16
  24. package/dist/agents/research.js.map +1 -1
  25. package/dist/agents/review.d.ts +77 -2
  26. package/dist/agents/review.d.ts.map +1 -1
  27. package/dist/agents/review.js +615 -93
  28. package/dist/agents/review.js.map +1 -1
  29. package/dist/agents/rework.d.ts.map +1 -1
  30. package/dist/agents/rework.js +22 -3
  31. package/dist/agents/rework.js.map +1 -1
  32. package/dist/agents/single-task.d.ts +41 -0
  33. package/dist/agents/single-task.d.ts.map +1 -0
  34. package/dist/agents/single-task.js +357 -0
  35. package/dist/agents/single-task.js.map +1 -0
  36. package/dist/agents/state-assessor.d.ts +3 -3
  37. package/dist/agents/state-assessor.d.ts.map +1 -1
  38. package/dist/agents/state-assessor.js +6 -6
  39. package/dist/agents/state-assessor.js.map +1 -1
  40. package/dist/agents/test-pattern-detector.d.ts +49 -0
  41. package/dist/agents/test-pattern-detector.d.ts.map +1 -0
  42. package/dist/agents/test-pattern-detector.js +273 -0
  43. package/dist/agents/test-pattern-detector.js.map +1 -0
  44. package/dist/agents/verification.d.ts +11 -0
  45. package/dist/agents/verification.d.ts.map +1 -1
  46. package/dist/agents/verification.js +97 -12
  47. package/dist/agents/verification.js.map +1 -1
  48. package/dist/cli/commands/migrate.js +1 -1
  49. package/dist/cli/commands/migrate.js.map +1 -1
  50. package/dist/cli/commands.d.ts +65 -3
  51. package/dist/cli/commands.d.ts.map +1 -1
  52. package/dist/cli/commands.js +1108 -204
  53. package/dist/cli/commands.js.map +1 -1
  54. package/dist/cli/daemon.d.ts.map +1 -1
  55. package/dist/cli/daemon.js +20 -3
  56. package/dist/cli/daemon.js.map +1 -1
  57. package/dist/cli/runner.d.ts.map +1 -1
  58. package/dist/cli/runner.js +19 -11
  59. package/dist/cli/runner.js.map +1 -1
  60. package/dist/core/auth.d.ts +43 -0
  61. package/dist/core/auth.d.ts.map +1 -1
  62. package/dist/core/auth.js +105 -1
  63. package/dist/core/auth.js.map +1 -1
  64. package/dist/core/client.d.ts +6 -0
  65. package/dist/core/client.d.ts.map +1 -1
  66. package/dist/core/client.js +57 -3
  67. package/dist/core/client.js.map +1 -1
  68. package/dist/core/config.d.ts +24 -1
  69. package/dist/core/config.d.ts.map +1 -1
  70. package/dist/core/config.js +100 -3
  71. package/dist/core/config.js.map +1 -1
  72. package/dist/core/conflict-detector.d.ts +108 -0
  73. package/dist/core/conflict-detector.d.ts.map +1 -0
  74. package/dist/core/conflict-detector.js +413 -0
  75. package/dist/core/conflict-detector.js.map +1 -0
  76. package/dist/core/git-utils.d.ts +28 -0
  77. package/dist/core/git-utils.d.ts.map +1 -0
  78. package/dist/core/git-utils.js +146 -0
  79. package/dist/core/git-utils.js.map +1 -0
  80. package/dist/core/index.d.ts +19 -0
  81. package/dist/core/index.d.ts.map +1 -0
  82. package/dist/core/index.js +19 -0
  83. package/dist/core/index.js.map +1 -0
  84. package/dist/core/kanban.d.ts +1 -1
  85. package/dist/core/kanban.d.ts.map +1 -1
  86. package/dist/core/kanban.js +7 -6
  87. package/dist/core/kanban.js.map +1 -1
  88. package/dist/core/llm-utils.d.ts +103 -0
  89. package/dist/core/llm-utils.d.ts.map +1 -0
  90. package/dist/core/llm-utils.js +368 -0
  91. package/dist/core/llm-utils.js.map +1 -0
  92. package/dist/core/logger.d.ts +92 -0
  93. package/dist/core/logger.d.ts.map +1 -0
  94. package/dist/core/logger.js +221 -0
  95. package/dist/core/logger.js.map +1 -0
  96. package/dist/core/story-logger.d.ts +102 -0
  97. package/dist/core/story-logger.d.ts.map +1 -0
  98. package/dist/core/story-logger.js +265 -0
  99. package/dist/core/story-logger.js.map +1 -0
  100. package/dist/core/story.d.ts +89 -20
  101. package/dist/core/story.d.ts.map +1 -1
  102. package/dist/core/story.js +297 -52
  103. package/dist/core/story.js.map +1 -1
  104. package/dist/core/task-parser.d.ts +59 -0
  105. package/dist/core/task-parser.d.ts.map +1 -0
  106. package/dist/core/task-parser.js +235 -0
  107. package/dist/core/task-parser.js.map +1 -0
  108. package/dist/core/task-progress.d.ts +92 -0
  109. package/dist/core/task-progress.d.ts.map +1 -0
  110. package/dist/core/task-progress.js +280 -0
  111. package/dist/core/task-progress.js.map +1 -0
  112. package/dist/core/workflow-state.d.ts +45 -6
  113. package/dist/core/workflow-state.d.ts.map +1 -1
  114. package/dist/core/workflow-state.js +201 -12
  115. package/dist/core/workflow-state.js.map +1 -1
  116. package/dist/core/worktree.d.ts +77 -0
  117. package/dist/core/worktree.d.ts.map +1 -0
  118. package/dist/core/worktree.js +246 -0
  119. package/dist/core/worktree.js.map +1 -0
  120. package/dist/index.js +135 -5
  121. package/dist/index.js.map +1 -1
  122. package/dist/services/error-classifier.d.ts +119 -0
  123. package/dist/services/error-classifier.d.ts.map +1 -0
  124. package/dist/services/error-classifier.js +182 -0
  125. package/dist/services/error-classifier.js.map +1 -0
  126. package/dist/types/index.d.ts +336 -1
  127. package/dist/types/index.d.ts.map +1 -1
  128. package/dist/types/index.js +1 -0
  129. package/dist/types/index.js.map +1 -1
  130. package/package.json +4 -1
@@ -1,12 +1,15 @@
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';
9
+ import { extractStructuredResponseSync } from '../core/llm-utils.js';
8
10
  import { ReviewDecision, ReviewSeverity } from '../types/index.js';
9
11
  import { sanitizeInput, truncateText } from '../cli/formatting.js';
12
+ import { detectTestDuplicationPatterns } from './test-pattern-detector.js';
10
13
  /**
11
14
  * Security: Validate Git branch name to prevent command injection
12
15
  * Only allows alphanumeric characters, hyphens, underscores, and forward slashes
@@ -93,7 +96,9 @@ const ReviewIssueSchema = z.object({
93
96
  // This handles LLM responses that return {"line": null} instead of omitting the field
94
97
  file: z.string().nullish().transform(v => v ?? undefined),
95
98
  line: z.number().int().positive().nullish().transform(v => v ?? undefined),
96
- suggestedFix: z.string().max(2000).nullish().transform(v => v ?? undefined),
99
+ suggestedFix: z.string().max(5000).nullish().transform(v => v ?? undefined),
100
+ // Perspectives field for unified review (optional for backward compatibility)
101
+ perspectives: z.array(z.enum(['code', 'security', 'po'])).optional(),
97
102
  });
98
103
  const ReviewResponseSchema = z.object({
99
104
  passed: z.boolean(),
@@ -248,23 +253,138 @@ Output your review as a JSON object with this structure:
248
253
  "issues": [
249
254
  {
250
255
  "severity": "blocker" | "critical" | "major" | "minor",
251
- "category": "code_quality" | "security" | "requirements" | "testing" | etc,
256
+ "category": "code_quality" | "security" | "requirements" | "testing" | "test_alignment" | etc,
252
257
  "description": "Detailed description of the issue",
253
258
  "file": "path/to/file.ts" (if applicable),
254
259
  "line": 42 (if applicable),
255
- "suggestedFix": "How to fix this issue"
260
+ "suggestedFix": "How to fix this issue",
261
+ "perspectives": ["code", "security", "po"] (which perspectives this issue relates to)
256
262
  }
257
263
  ]
258
264
  }
259
265
 
260
266
  Severity guidelines:
261
- - blocker: Must be fixed before merging (security holes, broken functionality)
267
+ - blocker: Must be fixed before merging (security holes, broken functionality, test misalignment)
262
268
  - critical: Should be fixed before merging (major bugs, poor practices)
263
269
  - major: Should be addressed soon (code quality, maintainability)
264
270
  - minor: Nice to have improvements (style, optimizations)
265
271
 
266
272
  If no issues found, return: {"passed": true, "issues": []}
267
273
  `;
274
+ /**
275
+ * Unified Review Prompt - combines code, security, and product owner perspectives
276
+ * into a single collaborative review to eliminate duplicate issues.
277
+ */
278
+ const UNIFIED_REVIEW_PROMPT = `You are a senior engineering team conducting a comprehensive collaborative review.
279
+
280
+ You must evaluate the implementation from THREE perspectives simultaneously, but produce ONE unified set of issues:
281
+
282
+ ## Perspective 1: Code Quality (Senior Developer)
283
+ Evaluate:
284
+ - Code quality and maintainability
285
+ - Following best practices and design patterns
286
+ - Potential bugs or logic errors
287
+ - Test coverage adequacy and test quality
288
+ - Error handling completeness
289
+ - Performance considerations
290
+
291
+ ## Perspective 2: Security (Security Engineer)
292
+ Evaluate:
293
+ - OWASP Top 10 vulnerabilities
294
+ - Input validation and sanitization
295
+ - Authentication and authorization issues
296
+ - Data exposure risks
297
+ - Command injection vulnerabilities
298
+ - Secure coding practices
299
+
300
+ ## Perspective 3: Requirements (Product Owner)
301
+ Evaluate:
302
+ - Does it meet the acceptance criteria stated in the story?
303
+ - Is the user experience appropriate and intuitive?
304
+ - Are edge cases and error scenarios handled?
305
+ - Is documentation adequate for users and maintainers?
306
+ - Does the implementation align with the story goals?
307
+
308
+ ## Test-Implementation Alignment (BLOCKER category)
309
+
310
+ **CRITICAL PRE-REVIEW REQUIREMENT**: Tests have already been executed and passed. However, passing tests don't guarantee correctness if they verify outdated behavior.
311
+
312
+ During code review, you MUST verify test alignment:
313
+
314
+ 1. **For each changed production file, identify its test file**
315
+ - Check if tests exist for modified functions/modules
316
+ - Read the test assertions carefully
317
+
318
+ 2. **Verify tests match NEW behavior, not OLD**
319
+ - Do test assertions expect the current implementation behavior?
320
+ - If production code changed from sync to async, do tests use await?
321
+ - If function signature changed, do tests call it correctly?
322
+ - If return values changed, do tests expect the new values?
323
+
324
+ 3. **Flag misalignment as BLOCKER**
325
+ - If tests reference changed code but still expect old behavior:
326
+ - This is a **BLOCKER** severity issue
327
+ - Category MUST be: \`"test_alignment"\`
328
+ - Specify which test files need updating and why
329
+ - Provide example of correct assertion for new behavior
330
+
331
+ **Example of misaligned test (BLOCKER):**
332
+ \`\`\`typescript
333
+ // Production code changed from sync to async
334
+ async function loadConfig(): Promise<Config> {
335
+ return await fetchConfig();
336
+ }
337
+
338
+ // Test still expects sync behavior - MISSING await (BLOCKER)
339
+ test('loads config', () => {
340
+ const config = loadConfig(); // ❌ Missing await! Returns Promise<Config>, not Config
341
+ expect(config.port).toBe(3000); // ❌ Checking Promise.port, not config.port
342
+ });
343
+
344
+ // Correct aligned test:
345
+ test('loads config', async () => {
346
+ const config = await loadConfig(); // ✅ Awaits async function
347
+ expect(config.port).toBe(3000); // ✅ Checks actual config
348
+ });
349
+ \`\`\`
350
+
351
+ **When to flag test_alignment issues:**
352
+ - Tests verify old function signatures that no longer exist
353
+ - Tests expect old return value formats that changed
354
+ - Tests miss new error conditions introduced
355
+ - Tests pass but don't exercise the new code paths
356
+ - Mock expectations don't match the new implementation calls
357
+
358
+ ## CRITICAL DEDUPLICATION INSTRUCTIONS:
359
+
360
+ 1. **DO NOT repeat the same underlying issue from different perspectives**
361
+ - If multiple perspectives notice the same problem, list it ONCE
362
+ - Use the \`perspectives\` array to indicate which perspectives it affects
363
+
364
+ 2. **Prioritize by actual impact, not by how many perspectives notice it**
365
+ - A issue seen by all 3 perspectives is still just ONE issue
366
+ - Focus on the distinct, actionable problems that need fixing
367
+
368
+ 3. **If the fundamental problem is "no implementation exists" or "functionality completely missing":**
369
+ - Report this as ONE blocker issue, not three separate issues
370
+ - Use perspectives: ["code", "security", "po"] to show all perspectives agree
371
+
372
+ 4. **Combine related issues into single, comprehensive descriptions:**
373
+ - Instead of: "No tests" (code) + "Untested security" (security) + "No validation tests" (po)
374
+ - Write: "No tests exist for the implementation" with perspectives: ["code", "security", "po"]
375
+
376
+ 5. **Each issue should have a clear, single suggested fix**
377
+ - Avoid vague suggestions like "improve everything"
378
+ - Be specific and actionable
379
+
380
+ ${REVIEW_OUTPUT_FORMAT}
381
+
382
+ Remember: Your goal is to produce a clean, deduplicated list of actual distinct problems, not to maximize issue count.`;
383
+ /**
384
+ * Legacy prompts - kept for reference only
385
+ * @deprecated These are replaced by UNIFIED_REVIEW_PROMPT which combines all three perspectives.
386
+ * The unified prompt reduces LLM calls from 3 to 1 and eliminates duplicate issues.
387
+ */
268
388
  const CODE_REVIEW_PROMPT = `You are a senior code reviewer. Review the implementation for:
269
389
  1. Code quality and maintainability
270
390
  2. Following best practices
@@ -272,6 +392,9 @@ const CODE_REVIEW_PROMPT = `You are a senior code reviewer. Review the implement
272
392
  4. Test coverage adequacy
273
393
 
274
394
  ${REVIEW_OUTPUT_FORMAT}`;
395
+ /**
396
+ * @deprecated Use UNIFIED_REVIEW_PROMPT instead
397
+ */
275
398
  const SECURITY_REVIEW_PROMPT = `You are a security specialist. Review the implementation for:
276
399
  1. OWASP Top 10 vulnerabilities
277
400
  2. Input validation issues
@@ -279,6 +402,9 @@ const SECURITY_REVIEW_PROMPT = `You are a security specialist. Review the implem
279
402
  4. Data exposure risks
280
403
 
281
404
  ${REVIEW_OUTPUT_FORMAT}`;
405
+ /**
406
+ * @deprecated Use UNIFIED_REVIEW_PROMPT instead
407
+ */
282
408
  const PO_REVIEW_PROMPT = `You are a product owner validating the implementation. Check:
283
409
  1. Does it meet the acceptance criteria?
284
410
  2. Is the user experience appropriate?
@@ -288,26 +414,25 @@ const PO_REVIEW_PROMPT = `You are a product owner validating the implementation.
288
414
  ${REVIEW_OUTPUT_FORMAT}`;
289
415
  /**
290
416
  * Parse review response and extract structured issues
417
+ * Uses extractStructuredResponseSync for robust parsing with multiple strategies:
418
+ * 1. Direct JSON parse
419
+ * 2. JSON within markdown code blocks
420
+ * 3. JSON with leading/trailing text stripped
421
+ * 4. YAML format fallback
422
+ *
291
423
  * Security: Uses zod schema validation to prevent malicious JSON
292
424
  */
293
425
  function parseReviewResponse(response, reviewType) {
294
- try {
295
- // Try to extract JSON from the response
296
- const jsonMatch = response.match(/\{[\s\S]*\}/);
297
- if (!jsonMatch) {
298
- // Fallback: no JSON found, analyze text
299
- return parseTextReview(response, reviewType);
300
- }
301
- const parsed = JSON.parse(jsonMatch[0]);
302
- // Security: Validate against zod schema before using the data
303
- const validationResult = ReviewResponseSchema.safeParse(parsed);
304
- if (!validationResult.success) {
305
- // Log validation errors for debugging
306
- console.warn('Review response failed schema validation:', validationResult.error);
307
- // Fallback to text analysis
308
- return parseTextReview(response, reviewType);
309
- }
310
- const validated = validationResult.data;
426
+ const logger = getLogger();
427
+ // Use the robust extraction utility with all strategies
428
+ const extractionResult = extractStructuredResponseSync(response, ReviewResponseSchema, false);
429
+ if (extractionResult.success && extractionResult.data) {
430
+ const validated = extractionResult.data;
431
+ logger.debug('review', `Successfully parsed review response using strategy: ${extractionResult.strategy}`, {
432
+ reviewType,
433
+ strategy: extractionResult.strategy,
434
+ issueCount: validated.issues.length,
435
+ });
311
436
  // Map validated data to ReviewIssue format (additional sanitization)
312
437
  const issues = validated.issues.map((issue) => ({
313
438
  severity: issue.severity,
@@ -316,17 +441,20 @@ function parseReviewResponse(response, reviewType) {
316
441
  file: issue.file,
317
442
  line: issue.line,
318
443
  suggestedFix: issue.suggestedFix,
444
+ perspectives: issue.perspectives,
319
445
  }));
320
446
  return {
321
447
  passed: validated.passed !== false && issues.filter(i => i.severity === 'blocker' || i.severity === 'critical').length === 0,
322
448
  issues,
323
449
  };
324
450
  }
325
- catch (error) {
326
- // Fallback to text analysis if JSON parsing fails
327
- console.warn('Review response parsing error:', error);
328
- return parseTextReview(response, reviewType);
329
- }
451
+ // All extraction strategies failed - log raw response for debugging and use text fallback
452
+ logger.warn('review', 'All extraction strategies failed for review response', {
453
+ reviewType,
454
+ error: extractionResult.error,
455
+ responsePreview: response.substring(0, 200),
456
+ });
457
+ return parseTextReview(response, reviewType);
330
458
  }
331
459
  /**
332
460
  * Fallback: Parse text-based review response (for when LLM doesn't return JSON)
@@ -383,8 +511,35 @@ function determineReviewSeverity(issues) {
383
511
  return ReviewSeverity.LOW;
384
512
  }
385
513
  }
514
+ /**
515
+ * Derive individual perspective pass/fail status from issues
516
+ *
517
+ * For backward compatibility with ReviewAttempt structure, determines whether
518
+ * each perspective (code, security, po) would pass based on issues flagged
519
+ * for that perspective.
520
+ *
521
+ * A perspective fails if it has any blocker or critical issues.
522
+ *
523
+ * @param issues - Array of review issues with perspectives field
524
+ * @returns Object with pass/fail status for each perspective
525
+ */
526
+ export function deriveIndividualPassFailFromPerspectives(issues) {
527
+ // Check if any blocker/critical issues exist for each perspective
528
+ const codeIssues = issues.filter(i => i.perspectives?.includes('code') &&
529
+ (i.severity === 'blocker' || i.severity === 'critical'));
530
+ const securityIssues = issues.filter(i => i.perspectives?.includes('security') &&
531
+ (i.severity === 'blocker' || i.severity === 'critical'));
532
+ const poIssues = issues.filter(i => i.perspectives?.includes('po') &&
533
+ (i.severity === 'blocker' || i.severity === 'critical'));
534
+ return {
535
+ codeReviewPassed: codeIssues.length === 0,
536
+ securityReviewPassed: securityIssues.length === 0,
537
+ poReviewPassed: poIssues.length === 0,
538
+ };
539
+ }
386
540
  /**
387
541
  * Aggregate issues from multiple reviews and determine overall pass/fail
542
+ * @deprecated No longer used with unified review. Kept for reference only.
388
543
  */
389
544
  function aggregateReviews(codeResult, securityResult, poResult) {
390
545
  const allIssues = [...codeResult.issues, ...securityResult.issues, ...poResult.issues];
@@ -399,6 +554,7 @@ function aggregateReviews(codeResult, securityResult, poResult) {
399
554
  }
400
555
  /**
401
556
  * Format issues for display in review notes
557
+ * Shows perspectives (code, security, po) when available
402
558
  */
403
559
  function formatIssuesForDisplay(issues) {
404
560
  if (issues.length === 0) {
@@ -417,7 +573,11 @@ function formatIssuesForDisplay(issues) {
417
573
  const icon = severity === 'blocker' ? '🛑' : severity === 'critical' ? '⚠️' : severity === 'major' ? '📋' : 'ℹ️';
418
574
  output += `\n#### ${icon} ${severity.toUpperCase()} (${issueList.length})\n\n`;
419
575
  for (const issue of issueList) {
420
- output += `**${issue.category}**: ${issue.description}\n`;
576
+ // Format perspectives indicator if present
577
+ const perspectivesTag = issue.perspectives && issue.perspectives.length > 0
578
+ ? ` [${issue.perspectives.join(', ')}]`
579
+ : '';
580
+ output += `**${issue.category}**${perspectivesTag}: ${issue.description}\n`;
421
581
  if (issue.file) {
422
582
  output += ` - File: \`${issue.file}\`${issue.line ? `:${issue.line}` : ''}\n`;
423
583
  }
@@ -429,6 +589,74 @@ function formatIssuesForDisplay(issues) {
429
589
  }
430
590
  return output;
431
591
  }
592
+ /**
593
+ * Get source code changes from git diff
594
+ *
595
+ * Returns list of source files that have been modified (excludes tests and story files).
596
+ * Uses spawnSync for security (prevents command injection).
597
+ *
598
+ * @param workingDir - Working directory to run git diff in
599
+ * @returns Array of source file paths that have changed, or ['unknown'] if git fails
600
+ */
601
+ export function getSourceCodeChanges(workingDir) {
602
+ try {
603
+ // Security: Use spawnSync with explicit args (not shell) to prevent injection
604
+ const result = spawnSync('git', ['diff', '--name-only', 'HEAD~1'], {
605
+ cwd: workingDir,
606
+ encoding: 'utf-8',
607
+ stdio: ['ignore', 'pipe', 'pipe'],
608
+ });
609
+ if (result.status !== 0) {
610
+ // Git command failed - fail open (assume changes exist)
611
+ return ['unknown'];
612
+ }
613
+ const output = result.stdout.toString();
614
+ return output
615
+ .split('\n')
616
+ .filter(f => f.trim())
617
+ .filter(f => /\.(ts|tsx|js|jsx)$/.test(f)) // Source files only
618
+ .filter(f => !f.includes('.test.')) // Exclude test files
619
+ .filter(f => !f.includes('.spec.')) // Exclude spec files
620
+ .filter(f => !f.startsWith('.ai-sdlc/')); // Exclude story files
621
+ }
622
+ catch {
623
+ // If git diff fails, assume there are changes (fail open, not closed)
624
+ return ['unknown'];
625
+ }
626
+ }
627
+ /**
628
+ * Check if test files exist in git diff
629
+ *
630
+ * Returns true if any test files have been modified/added, false otherwise.
631
+ * Uses spawnSync for security (prevents command injection).
632
+ *
633
+ * @param workingDir - Working directory to run git diff in
634
+ * @returns True if test files exist in changes, false otherwise
635
+ */
636
+ export function hasTestFiles(workingDir) {
637
+ try {
638
+ // Security: Use spawnSync with explicit args (not shell) to prevent injection
639
+ const result = spawnSync('git', ['diff', '--name-only', 'HEAD~1'], {
640
+ cwd: workingDir,
641
+ encoding: 'utf-8',
642
+ stdio: ['ignore', 'pipe', 'pipe'],
643
+ });
644
+ if (result.status !== 0) {
645
+ // Git command failed - fail open (assume tests exist to avoid false blocks)
646
+ return true;
647
+ }
648
+ const output = result.stdout.toString();
649
+ const files = output.split('\n').filter(f => f.trim());
650
+ // Check if any files match test patterns
651
+ return files.some(f => f.includes('.test.') ||
652
+ f.includes('.spec.') ||
653
+ f.includes('__tests__/'));
654
+ }
655
+ catch {
656
+ // If git diff fails, assume tests exist (fail open, not closed)
657
+ return true;
658
+ }
659
+ }
432
660
  /**
433
661
  * Generate executive summary from review issues (1-3 sentences)
434
662
  *
@@ -526,9 +754,15 @@ export function generateReviewSummary(issues, terminalWidth) {
526
754
  * Now returns structured ReviewResult with pass/fail and issues.
527
755
  */
528
756
  export async function runReviewAgent(storyPath, sdlcRoot, options) {
757
+ const logger = getLogger();
758
+ const startTime = Date.now();
529
759
  const story = parseStory(storyPath);
530
760
  const changesMade = [];
531
761
  const workingDir = path.dirname(sdlcRoot);
762
+ logger.info('review', 'Starting review phase', {
763
+ storyId: story.frontmatter.id,
764
+ retryCount: story.frontmatter.retry_count || 0,
765
+ });
532
766
  // Security: Validate working directory before any operations
533
767
  try {
534
768
  validateWorkingDirectory(workingDir);
@@ -554,14 +788,14 @@ export async function runReviewAgent(storyPath, sdlcRoot, options) {
554
788
  const config = loadConfig(workingDir);
555
789
  try {
556
790
  // Snapshot max_retries from config (protects against mid-cycle config changes)
557
- snapshotMaxRetries(story, config);
791
+ await snapshotMaxRetries(story, config);
558
792
  // Check if story has reached max retries
559
793
  if (isAtMaxRetries(story, config)) {
560
794
  const retryCount = story.frontmatter.retry_count || 0;
561
795
  const maxRetries = getEffectiveMaxRetries(story, config);
562
796
  const maxRetriesDisplay = Number.isFinite(maxRetries) ? maxRetries : '∞';
563
797
  const errorMsg = `Story has reached maximum retry limit (${retryCount}/${maxRetriesDisplay}). Manual intervention required.`;
564
- updateStoryField(story, 'last_error', errorMsg);
798
+ await updateStoryField(story, 'last_error', errorMsg);
565
799
  changesMade.push(errorMsg);
566
800
  return {
567
801
  success: false,
@@ -579,6 +813,95 @@ export async function runReviewAgent(storyPath, sdlcRoot, options) {
579
813
  feedback: errorMsg,
580
814
  };
581
815
  }
816
+ // PRE-CHECK GATE: Detect documentation-only implementations before running expensive LLM reviews
817
+ const sourceChanges = getSourceCodeChanges(workingDir);
818
+ if (sourceChanges.length === 0) {
819
+ // No source code changes detected - check if we can recover
820
+ const retryCount = story.frontmatter.implementation_retry_count || 0;
821
+ const maxRetries = getEffectiveMaxImplementationRetries(story, config);
822
+ if (retryCount < maxRetries) {
823
+ // RECOVERABLE: Trigger implementation recovery
824
+ logger.warn('review', 'No source code changes detected - triggering implementation recovery', {
825
+ storyId: story.frontmatter.id,
826
+ retryCount,
827
+ maxRetries,
828
+ });
829
+ await updateStoryField(story, 'implementation_complete', false);
830
+ await updateStoryField(story, 'last_restart_reason', 'No source code changes detected. Implementation wrote documentation only.');
831
+ return {
832
+ success: true,
833
+ story: parseStory(storyPath),
834
+ changesMade: ['Detected documentation-only implementation', 'Triggered implementation recovery'],
835
+ passed: false,
836
+ decision: ReviewDecision.RECOVERY,
837
+ reviewType: 'pre-check',
838
+ issues: [{
839
+ severity: 'critical',
840
+ category: 'implementation',
841
+ description: 'No source code modifications detected. Re-running implementation phase.',
842
+ }],
843
+ feedback: 'Implementation recovery triggered - no source changes found.',
844
+ };
845
+ }
846
+ else {
847
+ // NON-RECOVERABLE: Max retries reached
848
+ const maxRetriesDisplay = Number.isFinite(maxRetries) ? maxRetries : '∞';
849
+ logger.error('review', 'No source code changes detected and max implementation retries reached', {
850
+ storyId: story.frontmatter.id,
851
+ retryCount,
852
+ maxRetries,
853
+ });
854
+ return {
855
+ success: true,
856
+ story: parseStory(storyPath),
857
+ changesMade: ['Detected documentation-only implementation', 'Max retries reached'],
858
+ passed: false,
859
+ decision: ReviewDecision.FAILED,
860
+ severity: ReviewSeverity.CRITICAL,
861
+ reviewType: 'pre-check',
862
+ issues: [{
863
+ severity: 'blocker',
864
+ category: 'implementation',
865
+ description: `Implementation phase wrote documentation/planning only - no source code was modified. This has occurred ${retryCount} time(s) (max: ${maxRetriesDisplay}). Manual intervention required.`,
866
+ 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.',
867
+ }],
868
+ feedback: 'Implementation failed to produce code changes after multiple attempts.',
869
+ };
870
+ }
871
+ }
872
+ // Source changes exist - proceed with normal review flow
873
+ logger.info('review', 'Source code changes detected - proceeding with verification', {
874
+ storyId: story.frontmatter.id,
875
+ fileCount: sourceChanges.length,
876
+ });
877
+ // PRE-CHECK GATE: Check if test files exist
878
+ const testsExist = hasTestFiles(workingDir);
879
+ if (!testsExist) {
880
+ logger.warn('review', 'No test files detected in implementation changes', {
881
+ storyId: story.frontmatter.id,
882
+ });
883
+ return {
884
+ success: true,
885
+ story: parseStory(storyPath),
886
+ changesMade: ['No test files found for implementation'],
887
+ passed: false,
888
+ decision: ReviewDecision.REJECTED,
889
+ severity: ReviewSeverity.CRITICAL,
890
+ reviewType: 'pre-check',
891
+ issues: [{
892
+ severity: 'blocker',
893
+ category: 'testing',
894
+ description: 'No tests found for this implementation. All implementations must include tests.',
895
+ suggestedFix: 'Add test files (*.test.ts, *.spec.ts, or files in __tests__/ directory) that verify the implementation.',
896
+ }],
897
+ feedback: formatIssuesForDisplay([{
898
+ severity: 'blocker',
899
+ category: 'testing',
900
+ description: 'No tests found for this implementation. All implementations must include tests.',
901
+ suggestedFix: 'Add test files (*.test.ts, *.spec.ts, or files in __tests__/ directory) that verify the implementation.',
902
+ }]),
903
+ };
904
+ }
582
905
  // Run build and tests BEFORE reviews (async with progress)
583
906
  changesMade.push('Running build and test verification...');
584
907
  const verification = await runVerificationAsync(workingDir, config, options?.onVerificationProgress);
@@ -625,7 +948,7 @@ export async function runReviewAgent(storyPath, sdlcRoot, options) {
625
948
  severity: 'blocker',
626
949
  category: 'testing',
627
950
  description: `Tests must pass before code review can proceed.\n\nCommand: ${config.testCommand}\n\nTest output:\n\`\`\`\n${testOutput}${truncationNote}\n\`\`\``,
628
- suggestedFix: 'Fix failing tests before review can proceed.',
951
+ suggestedFix: 'Fix failing tests before review can proceed. If tests are failing after implementation changes, verify that tests were updated to match the new behavior (not just the old behavior).',
629
952
  });
630
953
  verificationContext += `\n## Test Results ❌\nTest command \`${config.testCommand}\` FAILED:\n\`\`\`\n${testOutput}${truncationNote}\n\`\`\`\n`;
631
954
  }
@@ -646,60 +969,82 @@ export async function runReviewAgent(storyPath, sdlcRoot, options) {
646
969
  feedback: formatIssuesForDisplay(verificationIssues),
647
970
  };
648
971
  }
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');
972
+ // Verification passed - proceed with unified collaborative review
973
+ changesMade.push('Verification passed - proceeding with unified collaborative review');
974
+ // Run test pattern detection if enabled
975
+ let testPatternIssues = [];
976
+ if (config.reviewConfig.detectTestAntipatterns !== false) {
977
+ try {
978
+ changesMade.push('Running test anti-pattern detection...');
979
+ testPatternIssues = await detectTestDuplicationPatterns(workingDir);
980
+ if (testPatternIssues.length > 0) {
981
+ changesMade.push(`Detected ${testPatternIssues.length} test anti-pattern(s)`);
982
+ }
983
+ else {
984
+ changesMade.push('No test anti-patterns detected');
985
+ }
986
+ }
987
+ catch (error) {
988
+ // Don't fail review if detection errors - just log and continue
989
+ const errorMsg = error instanceof Error ? error.message : String(error);
990
+ changesMade.push(`Test pattern detection error: ${errorMsg}`);
991
+ }
992
+ }
993
+ const unifiedReviewResponse = await runSubReview(story, UNIFIED_REVIEW_PROMPT, 'Unified Collaborative Review', workingDir, verificationContext);
994
+ // Parse unified review response into structured issues
995
+ const unifiedResult = parseReviewResponse(unifiedReviewResponse, 'Unified Review');
660
996
  // TDD Validation: Check TDD cycle completeness if TDD was enabled for this story
661
997
  const tddEnabled = story.frontmatter.tdd_enabled ?? config.tdd?.enabled ?? false;
662
998
  if (tddEnabled && story.frontmatter.tdd_test_history?.length) {
663
999
  const tddViolations = validateTDDCycles(story.frontmatter.tdd_test_history);
664
1000
  if (tddViolations.length > 0) {
665
1001
  const tddIssues = generateTDDIssues(tddViolations);
666
- codeResult.issues.push(...tddIssues);
667
- codeResult.passed = false;
1002
+ unifiedResult.issues.push(...tddIssues);
1003
+ unifiedResult.passed = false;
668
1004
  changesMade.push(`TDD validation: ${tddViolations.length} violation(s) detected`);
669
1005
  }
670
1006
  else {
671
1007
  changesMade.push('TDD validation: All cycles completed correctly');
672
1008
  }
673
1009
  }
674
- // Add verification issues to code result (they're code-quality related)
675
- codeResult.issues.unshift(...verificationIssues);
1010
+ // Add test pattern issues to unified result (they're code-quality related)
1011
+ if (testPatternIssues.length > 0) {
1012
+ unifiedResult.issues.push(...testPatternIssues);
1013
+ unifiedResult.passed = false;
1014
+ }
1015
+ // Add verification issues to unified result (they're code-quality related)
1016
+ unifiedResult.issues.unshift(...verificationIssues);
676
1017
  if (verificationIssues.length > 0) {
677
- codeResult.passed = false;
1018
+ unifiedResult.passed = false;
678
1019
  }
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
1020
+ // Determine overall pass/fail from unified review
1021
+ const allIssues = unifiedResult.issues;
1022
+ const blockerCount = allIssues.filter(i => i.severity === 'blocker').length;
1023
+ const criticalCount = allIssues.filter(i => i.severity === 'critical').length;
1024
+ const passed = blockerCount === 0 && criticalCount < 2;
1025
+ const severity = determineReviewSeverity(allIssues);
1026
+ // Derive individual perspective pass/fail for backward compatibility
1027
+ const { codeReviewPassed, securityReviewPassed, poReviewPassed } = deriveIndividualPassFailFromPerspectives(allIssues);
1028
+ // Compile review notes with structured format for unified review
682
1029
  const reviewNotes = `
683
- ### Code Review
684
- ${formatIssuesForDisplay(codeResult.issues)}
1030
+ ### Unified Collaborative Review
685
1031
 
686
- ### Security Review
687
- ${formatIssuesForDisplay(securityResult.issues)}
1032
+ ${formatIssuesForDisplay(allIssues)}
688
1033
 
689
- ### Product Owner Review
690
- ${formatIssuesForDisplay(poResult.issues)}
1034
+ ### Perspective Summary
1035
+ - Code Quality: ${codeReviewPassed ? '✅ Passed' : '❌ Failed'}
1036
+ - Security: ${securityReviewPassed ? '✅ Passed' : '❌ Failed'}
1037
+ - Requirements (PO): ${poReviewPassed ? '✅ Passed' : '❌ Failed'}
691
1038
 
692
1039
  ### Overall Result
693
1040
  ${passed ? '✅ **PASSED** - All reviews approved' : '❌ **FAILED** - Issues must be addressed'}
694
1041
 
695
1042
  ---
696
- *Reviews completed: ${new Date().toISOString().split('T')[0]}*
1043
+ *Review completed: ${new Date().toISOString().split('T')[0]}*
697
1044
  `;
698
1045
  // 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');
1046
+ await appendToSection(story, 'Review Notes', reviewNotes);
1047
+ changesMade.push('Added unified collaborative review notes');
703
1048
  // Determine decision
704
1049
  const decision = passed ? ReviewDecision.APPROVED : ReviewDecision.REJECTED;
705
1050
  // Create review attempt record (omit undefined fields to avoid YAML serialization errors)
@@ -709,21 +1054,28 @@ ${passed ? '✅ **PASSED** - All reviews approved' : '❌ **FAILED** - Issues mu
709
1054
  ...(passed ? {} : { severity }),
710
1055
  feedback: passed ? 'All reviews passed' : formatIssuesForDisplay(allIssues),
711
1056
  blockers: allIssues.filter(i => i.severity === 'blocker').map(i => i.description),
712
- codeReviewPassed: codeResult.passed,
713
- securityReviewPassed: securityResult.passed,
714
- poReviewPassed: poResult.passed,
1057
+ codeReviewPassed,
1058
+ securityReviewPassed,
1059
+ poReviewPassed,
715
1060
  };
716
1061
  // Append to review history
717
- appendReviewHistory(story, reviewAttempt);
1062
+ await appendReviewHistory(story, reviewAttempt);
718
1063
  changesMade.push('Recorded review attempt in history');
719
1064
  if (passed) {
720
- updateStoryField(story, 'reviews_complete', true);
1065
+ await updateStoryField(story, 'reviews_complete', true);
721
1066
  changesMade.push('Marked reviews_complete: true');
722
1067
  }
723
1068
  else {
724
1069
  changesMade.push(`Reviews failed with ${allIssues.length} issue(s) - rework required`);
725
1070
  // Don't mark reviews_complete, this will trigger rework
726
1071
  }
1072
+ logger.info('review', 'Review phase complete', {
1073
+ storyId: story.frontmatter.id,
1074
+ durationMs: Date.now() - startTime,
1075
+ passed,
1076
+ decision,
1077
+ issueCount: allIssues.length,
1078
+ });
727
1079
  return {
728
1080
  success: true,
729
1081
  story: parseStory(storyPath),
@@ -739,6 +1091,11 @@ ${passed ? '✅ **PASSED** - All reviews approved' : '❌ **FAILED** - Issues mu
739
1091
  catch (error) {
740
1092
  // Review agent failure - return FAILED decision (doesn't count as retry)
741
1093
  const errorMsg = error instanceof Error ? error.message : String(error);
1094
+ logger.error('review', 'Review phase failed', {
1095
+ storyId: story.frontmatter.id,
1096
+ durationMs: Date.now() - startTime,
1097
+ error: errorMsg,
1098
+ });
742
1099
  return {
743
1100
  success: false,
744
1101
  story,
@@ -756,6 +1113,139 @@ ${passed ? '✅ **PASSED** - All reviews approved' : '❌ **FAILED** - Issues mu
756
1113
  };
757
1114
  }
758
1115
  }
1116
+ /**
1117
+ * Parse story content into sections by level-2 headers (##)
1118
+ * Returns array of {title, content} objects
1119
+ */
1120
+ export function parseContentSections(content) {
1121
+ const sections = [];
1122
+ const lines = content.split('\n');
1123
+ let currentSection = null;
1124
+ for (const line of lines) {
1125
+ const headerMatch = line.match(/^##\s+(.+)$/);
1126
+ if (headerMatch) {
1127
+ if (currentSection)
1128
+ sections.push(currentSection);
1129
+ currentSection = { title: headerMatch[1], content: '' };
1130
+ }
1131
+ else if (currentSection) {
1132
+ currentSection.content += line + '\n';
1133
+ }
1134
+ }
1135
+ if (currentSection)
1136
+ sections.push(currentSection);
1137
+ return sections;
1138
+ }
1139
+ /**
1140
+ * Remove unfinished checkboxes from content (per CLAUDE.md requirement)
1141
+ * Removes lines with `- [ ]` or `* [ ]` patterns
1142
+ * Preserves completed checkboxes `- [x]` and `- [X]`
1143
+ */
1144
+ export function removeUnfinishedCheckboxes(content) {
1145
+ const lines = content.split('\n');
1146
+ const filteredLines = [];
1147
+ for (let i = 0; i < lines.length; i++) {
1148
+ const line = lines[i];
1149
+ // Match unchecked boxes: - [ ] or * [ ] with optional leading whitespace
1150
+ const isUnchecked = /^\s*[-*] \[ \]/.test(line);
1151
+ if (!isUnchecked) {
1152
+ filteredLines.push(line);
1153
+ }
1154
+ }
1155
+ return filteredLines.join('\n');
1156
+ }
1157
+ /**
1158
+ * Generate GitHub blob URL for story file
1159
+ * Parses remote URL and constructs link to story in repository
1160
+ */
1161
+ export function getStoryFileURL(storyPath, branch, workingDir) {
1162
+ try {
1163
+ const remoteUrl = execSync('git remote get-url origin', { cwd: workingDir, encoding: 'utf-8' }).trim();
1164
+ // Parse owner/repo from URL
1165
+ // HTTPS: https://github.com/owner/repo.git
1166
+ // SSH: git@github.com:owner/repo.git
1167
+ const match = remoteUrl.match(/github\.com[:/]([^/]+)\/(.+?)(\.git)?$/);
1168
+ if (!match)
1169
+ return '';
1170
+ const [, owner, repo] = match;
1171
+ const relativePath = path.relative(workingDir, storyPath);
1172
+ return `https://github.com/${owner}/${repo}/blob/${branch}/${relativePath}`;
1173
+ }
1174
+ catch {
1175
+ return '';
1176
+ }
1177
+ }
1178
+ /**
1179
+ * Format PR description from story sections
1180
+ * Includes: Story ID, User Story, Summary, Acceptance Criteria, Implementation Summary
1181
+ * Removes unfinished checkboxes from all sections
1182
+ */
1183
+ export function formatPRDescription(story, storyFileUrl) {
1184
+ const sections = parseContentSections(story.content);
1185
+ // Extract key sections
1186
+ const userStory = sections.find(s => s.title === 'User Story')?.content || '';
1187
+ const summary = sections.find(s => s.title === 'Summary')?.content || '';
1188
+ const acceptanceCriteria = sections.find(s => s.title === 'Acceptance Criteria')?.content || '';
1189
+ const implementationSummary = sections.find(s => s.title === 'Implementation Summary')?.content || '';
1190
+ // Remove unfinished checkboxes from all sections
1191
+ const cleanAcceptanceCriteria = removeUnfinishedCheckboxes(acceptanceCriteria);
1192
+ const cleanImplementationSummary = removeUnfinishedCheckboxes(implementationSummary);
1193
+ // Build PR body
1194
+ let prBody = `## Story ID\n\n${story.frontmatter.id}\n\n`;
1195
+ if (userStory.trim()) {
1196
+ prBody += `## User Story\n\n${userStory.trim()}\n\n`;
1197
+ }
1198
+ if (summary.trim()) {
1199
+ prBody += `## Summary\n\n${summary.trim()}\n\n`;
1200
+ }
1201
+ if (cleanAcceptanceCriteria.trim()) {
1202
+ prBody += `## Acceptance Criteria\n\n${cleanAcceptanceCriteria.trim()}\n\n`;
1203
+ }
1204
+ if (cleanImplementationSummary.trim()) {
1205
+ prBody += `## Implementation Summary\n\n${cleanImplementationSummary.trim()}\n\n`;
1206
+ }
1207
+ // Add story file link
1208
+ if (storyFileUrl) {
1209
+ prBody += `---\n\n📋 [View Full Story](${storyFileUrl})\n`;
1210
+ }
1211
+ return prBody;
1212
+ }
1213
+ /**
1214
+ * Truncate PR body to respect GitHub's 65K character limit
1215
+ * Truncates Implementation Summary first (most verbose section)
1216
+ * Adds clear truncation indicator with story link
1217
+ */
1218
+ export function truncatePRBody(body, maxLength = 64000) {
1219
+ // Check if truncation needed
1220
+ if (body.length <= maxLength) {
1221
+ return body;
1222
+ }
1223
+ // Find Implementation Summary section
1224
+ const implSummaryMatch = body.match(/(## Implementation Summary\n\n)([\s\S]*?)(\n\n##|\n\n---|\n\n📋|$)/);
1225
+ if (implSummaryMatch) {
1226
+ const [fullMatch, header, content, trailer] = implSummaryMatch;
1227
+ const beforeImpl = body.substring(0, body.indexOf(fullMatch));
1228
+ const afterImpl = body.substring(body.indexOf(fullMatch) + fullMatch.length);
1229
+ // Calculate how much we need to remove
1230
+ const overhead = beforeImpl.length + header.length + trailer.length + afterImpl.length;
1231
+ const truncationIndicator = '\n\n⚠️ Implementation Summary truncated due to length. See full story for complete details.\n';
1232
+ const availableForContent = maxLength - overhead - truncationIndicator.length;
1233
+ if (availableForContent > 100) {
1234
+ // Truncate Implementation Summary at paragraph boundary
1235
+ let truncatedContent = content.substring(0, availableForContent);
1236
+ const lastParagraph = truncatedContent.lastIndexOf('\n\n');
1237
+ if (lastParagraph > 0) {
1238
+ truncatedContent = truncatedContent.substring(0, lastParagraph);
1239
+ }
1240
+ return beforeImpl + header + truncatedContent + truncationIndicator + trailer + afterImpl;
1241
+ }
1242
+ }
1243
+ // Fallback: simple truncation if no Implementation Summary found
1244
+ const truncatedBody = body.substring(0, maxLength - 200);
1245
+ const lastParagraph = truncatedBody.lastIndexOf('\n\n');
1246
+ const finalBody = lastParagraph > 0 ? truncatedBody.substring(0, lastParagraph) : truncatedBody;
1247
+ return finalBody + '\n\n⚠️ Description truncated due to length. See full story for complete details.\n';
1248
+ }
759
1249
  /**
760
1250
  * Run a sub-review with a specific prompt
761
1251
  */
@@ -782,7 +1272,7 @@ Provide your ${reviewType} feedback. Be specific and actionable.`;
782
1272
  /**
783
1273
  * Create a pull request for the completed story
784
1274
  */
785
- export async function createPullRequest(storyPath, sdlcRoot) {
1275
+ export async function createPullRequest(storyPath, sdlcRoot, options) {
786
1276
  let story = parseStory(storyPath);
787
1277
  const changesMade = [];
788
1278
  const workingDir = path.dirname(sdlcRoot);
@@ -819,7 +1309,7 @@ export async function createPullRequest(storyPath, sdlcRoot) {
819
1309
  catch {
820
1310
  changesMade.push('GitHub CLI not available - PR creation skipped');
821
1311
  // Still update to done for MVP
822
- story = updateStoryStatus(story, 'done');
1312
+ story = await updateStoryStatus(story, 'done');
823
1313
  changesMade.push('Updated status to done');
824
1314
  return {
825
1315
  success: true,
@@ -844,37 +1334,69 @@ export async function createPullRequest(storyPath, sdlcRoot) {
844
1334
  // Push branch (already validated)
845
1335
  execSync(`git push -u origin ${branchName}`, { cwd: workingDir, stdio: 'pipe' });
846
1336
  changesMade.push(`Pushed branch: ${branchName}`);
847
- // Create PR using gh CLI with safe arguments
848
- // Security: Use escaped arguments to prevent shell injection
1337
+ // Check if PR already exists for this branch
1338
+ try {
1339
+ const existingPROutput = execSync('gh pr view --json url', { cwd: workingDir, encoding: 'utf-8', stdio: 'pipe' });
1340
+ const prData = JSON.parse(existingPROutput);
1341
+ if (prData.url) {
1342
+ changesMade.push(`PR already exists: ${prData.url}`);
1343
+ // Update story with PR URL if missing
1344
+ if (!story.frontmatter.pr_url) {
1345
+ await updateStoryField(story, 'pr_url', prData.url);
1346
+ changesMade.push('Updated story with existing PR URL');
1347
+ }
1348
+ // Don't create duplicate - skip to status update
1349
+ story = await updateStoryStatus(story, 'done');
1350
+ changesMade.push('Updated status to done');
1351
+ return {
1352
+ success: true,
1353
+ story,
1354
+ changesMade,
1355
+ };
1356
+ }
1357
+ }
1358
+ catch {
1359
+ // No existing PR - proceed with creation
1360
+ }
1361
+ // Create PR using gh CLI with rich formatted body
1362
+ // Security: Use escaped arguments and heredoc to prevent shell injection
849
1363
  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' });
1364
+ // Generate story file URL
1365
+ const storyFileUrl = getStoryFileURL(storyPath, branchName, workingDir);
1366
+ // Format rich PR description
1367
+ let prBody = formatPRDescription(story, storyFileUrl);
1368
+ // Truncate if needed to respect GitHub's 65K limit
1369
+ prBody = truncatePRBody(prBody);
1370
+ // Determine if draft PR should be created
1371
+ // Options parameter takes precedence, then config, default is false
1372
+ const config = loadConfig(workingDir);
1373
+ const createAsDraft = options?.draft ?? config.github?.createDraftPRs ?? false;
1374
+ const draftFlag = createAsDraft ? ' --draft' : '';
1375
+ // Use heredoc pattern for multi-line body to preserve formatting
1376
+ const ghCommand = `gh pr create --title ${escapeShellArg(prTitle)}${draftFlag} --body "$(cat <<'EOF'
1377
+ ${prBody}
1378
+ EOF
1379
+ )"`;
1380
+ const prOutput = execSync(ghCommand, { cwd: workingDir, encoding: 'utf-8' });
868
1381
  const prUrl = prOutput.trim();
869
- updateStoryField(story, 'pr_url', prUrl);
870
- changesMade.push(`Created PR: ${prUrl}`);
1382
+ await updateStoryField(story, 'pr_url', prUrl);
1383
+ const prTypeLabel = createAsDraft ? 'draft PR' : 'PR';
1384
+ changesMade.push(`Created ${prTypeLabel}: ${prUrl}`);
871
1385
  }
872
1386
  catch (error) {
873
1387
  const sanitizedError = sanitizeErrorMessage(error instanceof Error ? error.message : String(error), workingDir);
874
- changesMade.push(`PR creation failed: ${sanitizedError}`);
1388
+ // Provide actionable error messages for common issues
1389
+ let errorMessage = `PR creation failed: ${sanitizedError}`;
1390
+ if (sanitizedError.includes('authentication') || sanitizedError.includes('auth') || sanitizedError.includes('credentials')) {
1391
+ errorMessage = `GitHub authentication failed. Please authenticate using one of:
1392
+ 1. Set GITHUB_TOKEN env var: export GITHUB_TOKEN=ghp_xxx
1393
+ 2. Run: gh auth login
1394
+ 3. Check: gh auth status`;
1395
+ }
1396
+ changesMade.push(errorMessage);
875
1397
  }
876
1398
  // Update status to done
877
- story = updateStoryStatus(story, 'done');
1399
+ story = await updateStoryStatus(story, 'done');
878
1400
  changesMade.push('Updated status to done');
879
1401
  return {
880
1402
  success: true,