@hyperdrive.bot/bmad-workflow 1.0.17 → 1.0.19

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 (110) hide show
  1. package/dist/commands/config/show.js +8 -2
  2. package/dist/commands/decompose.js +26 -5
  3. package/dist/commands/epics/create.d.ts +1 -0
  4. package/dist/commands/mcp/add.d.ts +16 -0
  5. package/dist/commands/mcp/add.js +77 -0
  6. package/dist/commands/mcp/credential/get.d.ts +14 -0
  7. package/dist/commands/mcp/credential/get.js +35 -0
  8. package/dist/commands/mcp/credential/list.d.ts +17 -0
  9. package/dist/commands/mcp/credential/list.js +67 -0
  10. package/dist/commands/mcp/credential/remove.d.ts +18 -0
  11. package/dist/commands/mcp/credential/remove.js +84 -0
  12. package/dist/commands/mcp/credential/set.d.ts +16 -0
  13. package/dist/commands/mcp/credential/set.js +41 -0
  14. package/dist/commands/mcp/credential/validate.d.ts +12 -0
  15. package/dist/commands/mcp/credential/validate.js +150 -0
  16. package/dist/commands/mcp/list.d.ts +17 -0
  17. package/dist/commands/mcp/list.js +80 -0
  18. package/dist/commands/mcp/logs.d.ts +15 -0
  19. package/dist/commands/mcp/logs.js +64 -0
  20. package/dist/commands/mcp/preset.d.ts +15 -0
  21. package/dist/commands/mcp/preset.js +84 -0
  22. package/dist/commands/mcp/remove.d.ts +14 -0
  23. package/dist/commands/mcp/remove.js +36 -0
  24. package/dist/commands/mcp/start.d.ts +12 -0
  25. package/dist/commands/mcp/start.js +80 -0
  26. package/dist/commands/mcp/status.d.ts +30 -0
  27. package/dist/commands/mcp/status.js +180 -0
  28. package/dist/commands/mcp/stop.d.ts +12 -0
  29. package/dist/commands/mcp/stop.js +47 -0
  30. package/dist/commands/stories/create.d.ts +1 -0
  31. package/dist/commands/stories/develop.d.ts +1 -0
  32. package/dist/commands/stories/qa.js +34 -75
  33. package/dist/commands/stories/review.d.ts +124 -0
  34. package/dist/commands/stories/review.js +516 -0
  35. package/dist/commands/workflow.d.ts +89 -0
  36. package/dist/commands/workflow.js +487 -14
  37. package/dist/mcp/types.d.ts +99 -0
  38. package/dist/mcp/types.js +7 -0
  39. package/dist/mcp/utils/docker-utils.d.ts +56 -0
  40. package/dist/mcp/utils/docker-utils.js +108 -0
  41. package/dist/mcp/utils/template-loader.d.ts +21 -0
  42. package/dist/mcp/utils/template-loader.js +60 -0
  43. package/dist/models/agent-options.d.ts +10 -1
  44. package/dist/models/index.d.ts +1 -0
  45. package/dist/models/index.js +1 -0
  46. package/dist/models/workflow-callbacks.d.ts +251 -0
  47. package/dist/models/workflow-callbacks.js +10 -0
  48. package/dist/models/workflow-config.d.ts +77 -0
  49. package/dist/models/workflow-result.d.ts +7 -0
  50. package/dist/services/WorkflowReporter.d.ts +165 -0
  51. package/dist/services/WorkflowReporter.js +691 -0
  52. package/dist/services/agents/claude-agent-runner.js +25 -4
  53. package/dist/services/file-system/path-resolver.d.ts +10 -0
  54. package/dist/services/file-system/path-resolver.js +12 -0
  55. package/dist/services/mcp/mcp-config-manager.d.ts +54 -0
  56. package/dist/services/mcp/mcp-config-manager.js +146 -0
  57. package/dist/services/mcp/mcp-context-injector.d.ts +92 -0
  58. package/dist/services/mcp/mcp-context-injector.js +168 -0
  59. package/dist/services/mcp/mcp-credential-manager.d.ts +48 -0
  60. package/dist/services/mcp/mcp-credential-manager.js +124 -0
  61. package/dist/services/mcp/mcp-health-checker.d.ts +56 -0
  62. package/dist/services/mcp/mcp-health-checker.js +162 -0
  63. package/dist/services/mcp/types/health-types.d.ts +31 -0
  64. package/dist/services/mcp/types/health-types.js +7 -0
  65. package/dist/services/orchestration/dependency-graph-executor.js +1 -1
  66. package/dist/services/orchestration/task-decomposition-service.d.ts +2 -1
  67. package/dist/services/orchestration/task-decomposition-service.js +90 -36
  68. package/dist/services/orchestration/workflow-orchestrator.d.ts +87 -3
  69. package/dist/services/orchestration/workflow-orchestrator.js +1169 -289
  70. package/dist/services/review/ai-review-scanner.d.ts +66 -0
  71. package/dist/services/review/ai-review-scanner.js +142 -0
  72. package/dist/services/review/coderabbit-scanner.d.ts +25 -0
  73. package/dist/services/review/coderabbit-scanner.js +31 -0
  74. package/dist/services/review/index.d.ts +20 -0
  75. package/dist/services/review/index.js +15 -0
  76. package/dist/services/review/lint-scanner.d.ts +46 -0
  77. package/dist/services/review/lint-scanner.js +172 -0
  78. package/dist/services/review/review-config.d.ts +62 -0
  79. package/dist/services/review/review-config.js +91 -0
  80. package/dist/services/review/review-phase-executor.d.ts +69 -0
  81. package/dist/services/review/review-phase-executor.js +152 -0
  82. package/dist/services/review/review-queue.d.ts +98 -0
  83. package/dist/services/review/review-queue.js +174 -0
  84. package/dist/services/review/review-reporter.d.ts +94 -0
  85. package/dist/services/review/review-reporter.js +386 -0
  86. package/dist/services/review/scanner-factory.d.ts +42 -0
  87. package/dist/services/review/scanner-factory.js +60 -0
  88. package/dist/services/review/self-heal-loop.d.ts +58 -0
  89. package/dist/services/review/self-heal-loop.js +132 -0
  90. package/dist/services/review/severity-classifier.d.ts +17 -0
  91. package/dist/services/review/severity-classifier.js +314 -0
  92. package/dist/services/review/tech-debt-tracker.d.ts +52 -0
  93. package/dist/services/review/tech-debt-tracker.js +245 -0
  94. package/dist/services/review/types.d.ts +93 -0
  95. package/dist/services/review/types.js +23 -0
  96. package/dist/services/scaffolding/workflow-session-scaffolder.d.ts +182 -0
  97. package/dist/services/scaffolding/workflow-session-scaffolder.js +236 -0
  98. package/dist/services/validation/config-validator.d.ts +84 -0
  99. package/dist/services/validation/config-validator.js +78 -0
  100. package/dist/utils/colors.d.ts +10 -10
  101. package/dist/utils/colors.js +15 -15
  102. package/dist/utils/credential-utils.d.ts +14 -0
  103. package/dist/utils/credential-utils.js +19 -0
  104. package/dist/utils/duration.d.ts +41 -0
  105. package/dist/utils/duration.js +89 -0
  106. package/dist/utils/listr2-helpers.d.ts +216 -0
  107. package/dist/utils/listr2-helpers.js +334 -0
  108. package/dist/utils/shared-flags.d.ts +1 -0
  109. package/dist/utils/shared-flags.js +11 -2
  110. package/package.json +6 -3
@@ -50,6 +50,7 @@ import { PrdFixer } from '../parsers/prd-fixer.js';
50
50
  import { FileScaffolder } from '../scaffolding/file-scaffolder.js';
51
51
  import { BatchProcessor } from './batch-processor.js';
52
52
  import { StoryQueue } from './story-queue.js';
53
+ import { ReviewQueue } from '../review/review-queue.js';
53
54
  /**
54
55
  * WorkflowOrchestrator service for coordinating multi-phase workflows
55
56
  *
@@ -60,14 +61,17 @@ import { StoryQueue } from './story-queue.js';
60
61
  export class WorkflowOrchestrator {
61
62
  agentRunner;
62
63
  batchProcessor;
64
+ callbacks;
63
65
  epicParser;
64
66
  fileManager;
65
67
  fileScaffolder;
66
68
  inputDetector;
67
69
  logger;
70
+ mcpContextInjector;
68
71
  pathResolver;
69
72
  prdFixer;
70
73
  prdParser;
74
+ reviewPhaseExecutor;
71
75
  storyTypeDetector;
72
76
  workflowLogger;
73
77
  /**
@@ -82,10 +86,13 @@ export class WorkflowOrchestrator {
82
86
  this.agentRunner = config.agentRunner;
83
87
  this.batchProcessor = config.batchProcessor;
84
88
  this.fileManager = config.fileManager;
89
+ this.mcpContextInjector = config.mcpContextInjector;
85
90
  this.pathResolver = config.pathResolver;
91
+ this.reviewPhaseExecutor = config.reviewPhaseExecutor;
86
92
  this.storyTypeDetector = config.storyTypeDetector;
87
93
  this.logger = config.logger;
88
94
  this.workflowLogger = config.workflowLogger;
95
+ this.callbacks = config.callbacks;
89
96
  this.fileScaffolder = new FileScaffolder(config.logger);
90
97
  this.prdFixer = new PrdFixer(config.agentRunner, config.fileManager, config.logger);
91
98
  this.logger.debug('WorkflowOrchestrator initialized');
@@ -111,9 +118,48 @@ export class WorkflowOrchestrator {
111
118
  if (this.shouldAbortAfterEpicFailure(epicPhase)) {
112
119
  return this.buildFailureResult(startTime, epicPhase);
113
120
  }
114
- const { devPhase, storyPhase } = await this.executeStoryAndDevPhases(config, detection, epicPhase, phaseFlags, startTime);
121
+ const { devPhase, reviewPhase, storyPhase } = await this.executeStoryAndDevPhases(config, detection, epicPhase, phaseFlags, startTime);
115
122
  const qaPhase = await this.executeQaPhaseIfNeeded(config, detection, devPhase, phaseFlags.shouldExecuteQaPhase);
116
- return this.buildSuccessResult(startTime, epicPhase, storyPhase, devPhase, qaPhase);
123
+ return this.buildSuccessResult(startTime, epicPhase, storyPhase, devPhase, qaPhase, reviewPhase);
124
+ }
125
+ /**
126
+ * Get MCP prompt prefix for a given phase
127
+ *
128
+ * Returns MCP tool discovery instructions to prepend before agent prompts.
129
+ * Returns empty string when MCP is disabled, injector is unavailable,
130
+ * phase is filtered out, or injector returns empty/errors.
131
+ *
132
+ * @param phase - Workflow phase (epic, story, dev, review, qa)
133
+ * @param agentType - Agent type being spawned
134
+ * @param config - Workflow configuration
135
+ * @returns Instructions string to prepend, or empty string
136
+ * @private
137
+ */
138
+ async getMcpPromptPrefix(phase, agentType, config) {
139
+ // Skip if MCP is not enabled
140
+ if (!config.mcp) {
141
+ return '';
142
+ }
143
+ // Skip if injector is not available
144
+ if (!this.mcpContextInjector) {
145
+ return '';
146
+ }
147
+ // Skip if phase is filtered out by mcpPhases
148
+ if (config.mcpPhases && config.mcpPhases.length > 0 && !config.mcpPhases.includes(phase)) {
149
+ return '';
150
+ }
151
+ try {
152
+ const mcpContext = await this.mcpContextInjector.getContextForAgent(agentType, phase);
153
+ if (!mcpContext.instructions) {
154
+ this.logger.warn({ phase }, 'MCP enabled but injector returned empty instructions');
155
+ return '';
156
+ }
157
+ return mcpContext.instructions;
158
+ }
159
+ catch (error) {
160
+ this.logger.warn({ error: error.message, phase }, 'MCP context injection failed, continuing without MCP tools');
161
+ return '';
162
+ }
117
163
  }
118
164
  /**
119
165
  * Build prompt for epic creation
@@ -197,13 +243,14 @@ Write output to: ${outputPath}`;
197
243
  * @returns WorkflowResult
198
244
  * @private
199
245
  */
200
- buildSuccessResult(startTime, epicPhase, storyPhase, devPhase, qaPhase) {
246
+ buildSuccessResult(startTime, epicPhase, storyPhase, devPhase, qaPhase, reviewPhase) {
201
247
  const totalDuration = Date.now() - startTime;
202
248
  const totalFilesProcessed = (epicPhase?.success ?? 0) + (storyPhase?.success ?? 0) + (devPhase?.success ?? 0) + (qaPhase?.success ?? 0);
203
249
  const totalFailures = (epicPhase?.failures.length ?? 0) +
204
250
  (storyPhase?.failures.length ?? 0) +
205
251
  (devPhase?.failures.length ?? 0) +
206
- (qaPhase?.failures.length ?? 0);
252
+ (qaPhase?.failures.length ?? 0) +
253
+ (reviewPhase?.failures.length ?? 0);
207
254
  const overallSuccess = totalFailures === 0;
208
255
  this.logger.info({
209
256
  overallSuccess,
@@ -216,12 +263,90 @@ Write output to: ${outputPath}`;
216
263
  epicPhase,
217
264
  overallSuccess,
218
265
  qaPhase,
266
+ reviewPhase,
219
267
  storyPhase,
220
268
  totalDuration,
221
269
  totalFailures,
222
270
  totalFilesProcessed,
223
271
  };
224
272
  }
273
+ /**
274
+ * Invoke a callback safely, catching and logging any errors
275
+ *
276
+ * All callback invocations are wrapped in try-catch to prevent
277
+ * callback errors from interrupting workflow execution.
278
+ *
279
+ * @param callbackName - Name of the callback for logging
280
+ * @param callback - The callback function to invoke
281
+ * @param context - The context to pass to the callback
282
+ * @private
283
+ */
284
+ invokeCallback(callbackName, callback, context) {
285
+ if (!callback)
286
+ return;
287
+ try {
288
+ callback(context);
289
+ }
290
+ catch (error) {
291
+ this.logger.warn({
292
+ callbackName,
293
+ error: error.message,
294
+ }, 'Callback error (ignored to prevent workflow interruption)');
295
+ }
296
+ }
297
+ /**
298
+ * Invoke the onSpawnOutput callback safely
299
+ *
300
+ * @param callback - The callback function to invoke
301
+ * @param context - The spawn context
302
+ * @param output - The output text
303
+ * @private
304
+ */
305
+ invokeSpawnOutputCallback(callback, context, output) {
306
+ if (!callback)
307
+ return;
308
+ try {
309
+ callback(context, output);
310
+ }
311
+ catch (error) {
312
+ this.logger.warn({
313
+ callbackName: 'onSpawnOutput',
314
+ error: error.message,
315
+ }, 'Callback error (ignored to prevent workflow interruption)');
316
+ }
317
+ }
318
+ /**
319
+ * Invoke the onError callback safely
320
+ *
321
+ * @param error - The error that occurred
322
+ * @param options - Additional context for the error
323
+ * @private
324
+ */
325
+ invokeErrorCallback(error, options = {}) {
326
+ const callback = this.callbacks?.onError;
327
+ if (!callback)
328
+ return;
329
+ const context = {
330
+ error,
331
+ itemId: options.itemId,
332
+ message: error.message,
333
+ metadata: options.metadata,
334
+ phaseName: options.phaseName,
335
+ recoverable: options.recoverable ?? true,
336
+ spawnId: options.spawnId,
337
+ stack: error.stack,
338
+ timestamp: Date.now(),
339
+ };
340
+ try {
341
+ callback(context);
342
+ }
343
+ catch (callbackError) {
344
+ this.logger.warn({
345
+ callbackName: 'onError',
346
+ error: callbackError.message,
347
+ }, 'Callback error (ignored to prevent workflow interruption)');
348
+ }
349
+ }
225
350
  /**
226
351
  * Check which files already exist in a directory
227
352
  *
@@ -297,7 +422,7 @@ Write output to: ${outputPath}`;
297
422
  const content = await this.fileManager.readFile(filePath);
298
423
  const isScaffolded = content.includes('_[AI Agent will populate');
299
424
  if (isScaffolded) {
300
- this.logger.warn({
425
+ this.logger.info({
301
426
  fileName,
302
427
  filePath,
303
428
  }, 'Epic file exists but is only scaffolded (will be re-populated)');
@@ -373,11 +498,13 @@ Write output to: ${outputPath}`;
373
498
  const shouldExecuteEpicPhase = detection.type === 'prd' && !config.skipEpics;
374
499
  const shouldExecuteStoryPhase = !config.skipStories && detection.type !== 'story-pattern';
375
500
  const shouldExecuteDevPhase = !config.skipDev;
501
+ const shouldExecuteReviewPhase = config.review === true && shouldExecuteDevPhase;
376
502
  const shouldExecuteQaPhase = config.qa === true && shouldExecuteDevPhase;
377
503
  return {
378
504
  shouldExecuteDevPhase,
379
505
  shouldExecuteEpicPhase,
380
506
  shouldExecuteQaPhase,
507
+ shouldExecuteReviewPhase,
381
508
  shouldExecuteStoryPhase,
382
509
  };
383
510
  }
@@ -414,10 +541,11 @@ Write output to: ${outputPath}`;
414
541
  * @returns Promise resolving to object with success count and failure array
415
542
  * @private
416
543
  */
417
- async devWorker(workerId, queue, config) {
544
+ async devWorker(workerId, queue, config, reviewQueue) {
418
545
  const workerLogger = this.logger.child({ workerId });
419
546
  let successCount = 0;
420
547
  const failures = [];
548
+ let devPhaseStarted = false;
421
549
  workerLogger.info('Worker started');
422
550
  try {
423
551
  const storyDir = await this.pathResolver.getStoryDir();
@@ -430,6 +558,16 @@ Write output to: ${outputPath}`;
430
558
  workerLogger.info('Queue closed and empty, worker terminating');
431
559
  break;
432
560
  }
561
+ // Fire onPhaseStart on first dequeue (after story phase is done creating)
562
+ if (!devPhaseStarted) {
563
+ devPhaseStarted = true;
564
+ this.invokeCallback('onPhaseStart', this.callbacks?.onPhaseStart, {
565
+ itemCount: 0,
566
+ metadata: { mode: 'pipeline', workerId },
567
+ phaseName: 'dev',
568
+ startTime: Date.now(),
569
+ });
570
+ }
433
571
  workerLogger.info({
434
572
  storyNumber: story.fullNumber,
435
573
  storyTitle: story.title,
@@ -484,7 +622,9 @@ Write output to: ${outputPath}`;
484
622
  storyType: detection.type,
485
623
  }, 'Story type detected, auto-including documentation references');
486
624
  // Build prompt with auto-detected references
487
- let prompt = `@.bmad-core/agents/dev.md\n\n`;
625
+ const mcpPrefix = await this.getMcpPromptPrefix('dev', 'dev', config);
626
+ let prompt = mcpPrefix ? `${mcpPrefix}\n\n` : '';
627
+ prompt += `@.bmad-core/agents/dev.md\n\n`;
488
628
  // Add working directory instruction if specified
489
629
  if (config.cwd) {
490
630
  prompt += `Working directory: ${config.cwd}\n\n`;
@@ -500,6 +640,20 @@ Write output to: ${outputPath}`;
500
640
  prompt += '\n';
501
641
  }
502
642
  prompt += '*yolo mode*\n';
643
+ // Generate unique spawn ID
644
+ const spawnId = `dev-${story.fullNumber}-${storyStartTime}`;
645
+ // Invoke onSpawnStart callback
646
+ this.invokeCallback('onSpawnStart', this.callbacks?.onSpawnStart, {
647
+ agentType: 'dev',
648
+ itemId: story.fullNumber,
649
+ itemTitle: story.title,
650
+ outputPath: storyFilePath,
651
+ phaseName: 'dev',
652
+ prompt,
653
+ spawnId,
654
+ startTime: storyStartTime,
655
+ workerId,
656
+ });
503
657
  // Execute dev agent with retry on timeout/killed
504
658
  const result = await runAgentWithRetry(this.agentRunner, prompt, {
505
659
  agentType: 'dev',
@@ -510,11 +664,33 @@ Write output to: ${outputPath}`;
510
664
  logger: this.logger,
511
665
  maxRetries: config.maxRetries,
512
666
  });
667
+ const spawnEndTime = Date.now();
668
+ const spawnDuration = spawnEndTime - storyStartTime;
513
669
  if (result.success) {
670
+ // Invoke onSpawnComplete callback for success
671
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
672
+ agentType: 'dev',
673
+ duration: spawnDuration,
674
+ endTime: spawnEndTime,
675
+ itemId: story.fullNumber,
676
+ itemTitle: story.title,
677
+ output: result.output,
678
+ outputPath: storyFilePath,
679
+ phaseName: 'dev',
680
+ spawnId,
681
+ startTime: storyStartTime,
682
+ success: true,
683
+ workerId,
684
+ });
514
685
  // Update story status to Done
515
686
  await this.updateStoryStatus(storyFilePath, 'Done');
516
687
  // Move to QA folder
517
688
  await this.fileManager.moveFile(storyFilePath, qaFilePath);
689
+ // Enqueue to review queue if review is enabled (pipelined review path)
690
+ if (reviewQueue) {
691
+ reviewQueue.enqueue(story);
692
+ workerLogger.info({ storyNumber: story.fullNumber }, 'Story enqueued for review');
693
+ }
518
694
  successCount++;
519
695
  // Log story completed in pipeline mode
520
696
  const storyDuration = Date.now() - storyStartTime;
@@ -530,6 +706,28 @@ Write output to: ${outputPath}`;
530
706
  workerLogger.info({ storyNumber: story.fullNumber }, 'Story development completed successfully');
531
707
  }
532
708
  else {
709
+ // Invoke onSpawnComplete callback for failure
710
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
711
+ agentType: 'dev',
712
+ duration: spawnDuration,
713
+ endTime: spawnEndTime,
714
+ error: result.errors,
715
+ itemId: story.fullNumber,
716
+ itemTitle: story.title,
717
+ outputPath: storyFilePath,
718
+ phaseName: 'dev',
719
+ spawnId,
720
+ startTime: storyStartTime,
721
+ success: false,
722
+ workerId,
723
+ });
724
+ // Invoke onError callback
725
+ this.invokeErrorCallback(new Error(result.errors), {
726
+ itemId: story.fullNumber,
727
+ phaseName: 'dev',
728
+ recoverable: true,
729
+ spawnId,
730
+ });
533
731
  // Log story failed in pipeline mode
534
732
  const storyDuration = Date.now() - storyStartTime;
535
733
  if (this.workflowLogger) {
@@ -552,6 +750,12 @@ Write output to: ${outputPath}`;
552
750
  }
553
751
  }
554
752
  catch (error) {
753
+ // Invoke onError callback for unexpected errors
754
+ this.invokeErrorCallback(error, {
755
+ itemId: story.fullNumber,
756
+ phaseName: 'dev',
757
+ recoverable: true,
758
+ });
555
759
  // Catch errors for individual stories to prevent worker crash
556
760
  workerLogger.error({
557
761
  error: error.message,
@@ -636,10 +840,18 @@ Write output to: ${outputPath}`;
636
840
  const startTime = Date.now();
637
841
  const failures = [];
638
842
  let successCount = 0;
843
+ const itemCount = stories.length;
639
844
  this.logger.info({
640
845
  interval: config.storyInterval,
641
846
  storyCount: stories.length,
642
847
  }, 'Starting development phase');
848
+ // Invoke onPhaseStart callback
849
+ this.invokeCallback('onPhaseStart', this.callbacks?.onPhaseStart, {
850
+ itemCount,
851
+ metadata: { interval: config.storyInterval },
852
+ phaseName: 'dev',
853
+ startTime,
854
+ });
643
855
  try {
644
856
  const storyDir = await this.pathResolver.getStoryDir();
645
857
  const qaStoryDir = await this.pathResolver.getQaStoryDir();
@@ -685,7 +897,9 @@ Write output to: ${outputPath}`;
685
897
  storyType: detection.type,
686
898
  }, 'Story type detected, auto-including documentation references');
687
899
  // Build prompt with auto-detected references
688
- let prompt = `@.bmad-core/agents/dev.md\n\n`;
900
+ const mcpPrefix = await this.getMcpPromptPrefix('dev', 'dev', config);
901
+ let prompt = mcpPrefix ? `${mcpPrefix}\n\n` : '';
902
+ prompt += `@.bmad-core/agents/dev.md\n\n`;
689
903
  // Add working directory instruction if specified
690
904
  if (config.cwd) {
691
905
  prompt += `Working directory: ${config.cwd}\n\n`;
@@ -701,6 +915,20 @@ Write output to: ${outputPath}`;
701
915
  prompt += '\n';
702
916
  }
703
917
  prompt += '*yolo mode*\n';
918
+ // Generate unique spawn ID and timestamp
919
+ const spawnStartTime = Date.now();
920
+ const spawnId = `dev-${story.fullNumber}-${spawnStartTime}`;
921
+ // Invoke onSpawnStart callback
922
+ this.invokeCallback('onSpawnStart', this.callbacks?.onSpawnStart, {
923
+ agentType: 'dev',
924
+ itemId: story.fullNumber,
925
+ itemTitle: story.title,
926
+ outputPath: storyFilePath,
927
+ phaseName: 'dev',
928
+ prompt,
929
+ spawnId,
930
+ startTime: spawnStartTime,
931
+ });
704
932
  const result = await runAgentWithRetry(this.agentRunner, prompt, {
705
933
  agentType: 'dev',
706
934
  references: config.references,
@@ -710,7 +938,23 @@ Write output to: ${outputPath}`;
710
938
  logger: this.logger,
711
939
  maxRetries: config.maxRetries,
712
940
  });
941
+ const spawnEndTime = Date.now();
942
+ const spawnDuration = spawnEndTime - spawnStartTime;
713
943
  if (result.success) {
944
+ // Invoke onSpawnComplete callback for success
945
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
946
+ agentType: 'dev',
947
+ duration: spawnDuration,
948
+ endTime: spawnEndTime,
949
+ itemId: story.fullNumber,
950
+ itemTitle: story.title,
951
+ output: result.output,
952
+ outputPath: storyFilePath,
953
+ phaseName: 'dev',
954
+ spawnId,
955
+ startTime: spawnStartTime,
956
+ success: true,
957
+ });
714
958
  // Update story status to Done
715
959
  await this.updateStoryStatus(storyFilePath, 'Done');
716
960
  // Move to QA folder (qaFilePath already defined above with correct filename)
@@ -719,6 +963,27 @@ Write output to: ${outputPath}`;
719
963
  this.logger.info({ storyNumber: story.fullNumber }, 'Story development completed successfully');
720
964
  }
721
965
  else {
966
+ // Invoke onSpawnComplete callback for failure
967
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
968
+ agentType: 'dev',
969
+ duration: spawnDuration,
970
+ endTime: spawnEndTime,
971
+ error: result.errors,
972
+ itemId: story.fullNumber,
973
+ itemTitle: story.title,
974
+ outputPath: storyFilePath,
975
+ phaseName: 'dev',
976
+ spawnId,
977
+ startTime: spawnStartTime,
978
+ success: false,
979
+ });
980
+ // Invoke onError callback for spawn failure
981
+ this.invokeErrorCallback(new Error(result.errors), {
982
+ itemId: story.fullNumber,
983
+ phaseName: 'dev',
984
+ recoverable: true,
985
+ spawnId,
986
+ });
722
987
  this.logger.error({
723
988
  error: result.errors,
724
989
  storyNumber: story.fullNumber,
@@ -735,11 +1000,22 @@ Write output to: ${outputPath}`;
735
1000
  }
736
1001
  }
737
1002
  const duration = Date.now() - startTime;
1003
+ const endTime = Date.now();
738
1004
  this.logger.info({
739
1005
  duration,
740
1006
  failures: failures.length,
741
1007
  success: successCount,
742
1008
  }, 'Development phase completed');
1009
+ // Invoke onPhaseComplete callback
1010
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1011
+ duration,
1012
+ endTime,
1013
+ failureCount: failures.length,
1014
+ itemCount,
1015
+ phaseName: 'dev',
1016
+ startTime,
1017
+ successCount,
1018
+ });
743
1019
  return {
744
1020
  duration,
745
1021
  failures,
@@ -749,9 +1025,28 @@ Write output to: ${outputPath}`;
749
1025
  };
750
1026
  }
751
1027
  catch (error) {
1028
+ const duration = Date.now() - startTime;
1029
+ const endTime = Date.now();
752
1030
  this.logger.error({ error: error.message }, 'Development phase failed');
1031
+ // Invoke onError callback
1032
+ this.invokeErrorCallback(error, {
1033
+ itemId: 'dev-phase',
1034
+ phaseName: 'dev',
1035
+ recoverable: false,
1036
+ });
1037
+ // Invoke onPhaseComplete callback even on error
1038
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1039
+ duration,
1040
+ endTime,
1041
+ failureCount: failures.length + 1,
1042
+ itemCount,
1043
+ metadata: { error: error.message },
1044
+ phaseName: 'dev',
1045
+ startTime,
1046
+ successCount,
1047
+ });
753
1048
  return {
754
- duration: Date.now() - startTime,
1049
+ duration,
755
1050
  failures: [
756
1051
  ...failures,
757
1052
  {
@@ -780,11 +1075,19 @@ Write output to: ${outputPath}`;
780
1075
  const startTime = Date.now();
781
1076
  const failures = [];
782
1077
  let successCount = 0;
1078
+ let itemCount = 0;
783
1079
  this.logger.info({
784
1080
  interval: config.prdInterval,
785
1081
  parallel: config.parallel,
786
1082
  prdFile: prdFilePath,
787
1083
  }, 'Starting epic creation phase');
1084
+ // Invoke onPhaseStart callback (itemCount will be updated once epics are parsed)
1085
+ this.invokeCallback('onPhaseStart', this.callbacks?.onPhaseStart, {
1086
+ itemCount: 0,
1087
+ metadata: { parallel: config.parallel, prdFilePath },
1088
+ phaseName: 'epic',
1089
+ startTime,
1090
+ });
788
1091
  try {
789
1092
  // Read PRD file
790
1093
  let prdContent = await this.fileManager.readFile(prdFilePath);
@@ -819,8 +1122,19 @@ Write output to: ${outputPath}`;
819
1122
  }
820
1123
  }
821
1124
  this.logger.info({ epicCount: epics.length }, 'Epics extracted from PRD');
1125
+ itemCount = epics.length;
822
1126
  if (epics.length === 0) {
823
1127
  this.logger.warn('No epics found in PRD file');
1128
+ // Invoke onPhaseComplete for empty phase
1129
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1130
+ duration: Date.now() - startTime,
1131
+ endTime: Date.now(),
1132
+ failureCount: 0,
1133
+ itemCount: 0,
1134
+ phaseName: 'epic',
1135
+ startTime,
1136
+ successCount: 0,
1137
+ });
824
1138
  return {
825
1139
  duration: Date.now() - startTime,
826
1140
  failures: [],
@@ -837,8 +1151,18 @@ Write output to: ${outputPath}`;
837
1151
  const epicsToCreate = epics.filter((epic) => !properlyPopulatedEpics.includes(this.generateEpicFileName(prefix, epic.number)));
838
1152
  if (epicsToCreate.length === 0) {
839
1153
  this.logger.info('All epics already exist and are properly populated, skipping creation');
1154
+ const duration = Date.now() - startTime;
1155
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1156
+ duration,
1157
+ endTime: Date.now(),
1158
+ failureCount: 0,
1159
+ itemCount: epics.length,
1160
+ phaseName: 'epic',
1161
+ startTime,
1162
+ successCount: epics.length,
1163
+ });
840
1164
  return {
841
- duration: Date.now() - startTime,
1165
+ duration,
842
1166
  failures: [],
843
1167
  phaseName: 'epic',
844
1168
  skipped: false,
@@ -851,99 +1175,142 @@ Write output to: ${outputPath}`;
851
1175
  }, 'Creating/re-populating epic files');
852
1176
  // Create epic files using BatchProcessor and ClaudeAgentRunner
853
1177
  const results = await this.batchProcessor.processBatch(epicsToCreate, async (epic) => {
854
- // Generate epic file path with prefix
855
- const epicFileName = this.generateEpicFileName(prefix, epic.number);
856
- const epicFilePath = `${epicDir}/${epicFileName}`;
857
- // Check if file already exists and is properly populated
858
- const fileExists = await this.fileManager.fileExists(epicFilePath);
859
- if (fileExists) {
860
- // Check if the file is properly populated (has actual story entries, not placeholder text)
861
- const existingContent = await this.fileManager.readFile(epicFilePath);
862
- const isScaffolded = existingContent.includes('_[AI Agent will populate');
863
- if (!isScaffolded) {
864
- // File is properly populated, skip recreation
1178
+ const spawnStartTime = Date.now();
1179
+ const spawnId = `epic-${epic.number}`;
1180
+ // Fire onSpawnStart so the reporter shows which epic is being generated
1181
+ this.invokeCallback('onSpawnStart', this.callbacks?.onSpawnStart, {
1182
+ agentType: 'architect',
1183
+ itemId: `epic-${epic.number}`,
1184
+ itemTitle: epic.title,
1185
+ phaseName: 'epic',
1186
+ spawnId,
1187
+ startTime: spawnStartTime,
1188
+ });
1189
+ try {
1190
+ // Generate epic file path with prefix
1191
+ const epicFileName = this.generateEpicFileName(prefix, epic.number);
1192
+ const epicFilePath = `${epicDir}/${epicFileName}`;
1193
+ // Check if file already exists and is properly populated
1194
+ const fileExists = await this.fileManager.fileExists(epicFilePath);
1195
+ if (fileExists) {
1196
+ // Check if the file is properly populated (has actual story entries, not placeholder text)
1197
+ const existingContent = await this.fileManager.readFile(epicFilePath);
1198
+ const isScaffolded = existingContent.includes('_[AI Agent will populate');
1199
+ if (!isScaffolded) {
1200
+ // File is properly populated, skip recreation
1201
+ this.logger.info({
1202
+ epicNumber: epic.number,
1203
+ epicTitle: epic.title,
1204
+ filePath: epicFilePath,
1205
+ }, 'Epic file already exists and is properly populated, skipping creation');
1206
+ return epicFilePath;
1207
+ }
1208
+ // File exists but is only scaffolded (incomplete) - will re-populate it
865
1209
  this.logger.info({
866
1210
  epicNumber: epic.number,
867
1211
  epicTitle: epic.title,
868
1212
  filePath: epicFilePath,
869
- }, 'Epic file already exists and is properly populated, skipping creation');
870
- return epicFilePath;
1213
+ }, 'Epic file exists but is only scaffolded (incomplete), re-populating with Claude agent');
871
1214
  }
872
- // File exists but is only scaffolded (incomplete) - will re-populate it
873
- this.logger.warn({
874
- epicNumber: epic.number,
875
- epicTitle: epic.title,
876
- filePath: epicFilePath,
877
- }, 'Epic file exists but is only scaffolded (incomplete), re-populating with Claude agent');
878
- }
879
- // Step 1: Create scaffolded file with structured sections and populated metadata (if not exists)
880
- const scaffoldedContent = this.fileScaffolder.scaffoldEpic({
881
- epicNumber: epic.number,
882
- epicTitle: epic.title,
883
- prefix,
884
- });
885
- if (fileExists) {
886
- this.logger.info({
887
- epicNumber: epic.number,
888
- epicTitle: epic.title,
889
- filePath: epicFilePath,
890
- }, 'Using existing scaffolded epic file');
891
- }
892
- else {
893
- await this.fileManager.writeFile(epicFilePath, scaffoldedContent);
894
- this.logger.info({
895
- epicNumber: epic.number,
896
- epicTitle: epic.title,
897
- filePath: epicFilePath,
898
- }, 'Epic scaffolded file created');
899
- }
900
- // Step 2: Build Claude prompt to populate the scaffolded file
901
- const prompt = this.buildEpicPrompt(epic, {
902
- cwd: config.cwd,
903
- outputPath: epicFilePath,
904
- prdPath: prdFilePath,
905
- prefix,
906
- references: config.references,
907
- });
908
- // Log prompt if verbose
909
- if (config.verbose) {
910
- this.logger.info({
1215
+ // Step 1: Create scaffolded file with structured sections and populated metadata (if not exists)
1216
+ const scaffoldedContent = this.fileScaffolder.scaffoldEpic({
911
1217
  epicNumber: epic.number,
912
1218
  epicTitle: epic.title,
1219
+ prefix,
1220
+ });
1221
+ if (fileExists) {
1222
+ this.logger.info({
1223
+ epicNumber: epic.number,
1224
+ epicTitle: epic.title,
1225
+ filePath: epicFilePath,
1226
+ }, 'Using existing scaffolded epic file');
1227
+ }
1228
+ else {
1229
+ await this.fileManager.writeFile(epicFilePath, scaffoldedContent);
1230
+ this.logger.info({
1231
+ epicNumber: epic.number,
1232
+ epicTitle: epic.title,
1233
+ filePath: epicFilePath,
1234
+ }, 'Epic scaffolded file created');
1235
+ }
1236
+ // Step 2: Build Claude prompt to populate the scaffolded file
1237
+ const mcpPrefix = await this.getMcpPromptPrefix('epic', 'architect', config);
1238
+ const basePrompt = this.buildEpicPrompt(epic, {
1239
+ cwd: config.cwd,
913
1240
  outputPath: epicFilePath,
914
- prompt,
915
- }, 'Claude Prompt (Epic)');
916
- }
917
- // Step 3: Run Claude agent to populate content sections
918
- const result = await runAgentWithRetry(this.agentRunner, prompt, {
919
- agentType: 'architect',
920
- references: config.references,
921
- timeout: config.timeout ?? 2_700_000,
922
- }, {
923
- backoffMs: config.retryBackoffMs,
924
- logger: this.logger,
925
- maxRetries: config.maxRetries,
926
- });
927
- // Log output if verbose
928
- if (config.verbose) {
929
- this.logger.info({
930
- duration: result.duration,
931
- epicNumber: epic.number,
932
- errors: result.errors,
933
- output: result.output,
934
- outputLength: result.output.length,
935
- success: result.success,
936
- }, 'Claude Response (Epic)');
937
- }
938
- if (!result.success) {
939
- throw new Error(result.errors);
1241
+ prdPath: prdFilePath,
1242
+ prefix,
1243
+ references: config.references,
1244
+ });
1245
+ const prompt = mcpPrefix ? `${mcpPrefix}\n\n${basePrompt}` : basePrompt;
1246
+ // Log prompt if verbose
1247
+ if (config.verbose) {
1248
+ this.logger.info({
1249
+ epicNumber: epic.number,
1250
+ epicTitle: epic.title,
1251
+ outputPath: epicFilePath,
1252
+ prompt,
1253
+ }, 'Claude Prompt (Epic)');
1254
+ }
1255
+ // Step 3: Run Claude agent to populate content sections
1256
+ const result = await runAgentWithRetry(this.agentRunner, prompt, {
1257
+ agentType: 'architect',
1258
+ references: config.references,
1259
+ timeout: config.timeout ?? 2_700_000,
1260
+ }, {
1261
+ backoffMs: config.retryBackoffMs,
1262
+ logger: this.logger,
1263
+ maxRetries: config.maxRetries,
1264
+ });
1265
+ // Log output if verbose
1266
+ if (config.verbose) {
1267
+ this.logger.info({
1268
+ duration: result.duration,
1269
+ epicNumber: epic.number,
1270
+ errors: result.errors,
1271
+ output: result.output,
1272
+ outputLength: result.output.length,
1273
+ success: result.success,
1274
+ }, 'Claude Response (Epic)');
1275
+ }
1276
+ if (!result.success) {
1277
+ throw new Error(result.errors);
1278
+ }
1279
+ // Step 4: Verify file was updated by Claude
1280
+ const updatedContent = await this.fileManager.readFile(epicFilePath);
1281
+ if (updatedContent === scaffoldedContent) {
1282
+ throw new Error(`Claude did not update the epic file at ${epicFilePath}`);
1283
+ }
1284
+ // Fire onSpawnComplete for success
1285
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
1286
+ agentType: 'architect',
1287
+ duration: Date.now() - spawnStartTime,
1288
+ endTime: Date.now(),
1289
+ itemId: `epic-${epic.number}`,
1290
+ itemTitle: epic.title,
1291
+ phaseName: 'epic',
1292
+ spawnId,
1293
+ startTime: spawnStartTime,
1294
+ success: true,
1295
+ });
1296
+ return epicFilePath;
940
1297
  }
941
- // Step 4: Verify file was updated by Claude
942
- const updatedContent = await this.fileManager.readFile(epicFilePath);
943
- if (updatedContent === scaffoldedContent) {
944
- throw new Error(`Claude did not update the epic file at ${epicFilePath}`);
1298
+ catch (spawnError) {
1299
+ // Fire onSpawnComplete for failure
1300
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
1301
+ agentType: 'architect',
1302
+ duration: Date.now() - spawnStartTime,
1303
+ endTime: Date.now(),
1304
+ error: spawnError.message,
1305
+ itemId: `epic-${epic.number}`,
1306
+ itemTitle: epic.title,
1307
+ phaseName: 'epic',
1308
+ spawnId,
1309
+ startTime: spawnStartTime,
1310
+ success: false,
1311
+ });
1312
+ throw spawnError;
945
1313
  }
946
- return epicFilePath;
947
1314
  }, (info) => {
948
1315
  this.logger.info({
949
1316
  completedItems: info.completedItems,
@@ -967,11 +1334,23 @@ Write output to: ${outputPath}`;
967
1334
  // Add properly populated epics to success count
968
1335
  successCount += properlyPopulatedEpics.length;
969
1336
  const duration = Date.now() - startTime;
1337
+ const endTime = Date.now();
970
1338
  this.logger.info({
971
1339
  duration,
972
1340
  failures: failures.length,
973
1341
  success: successCount,
974
1342
  }, 'Epic creation phase completed');
1343
+ // Invoke onPhaseComplete callback
1344
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1345
+ duration,
1346
+ endTime,
1347
+ failureCount: failures.length,
1348
+ itemCount,
1349
+ metadata: { parallel: config.parallel, prdFilePath },
1350
+ phaseName: 'epic',
1351
+ startTime,
1352
+ successCount,
1353
+ });
975
1354
  return {
976
1355
  duration,
977
1356
  failures,
@@ -981,9 +1360,28 @@ Write output to: ${outputPath}`;
981
1360
  };
982
1361
  }
983
1362
  catch (error) {
1363
+ const duration = Date.now() - startTime;
1364
+ const endTime = Date.now();
984
1365
  this.logger.error({ error: error.message }, 'Epic phase failed');
1366
+ // Invoke onError callback
1367
+ this.invokeErrorCallback(error, {
1368
+ itemId: 'epic-phase',
1369
+ phaseName: 'epic',
1370
+ recoverable: false,
1371
+ });
1372
+ // Invoke onPhaseComplete callback even on error
1373
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1374
+ duration,
1375
+ endTime,
1376
+ failureCount: failures.length + 1,
1377
+ itemCount,
1378
+ metadata: { error: error.message },
1379
+ phaseName: 'epic',
1380
+ startTime,
1381
+ successCount,
1382
+ });
985
1383
  return {
986
- duration: Date.now() - startTime,
1384
+ duration,
987
1385
  failures: [
988
1386
  ...failures,
989
1387
  {
@@ -1050,7 +1448,7 @@ Write output to: ${outputPath}`;
1050
1448
  * @returns PhaseResult with aggregate success/failure counts and duration
1051
1449
  * @private
1052
1450
  */
1053
- async executePipelinedDevPhase(queue, config) {
1451
+ async executePipelinedDevPhase(queue, config, reviewQueue) {
1054
1452
  const startTime = Date.now();
1055
1453
  // PIPELINE MODE: Use single worker for sequential story processing
1056
1454
  // Stories are processed in order, one at a time as they become available
@@ -1058,13 +1456,19 @@ Write output to: ${outputPath}`;
1058
1456
  this.logger.info({
1059
1457
  interval: config.storyInterval,
1060
1458
  mode: 'sequential',
1459
+ reviewEnabled: !!reviewQueue,
1061
1460
  workerCount,
1062
1461
  }, 'Starting pipelined development phase (sequential processing)');
1063
1462
  try {
1064
- // Create single worker for sequential processing
1065
- const workers = Array.from({ length: workerCount }, (_, i) => this.devWorker(i, queue, config));
1463
+ // Create single worker for sequential processing, passing reviewQueue if review is enabled
1464
+ const workers = Array.from({ length: workerCount }, (_, i) => this.devWorker(i, queue, config, reviewQueue));
1066
1465
  // Wait for worker to complete
1067
1466
  const workerResults = await Promise.all(workers);
1467
+ // Close ReviewQueue after all dev workers finish (Task 3: close/drain semantics)
1468
+ if (reviewQueue) {
1469
+ reviewQueue.close();
1470
+ this.logger.info('ReviewQueue closed after all dev workers completed');
1471
+ }
1068
1472
  // Aggregate results from all workers
1069
1473
  let totalSuccess = 0;
1070
1474
  const allFailures = [];
@@ -1079,6 +1483,15 @@ Write output to: ${outputPath}`;
1079
1483
  success: totalSuccess,
1080
1484
  workerCount,
1081
1485
  }, 'Pipelined development phase completed');
1486
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1487
+ duration,
1488
+ endTime: Date.now(),
1489
+ failureCount: allFailures.length,
1490
+ itemCount: totalSuccess + allFailures.length,
1491
+ phaseName: 'dev',
1492
+ startTime,
1493
+ successCount: totalSuccess,
1494
+ });
1082
1495
  return {
1083
1496
  duration,
1084
1497
  failures: allFailures,
@@ -1089,6 +1502,20 @@ Write output to: ${outputPath}`;
1089
1502
  }
1090
1503
  catch (error) {
1091
1504
  this.logger.error({ error: error.message }, 'Pipelined development phase failed');
1505
+ // Ensure ReviewQueue is closed on failure so review workers can terminate
1506
+ if (reviewQueue) {
1507
+ reviewQueue.close();
1508
+ this.logger.info('ReviewQueue closed after dev phase failure');
1509
+ }
1510
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1511
+ duration: Date.now() - startTime,
1512
+ endTime: Date.now(),
1513
+ failureCount: 1,
1514
+ itemCount: 1,
1515
+ phaseName: 'dev',
1516
+ startTime,
1517
+ successCount: 0,
1518
+ });
1092
1519
  return {
1093
1520
  duration: Date.now() - startTime,
1094
1521
  failures: [
@@ -1104,19 +1531,187 @@ Write output to: ${outputPath}`;
1104
1531
  }
1105
1532
  }
1106
1533
  /**
1107
- * Execute pipelined workflow (story and dev phases in parallel)
1534
+ * Review worker that consumes stories from the ReviewQueue and processes them.
1535
+ *
1536
+ * Worker loop:
1537
+ * 1. Dequeue next story (blocks/waits if queue empty)
1538
+ * 2. If null returned, queue is closed and empty → terminate worker
1539
+ * 3. For each story, invoke ReviewPhaseExecutor.reviewStory()
1540
+ * 4. Collect pass/fail results
1541
+ *
1542
+ * @param workerId - Worker identifier for logging
1543
+ * @param reviewQueue - ReviewQueue to dequeue stories from
1544
+ * @param config - Workflow configuration
1545
+ * @returns Worker result with passed and failed story arrays
1546
+ * @private
1547
+ */
1548
+ async reviewWorker(workerId, reviewQueue, config) {
1549
+ const workerLogger = this.logger.child({ reviewWorkerId: workerId });
1550
+ const passedStories = [];
1551
+ const failures = [];
1552
+ let successCount = 0;
1553
+ workerLogger.info('Review worker started');
1554
+ try {
1555
+ while (true) {
1556
+ const story = await reviewQueue.dequeue();
1557
+ if (!story) {
1558
+ workerLogger.info('ReviewQueue closed and empty, review worker terminating');
1559
+ break;
1560
+ }
1561
+ workerLogger.info({ storyNumber: story.fullNumber, storyTitle: story.title }, 'Review worker processing story');
1562
+ try {
1563
+ const result = await this.reviewPhaseExecutor.reviewStory(story, config);
1564
+ if (result.verdict === 'PASS') {
1565
+ passedStories.push(story);
1566
+ successCount++;
1567
+ workerLogger.info({ storyNumber: story.fullNumber, verdict: 'PASS' }, 'Story review passed');
1568
+ }
1569
+ else {
1570
+ const failureReason = result.message || `Review failed with ${result.issues.length} issue(s)`;
1571
+ failures.push({
1572
+ error: failureReason,
1573
+ identifier: story.fullNumber,
1574
+ });
1575
+ workerLogger.warn({
1576
+ issueCount: result.issues.length,
1577
+ reason: failureReason,
1578
+ storyNumber: story.fullNumber,
1579
+ verdict: 'FAIL',
1580
+ }, 'Story review failed — excluded from QA');
1581
+ }
1582
+ }
1583
+ catch (error) {
1584
+ workerLogger.error({ error: error.message, storyNumber: story.fullNumber }, 'Error reviewing story, marking as failed');
1585
+ failures.push({
1586
+ error: error.message,
1587
+ identifier: story.fullNumber,
1588
+ });
1589
+ }
1590
+ }
1591
+ workerLogger.info({ failureCount: failures.length, passedCount: passedStories.length, successCount }, 'Review worker completed');
1592
+ return { failures, passedStories, success: successCount };
1593
+ }
1594
+ catch (error) {
1595
+ workerLogger.error({ error: error.message }, 'Review worker failed with unhandled error');
1596
+ return { failures, passedStories, success: successCount };
1597
+ }
1598
+ }
1599
+ /**
1600
+ * Execute pipelined review phase with worker pool.
1601
+ *
1602
+ * @param reviewQueue - ReviewQueue to consume stories from
1603
+ * @param config - Workflow configuration
1604
+ * @returns PhaseResult with aggregate success/failure counts and passed stories
1605
+ * @private
1606
+ */
1607
+ async executePipelinedReviewPhase(reviewQueue, config) {
1608
+ const startTime = Date.now();
1609
+ const workerCount = 1;
1610
+ this.logger.info({ mode: 'sequential', workerCount }, 'Starting pipelined review phase');
1611
+ try {
1612
+ const workers = Array.from({ length: workerCount }, (_, i) => this.reviewWorker(i, reviewQueue, config));
1613
+ const workerResults = await Promise.all(workers);
1614
+ let totalSuccess = 0;
1615
+ const allFailures = [];
1616
+ const allPassedStories = [];
1617
+ for (const result of workerResults) {
1618
+ totalSuccess += result.success;
1619
+ allFailures.push(...result.failures);
1620
+ allPassedStories.push(...result.passedStories);
1621
+ }
1622
+ const duration = Date.now() - startTime;
1623
+ this.logger.info({
1624
+ duration,
1625
+ failures: allFailures.length,
1626
+ passedStories: allPassedStories.length,
1627
+ success: totalSuccess,
1628
+ workerCount,
1629
+ }, 'Pipelined review phase completed');
1630
+ return {
1631
+ passedStories: allPassedStories,
1632
+ phaseResult: {
1633
+ duration,
1634
+ failures: allFailures,
1635
+ phaseName: 'review',
1636
+ skipped: false,
1637
+ success: totalSuccess,
1638
+ },
1639
+ };
1640
+ }
1641
+ catch (error) {
1642
+ this.logger.error({ error: error.message }, 'Pipelined review phase failed');
1643
+ return {
1644
+ passedStories: [],
1645
+ phaseResult: {
1646
+ duration: Date.now() - startTime,
1647
+ failures: [{ error: error.message, identifier: 'review-phase' }],
1648
+ phaseName: 'review',
1649
+ skipped: false,
1650
+ success: 0,
1651
+ },
1652
+ };
1653
+ }
1654
+ }
1655
+ /**
1656
+ * Execute pipelined workflow (story, dev, and optionally review phases in parallel)
1657
+ *
1658
+ * When config.review === true && reviewPhaseExecutor is available:
1659
+ * [StoryQueue] → dev workers → [ReviewQueue] → review workers → QA collection
1660
+ * When config.review === false (default):
1661
+ * [StoryQueue] → dev workers → QA collection (unchanged behavior)
1108
1662
  *
1109
1663
  * @param config - Workflow configuration
1110
1664
  * @param detection - Input detection result
1111
- * @returns Story and dev phase results
1665
+ * @returns Story, dev, and optional review phase results
1112
1666
  * @private
1113
1667
  */
1114
1668
  async executePipelinedWorkflow(config, detection) {
1115
- this.logger.info('Executing pipelined workflow (sequential dev starts as stories are created)');
1669
+ const pipelineStartTime = Date.now();
1670
+ const enableReview = config.review === true && this.reviewPhaseExecutor != null;
1671
+ this.logger.info({ review: enableReview }, 'Executing pipelined workflow (sequential dev starts as stories are created)');
1116
1672
  const queue = new StoryQueue(this.logger);
1673
+ const reviewQueue = enableReview ? new ReviewQueue(this.logger) : undefined;
1117
1674
  try {
1118
1675
  const storyPhasePromise = this.executeStoryPhaseWithQueue(config, detection, queue);
1119
- const devPhasePromise = this.executePipelinedDevPhase(queue, config);
1676
+ const devPhasePromise = this.executePipelinedDevPhase(queue, config, reviewQueue);
1677
+ if (enableReview && reviewQueue) {
1678
+ // Three-phase pipeline: story → dev → review
1679
+ const reviewPhasePromise = this.executePipelinedReviewPhase(reviewQueue, config);
1680
+ const [storyResult, devResult, reviewResult] = await Promise.allSettled([
1681
+ storyPhasePromise,
1682
+ devPhasePromise,
1683
+ reviewPhasePromise,
1684
+ ]);
1685
+ const devPhase = this.handlePhaseResult(devResult, 'dev');
1686
+ const storyPhase = this.handlePhaseResult(storyResult, 'story');
1687
+ let reviewPhase;
1688
+ if (reviewResult.status === 'fulfilled') {
1689
+ reviewPhase = reviewResult.value.phaseResult;
1690
+ }
1691
+ else {
1692
+ this.logger.error({ error: reviewResult.reason?.message }, 'Review phase promise rejected');
1693
+ reviewPhase = {
1694
+ duration: 0,
1695
+ failures: [{ error: reviewResult.reason?.message || 'Review phase failed', identifier: 'review-phase' }],
1696
+ phaseName: 'review',
1697
+ skipped: false,
1698
+ success: 0,
1699
+ };
1700
+ }
1701
+ // Timing instrumentation (NFR4)
1702
+ const totalDuration = Date.now() - pipelineStartTime;
1703
+ const devDuration = devPhase?.duration ?? 0;
1704
+ const reviewDuration = reviewPhase?.duration ?? 0;
1705
+ const reviewOverheadPercent = devDuration > 0 ? Math.round((reviewDuration / devDuration) * 100) : 0;
1706
+ this.logger.info({
1707
+ devDuration,
1708
+ reviewDuration,
1709
+ reviewOverheadPercent,
1710
+ totalDuration,
1711
+ }, 'Pipeline timing: dev + review phase durations');
1712
+ return { devPhase, reviewPhase, storyPhase };
1713
+ }
1714
+ // Two-phase pipeline: story → dev (default, no review)
1120
1715
  const [storyResult, devResult] = await Promise.allSettled([storyPhasePromise, devPhasePromise]);
1121
1716
  return {
1122
1717
  devPhase: this.handlePhaseResult(devResult, 'dev'),
@@ -1146,9 +1741,17 @@ Write output to: ${outputPath}`;
1146
1741
  const startTime = Date.now();
1147
1742
  const failures = [];
1148
1743
  let successCount = 0;
1744
+ let itemCount = 0;
1149
1745
  this.logger.info({
1150
1746
  qaRetries: config.qaRetries ?? 2,
1151
1747
  }, 'Starting QA phase');
1748
+ // Invoke onPhaseStart callback
1749
+ this.invokeCallback('onPhaseStart', this.callbacks?.onPhaseStart, {
1750
+ itemCount: 0,
1751
+ metadata: { qaRetries: config.qaRetries ?? 2 },
1752
+ phaseName: 'qa',
1753
+ startTime,
1754
+ });
1152
1755
  try {
1153
1756
  // Get QA story directory
1154
1757
  const qaStoryDir = await this.pathResolver.getQaStoryDir();
@@ -1158,6 +1761,17 @@ Write output to: ${outputPath}`;
1158
1761
  const storyFiles = await this.fileManager.listFiles(qaStoryDir, storyPattern);
1159
1762
  if (storyFiles.length === 0) {
1160
1763
  this.logger.info('No stories found in QA folder, skipping QA phase');
1764
+ // Invoke onPhaseComplete for skipped phase
1765
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1766
+ duration: Date.now() - startTime,
1767
+ endTime: Date.now(),
1768
+ failureCount: 0,
1769
+ itemCount: 0,
1770
+ phaseName: 'qa',
1771
+ skipped: true,
1772
+ startTime,
1773
+ successCount: 0,
1774
+ });
1161
1775
  return {
1162
1776
  duration: Date.now() - startTime,
1163
1777
  failures: [],
@@ -1166,6 +1780,7 @@ Write output to: ${outputPath}`;
1166
1780
  success: 0,
1167
1781
  };
1168
1782
  }
1783
+ itemCount = storyFiles.length;
1169
1784
  this.logger.info({ storyCount: storyFiles.length }, 'Found stories for QA phase');
1170
1785
  // Dynamically import QA command to avoid circular dependencies
1171
1786
  const { default: StoriesQaCommand } = await import('../../commands/stories/qa.js');
@@ -1190,8 +1805,29 @@ Write output to: ${outputPath}`;
1190
1805
  qaFlags.push(`--reference=${ref}`);
1191
1806
  }
1192
1807
  }
1193
- // Run QA command
1194
- await StoriesQaCommand.run([...qaArgs, ...qaFlags]);
1808
+ // Forward MCP flags to QA command (when QA command supports them)
1809
+ if (config.mcp) {
1810
+ qaFlags.push('--mcp');
1811
+ if (config.mcpPhases && config.mcpPhases.length > 0) {
1812
+ qaFlags.push(`--mcp-phases=${config.mcpPhases.join(',')}`);
1813
+ }
1814
+ }
1815
+ // Suppress QA command's pino logger in non-verbose mode
1816
+ const prevLogLevel = process.env.LOG_LEVEL;
1817
+ if (!config.verbose) {
1818
+ process.env.LOG_LEVEL = 'silent';
1819
+ }
1820
+ try {
1821
+ await StoriesQaCommand.run([...qaArgs, ...qaFlags]);
1822
+ }
1823
+ finally {
1824
+ if (prevLogLevel === undefined) {
1825
+ delete process.env.LOG_LEVEL;
1826
+ }
1827
+ else {
1828
+ process.env.LOG_LEVEL = prevLogLevel;
1829
+ }
1830
+ }
1195
1831
  successCount++;
1196
1832
  this.logger.info({ storyPath }, 'QA workflow completed successfully for story');
1197
1833
  }
@@ -1205,11 +1841,22 @@ Write output to: ${outputPath}`;
1205
1841
  }
1206
1842
  }
1207
1843
  const duration = Date.now() - startTime;
1844
+ const endTime = Date.now();
1208
1845
  this.logger.info({
1209
1846
  duration,
1210
1847
  failures: failures.length,
1211
1848
  success: successCount,
1212
1849
  }, 'QA phase completed');
1850
+ // Invoke onPhaseComplete callback
1851
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1852
+ duration,
1853
+ endTime,
1854
+ failureCount: failures.length,
1855
+ itemCount,
1856
+ phaseName: 'qa',
1857
+ startTime,
1858
+ successCount,
1859
+ });
1213
1860
  return {
1214
1861
  duration,
1215
1862
  failures,
@@ -1219,9 +1866,28 @@ Write output to: ${outputPath}`;
1219
1866
  };
1220
1867
  }
1221
1868
  catch (error) {
1869
+ const duration = Date.now() - startTime;
1870
+ const endTime = Date.now();
1222
1871
  this.logger.error({ error: error.message }, 'QA phase failed');
1872
+ // Invoke onError callback
1873
+ this.invokeErrorCallback(error, {
1874
+ itemId: 'qa-phase',
1875
+ phaseName: 'qa',
1876
+ recoverable: false,
1877
+ });
1878
+ // Invoke onPhaseComplete callback even on error
1879
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1880
+ duration,
1881
+ endTime,
1882
+ failureCount: failures.length + 1,
1883
+ itemCount,
1884
+ metadata: { error: error.message },
1885
+ phaseName: 'qa',
1886
+ startTime,
1887
+ successCount,
1888
+ });
1223
1889
  return {
1224
- duration: Date.now() - startTime,
1890
+ duration,
1225
1891
  failures: [
1226
1892
  ...failures,
1227
1893
  {
@@ -1319,6 +1985,43 @@ Write output to: ${outputPath}`;
1319
1985
  return { devPhase: this.createSkippedPhaseResult('dev'), storyPhase };
1320
1986
  }
1321
1987
  const devPhase = await this.executeSequentialDevPhase(config, detection, phaseFlags.shouldExecuteDevPhase);
1988
+ // Review phase: after dev, before QA (sequential batch-style)
1989
+ if (phaseFlags.shouldExecuteReviewPhase && this.reviewPhaseExecutor && devPhase && devPhase.success > 0) {
1990
+ const reviewStartTime = Date.now();
1991
+ const stories = await this.getStoriesForDevPhase(config, detection);
1992
+ const reviewFailures = [];
1993
+ let reviewSuccess = 0;
1994
+ this.logger.info({ storyCount: stories.length }, 'Starting sequential review phase');
1995
+ for (const story of stories) {
1996
+ try {
1997
+ const result = await this.reviewPhaseExecutor.reviewStory(story, config);
1998
+ if (result.verdict === 'PASS') {
1999
+ reviewSuccess++;
2000
+ }
2001
+ else {
2002
+ reviewFailures.push({
2003
+ error: result.message || `Review failed with ${result.issues.length} issue(s)`,
2004
+ identifier: story.fullNumber,
2005
+ });
2006
+ this.logger.warn({ storyNumber: story.fullNumber, verdict: 'FAIL' }, 'Story review failed — excluded from QA');
2007
+ }
2008
+ }
2009
+ catch (error) {
2010
+ reviewFailures.push({
2011
+ error: error.message,
2012
+ identifier: story.fullNumber,
2013
+ });
2014
+ }
2015
+ }
2016
+ const reviewPhase = {
2017
+ duration: Date.now() - reviewStartTime,
2018
+ failures: reviewFailures,
2019
+ phaseName: 'review',
2020
+ skipped: false,
2021
+ success: reviewSuccess,
2022
+ };
2023
+ return { devPhase, reviewPhase, storyPhase };
2024
+ }
1322
2025
  return { devPhase, storyPhase };
1323
2026
  }
1324
2027
  /**
@@ -1355,16 +2058,24 @@ Write output to: ${outputPath}`;
1355
2058
  const startTime = Date.now();
1356
2059
  const failures = [];
1357
2060
  let successCount = 0;
2061
+ let itemCount = 0;
1358
2062
  // In pipeline mode, stories must be created sequentially (one at a time)
1359
2063
  // to ensure they are queued in the correct order for development
1360
2064
  const isPipelineMode = Boolean(onStoryComplete);
1361
- const effectiveParallel = isPipelineMode ? 1 : config.parallel;
2065
+ const effectiveParallel = config.parallel;
1362
2066
  this.logger.info({
1363
2067
  epicCount: epics.length,
1364
2068
  interval: config.epicInterval,
1365
2069
  mode: isPipelineMode ? 'sequential (pipeline)' : 'parallel',
1366
2070
  parallel: effectiveParallel,
1367
2071
  }, 'Starting story creation phase');
2072
+ // Invoke onPhaseStart callback
2073
+ this.invokeCallback('onPhaseStart', this.callbacks?.onPhaseStart, {
2074
+ itemCount: 0,
2075
+ metadata: { epicCount: epics.length, parallel: effectiveParallel, pipelineMode: isPipelineMode },
2076
+ phaseName: 'story',
2077
+ startTime,
2078
+ });
1368
2079
  try {
1369
2080
  const allStories = [];
1370
2081
  // Get epic directory
@@ -1397,8 +2108,19 @@ Write output to: ${outputPath}`;
1397
2108
  // Flatten the array of story arrays
1398
2109
  allStories.push(...epicStories.flat());
1399
2110
  this.logger.info({ storyCount: allStories.length }, 'Stories extracted from epics');
2111
+ itemCount = allStories.length;
1400
2112
  if (allStories.length === 0) {
1401
2113
  this.logger.warn('No stories found in epic files');
2114
+ // Invoke onPhaseComplete for empty phase
2115
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
2116
+ duration: Date.now() - startTime,
2117
+ endTime: Date.now(),
2118
+ failureCount: 0,
2119
+ itemCount: 0,
2120
+ phaseName: 'story',
2121
+ startTime,
2122
+ successCount: 0,
2123
+ });
1402
2124
  return {
1403
2125
  duration: Date.now() - startTime,
1404
2126
  failures: [],
@@ -1420,8 +2142,18 @@ Write output to: ${outputPath}`;
1420
2142
  if (onStoryComplete) {
1421
2143
  await this.enqueueExistingStoriesSequentially(allStories, storyDir, prefix, onStoryComplete);
1422
2144
  }
2145
+ const duration = Date.now() - startTime;
2146
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
2147
+ duration,
2148
+ endTime: Date.now(),
2149
+ failureCount: 0,
2150
+ itemCount: allStories.length,
2151
+ phaseName: 'story',
2152
+ startTime,
2153
+ successCount: allStories.length,
2154
+ });
1423
2155
  return {
1424
- duration: Date.now() - startTime,
2156
+ duration,
1425
2157
  failures: [],
1426
2158
  phaseName: 'story',
1427
2159
  skipped: false,
@@ -1441,107 +2173,161 @@ Write output to: ${outputPath}`;
1441
2173
  : this.batchProcessor;
1442
2174
  // Create story files using BatchProcessor and ClaudeAgentRunner
1443
2175
  const results = await storyBatchProcessor.processBatch(storiesToCreate, async (story) => {
1444
- // Generate story file path with prefix
1445
- const storyFileName = this.generateStoryFileName(prefix, story.fullNumber);
1446
- const storyFilePath = `${storyDir}/${storyFileName}`;
1447
- // Check if file already exists (might have been created by parallel process)
1448
- const fileExists = await this.fileManager.fileExists(storyFilePath);
1449
- if (fileExists) {
2176
+ const spawnStartTime = Date.now();
2177
+ const spawnId = `story-${story.fullNumber}`;
2178
+ // Fire onSpawnStart so the reporter shows which story is being created
2179
+ this.invokeCallback('onSpawnStart', this.callbacks?.onSpawnStart, {
2180
+ agentType: 'sm',
2181
+ itemId: `story-${story.fullNumber}`,
2182
+ itemTitle: story.title,
2183
+ phaseName: 'story',
2184
+ spawnId,
2185
+ startTime: spawnStartTime,
2186
+ });
2187
+ try {
2188
+ // Generate story file path with prefix
2189
+ const storyFileName = this.generateStoryFileName(prefix, story.fullNumber);
2190
+ const storyFilePath = `${storyDir}/${storyFileName}`;
2191
+ // Check if file already exists (might have been created by parallel process)
2192
+ const fileExists = await this.fileManager.fileExists(storyFilePath);
2193
+ if (fileExists) {
2194
+ this.logger.info({
2195
+ filePath: storyFilePath,
2196
+ storyNumber: story.fullNumber,
2197
+ storyTitle: story.title,
2198
+ }, 'Story file already exists, skipping creation');
2199
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
2200
+ agentType: 'sm',
2201
+ duration: Date.now() - spawnStartTime,
2202
+ endTime: Date.now(),
2203
+ itemId: `story-${story.fullNumber}`,
2204
+ itemTitle: story.title,
2205
+ phaseName: 'story',
2206
+ spawnId,
2207
+ startTime: spawnStartTime,
2208
+ success: true,
2209
+ });
2210
+ return storyFilePath;
2211
+ }
2212
+ // Step 1: Create scaffolded file with structured sections and populated metadata
2213
+ const scaffoldedContent = this.fileScaffolder.scaffoldStory({
2214
+ epicNumber: story.epicNumber,
2215
+ storyNumber: story.number,
2216
+ storyTitle: story.title,
2217
+ });
2218
+ await this.fileManager.writeFile(storyFilePath, scaffoldedContent);
1450
2219
  this.logger.info({
1451
2220
  filePath: storyFilePath,
1452
2221
  storyNumber: story.fullNumber,
1453
2222
  storyTitle: story.title,
1454
- }, 'Story file already exists, skipping creation');
1455
- return storyFilePath;
1456
- }
1457
- // Step 1: Create scaffolded file with structured sections and populated metadata
1458
- const scaffoldedContent = this.fileScaffolder.scaffoldStory({
1459
- epicNumber: story.epicNumber,
1460
- storyNumber: story.number,
1461
- storyTitle: story.title,
1462
- });
1463
- await this.fileManager.writeFile(storyFilePath, scaffoldedContent);
1464
- this.logger.info({
1465
- filePath: storyFilePath,
1466
- storyNumber: story.fullNumber,
1467
- storyTitle: story.title,
1468
- }, 'Story scaffolded file created');
1469
- // Step 2: Generate epic file path for this story
1470
- const epicFileName = this.generateEpicFileName(prefix, story.epicNumber);
1471
- const epicFilePath = `${epicDir}/${epicFileName}`;
1472
- // Step 3: Build Claude prompt to populate the scaffolded file
1473
- const prompt = this.buildStoryPrompt(story, {
1474
- cwd: config.cwd,
1475
- epicPath: epicFilePath,
1476
- outputPath: storyFilePath,
1477
- prefix,
1478
- references: config.references,
1479
- });
1480
- // Log prompt if verbose
1481
- if (config.verbose) {
1482
- this.logger.info({
2223
+ }, 'Story scaffolded file created');
2224
+ // Step 2: Generate epic file path for this story
2225
+ const epicFileName = this.generateEpicFileName(prefix, story.epicNumber);
2226
+ const epicFilePath = `${epicDir}/${epicFileName}`;
2227
+ // Step 3: Build Claude prompt to populate the scaffolded file
2228
+ const mcpPrefix = await this.getMcpPromptPrefix('story', 'sm', config);
2229
+ const basePrompt = this.buildStoryPrompt(story, {
2230
+ cwd: config.cwd,
2231
+ epicPath: epicFilePath,
1483
2232
  outputPath: storyFilePath,
1484
- prompt,
1485
- storyNumber: story.fullNumber,
1486
- storyTitle: story.title,
1487
- }, 'Claude Prompt (Story)');
1488
- }
1489
- // Step 4: Run Claude agent to populate content sections
1490
- const result = await runAgentWithRetry(this.agentRunner, prompt, {
1491
- agentType: 'sm',
1492
- references: config.references,
1493
- timeout: config.timeout ?? 2_700_000,
1494
- }, {
1495
- backoffMs: config.retryBackoffMs,
1496
- logger: this.logger,
1497
- maxRetries: config.maxRetries,
1498
- });
1499
- // Log output if verbose
1500
- if (config.verbose) {
1501
- this.logger.info({
1502
- duration: result.duration,
1503
- errors: result.errors,
1504
- output: result.output,
1505
- outputLength: result.output.length,
1506
- storyNumber: story.fullNumber,
1507
- success: result.success,
1508
- }, 'Claude Response (Story)');
1509
- }
1510
- if (!result.success) {
1511
- throw new Error(result.errors);
1512
- }
1513
- // Step 5: Verify file was updated by Claude
1514
- const updatedContent = await this.fileManager.readFile(storyFilePath);
1515
- if (updatedContent === scaffoldedContent) {
1516
- throw new Error(`Claude did not update the story file at ${storyFilePath}`);
1517
- }
1518
- // Invoke callback after successful story creation
1519
- if (onStoryComplete) {
1520
- try {
1521
- const metadata = {
1522
- epicNumber: story.epicNumber,
1523
- filePath: storyFilePath,
1524
- id: story.fullNumber,
1525
- number: story.fullNumber,
1526
- status: 'Draft',
1527
- storyNumber: story.number,
1528
- title: story.title,
1529
- type: 'epic-based',
1530
- };
1531
- this.logger.debug({
1532
- metadata,
2233
+ prefix,
2234
+ references: config.references,
2235
+ });
2236
+ const prompt = mcpPrefix ? `${mcpPrefix}\n\n${basePrompt}` : basePrompt;
2237
+ // Log prompt if verbose
2238
+ if (config.verbose) {
2239
+ this.logger.info({
2240
+ outputPath: storyFilePath,
2241
+ prompt,
1533
2242
  storyNumber: story.fullNumber,
1534
- }, 'Invoking story completion callback');
1535
- await onStoryComplete(metadata);
2243
+ storyTitle: story.title,
2244
+ }, 'Claude Prompt (Story)');
1536
2245
  }
1537
- catch (error) {
1538
- this.logger.error({
1539
- error: error.message,
2246
+ // Step 4: Run Claude agent to populate content sections
2247
+ const result = await runAgentWithRetry(this.agentRunner, prompt, {
2248
+ agentType: 'sm',
2249
+ references: config.references,
2250
+ timeout: config.timeout ?? 2_700_000,
2251
+ }, {
2252
+ backoffMs: config.retryBackoffMs,
2253
+ logger: this.logger,
2254
+ maxRetries: config.maxRetries,
2255
+ });
2256
+ // Log output if verbose
2257
+ if (config.verbose) {
2258
+ this.logger.info({
2259
+ duration: result.duration,
2260
+ errors: result.errors,
2261
+ output: result.output,
2262
+ outputLength: result.output.length,
1540
2263
  storyNumber: story.fullNumber,
1541
- }, 'Story completion callback failed, continuing story creation');
2264
+ success: result.success,
2265
+ }, 'Claude Response (Story)');
2266
+ }
2267
+ if (!result.success) {
2268
+ throw new Error(result.errors);
1542
2269
  }
2270
+ // Step 5: Verify file was updated by Claude
2271
+ const updatedContent = await this.fileManager.readFile(storyFilePath);
2272
+ if (updatedContent === scaffoldedContent) {
2273
+ throw new Error(`Claude did not update the story file at ${storyFilePath}`);
2274
+ }
2275
+ // Invoke callback after successful story creation
2276
+ if (onStoryComplete) {
2277
+ try {
2278
+ const metadata = {
2279
+ epicNumber: story.epicNumber,
2280
+ filePath: storyFilePath,
2281
+ id: story.fullNumber,
2282
+ number: story.fullNumber,
2283
+ status: 'Draft',
2284
+ storyNumber: story.number,
2285
+ title: story.title,
2286
+ type: 'epic-based',
2287
+ };
2288
+ this.logger.debug({
2289
+ metadata,
2290
+ storyNumber: story.fullNumber,
2291
+ }, 'Invoking story completion callback');
2292
+ await onStoryComplete(metadata);
2293
+ }
2294
+ catch (error) {
2295
+ this.logger.error({
2296
+ error: error.message,
2297
+ storyNumber: story.fullNumber,
2298
+ }, 'Story completion callback failed, continuing story creation');
2299
+ }
2300
+ }
2301
+ // Fire onSpawnComplete for success
2302
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
2303
+ agentType: 'sm',
2304
+ duration: Date.now() - spawnStartTime,
2305
+ endTime: Date.now(),
2306
+ itemId: `story-${story.fullNumber}`,
2307
+ itemTitle: story.title,
2308
+ phaseName: 'story',
2309
+ spawnId,
2310
+ startTime: spawnStartTime,
2311
+ success: true,
2312
+ });
2313
+ return storyFilePath;
2314
+ }
2315
+ catch (spawnError) {
2316
+ // Fire onSpawnComplete for failure
2317
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
2318
+ agentType: 'sm',
2319
+ duration: Date.now() - spawnStartTime,
2320
+ endTime: Date.now(),
2321
+ error: spawnError.message,
2322
+ itemId: `story-${story.fullNumber}`,
2323
+ itemTitle: story.title,
2324
+ phaseName: 'story',
2325
+ spawnId,
2326
+ startTime: spawnStartTime,
2327
+ success: false,
2328
+ });
2329
+ throw spawnError;
1543
2330
  }
1544
- return storyFilePath;
1545
2331
  }, (info) => {
1546
2332
  this.logger.info({
1547
2333
  completedItems: info.completedItems,
@@ -1565,11 +2351,22 @@ Write output to: ${outputPath}`;
1565
2351
  // Add existing stories to success count
1566
2352
  successCount += existingStories.length;
1567
2353
  const duration = Date.now() - startTime;
2354
+ const endTime = Date.now();
1568
2355
  this.logger.info({
1569
2356
  duration,
1570
2357
  failures: failures.length,
1571
2358
  success: successCount,
1572
2359
  }, 'Story creation phase completed');
2360
+ // Invoke onPhaseComplete callback
2361
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
2362
+ duration,
2363
+ endTime,
2364
+ failureCount: failures.length,
2365
+ itemCount,
2366
+ phaseName: 'story',
2367
+ startTime,
2368
+ successCount,
2369
+ });
1573
2370
  return {
1574
2371
  duration,
1575
2372
  failures,
@@ -1579,9 +2376,28 @@ Write output to: ${outputPath}`;
1579
2376
  };
1580
2377
  }
1581
2378
  catch (error) {
2379
+ const duration = Date.now() - startTime;
2380
+ const endTime = Date.now();
1582
2381
  this.logger.error({ error: error.message }, 'Story phase failed');
2382
+ // Invoke onError callback
2383
+ this.invokeErrorCallback(error, {
2384
+ itemId: 'story-phase',
2385
+ phaseName: 'story',
2386
+ recoverable: false,
2387
+ });
2388
+ // Invoke onPhaseComplete callback even on error
2389
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
2390
+ duration,
2391
+ endTime,
2392
+ failureCount: failures.length + 1,
2393
+ itemCount,
2394
+ metadata: { error: error.message },
2395
+ phaseName: 'story',
2396
+ startTime,
2397
+ successCount,
2398
+ });
1583
2399
  return {
1584
- duration: Date.now() - startTime,
2400
+ duration,
1585
2401
  failures: [
1586
2402
  ...failures,
1587
2403
  {
@@ -1613,7 +2429,7 @@ Write output to: ${outputPath}`;
1613
2429
  // In pipeline mode, stories must be created sequentially (one at a time)
1614
2430
  // to ensure they are queued in the correct order for development
1615
2431
  const isPipelineMode = Boolean(onStoryComplete);
1616
- const effectiveParallel = isPipelineMode ? 1 : config.parallel;
2432
+ const effectiveParallel = config.parallel;
1617
2433
  this.logger.info({
1618
2434
  epicFilePath,
1619
2435
  mode: isPipelineMode ? 'sequential (pipeline)' : 'parallel',
@@ -1652,8 +2468,18 @@ Write output to: ${outputPath}`;
1652
2468
  if (onStoryComplete) {
1653
2469
  await this.enqueueExistingStoriesSequentially(allStories, storyDir, prefix, onStoryComplete);
1654
2470
  }
2471
+ const duration = Date.now() - startTime;
2472
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
2473
+ duration,
2474
+ endTime: Date.now(),
2475
+ failureCount: 0,
2476
+ itemCount: allStories.length,
2477
+ phaseName: 'story',
2478
+ startTime,
2479
+ successCount: allStories.length,
2480
+ });
1655
2481
  return {
1656
- duration: Date.now() - startTime,
2482
+ duration,
1657
2483
  failures: [],
1658
2484
  phaseName: 'story',
1659
2485
  skipped: false,
@@ -1673,106 +2499,160 @@ Write output to: ${outputPath}`;
1673
2499
  : this.batchProcessor;
1674
2500
  // Create story files using BatchProcessor and ClaudeAgentRunner
1675
2501
  const results = await storyBatchProcessor.processBatch(storiesToCreate, async (story) => {
1676
- // Generate story file path with prefix
1677
- const storyFileName = this.generateStoryFileName(prefix, story.fullNumber);
1678
- const storyFilePath = `${storyDir}/${storyFileName}`;
1679
- // Check if file already exists (might have been created by parallel process)
1680
- const fileExists = await this.fileManager.fileExists(storyFilePath);
1681
- if (fileExists) {
2502
+ const spawnStartTime = Date.now();
2503
+ const spawnId = `story-${story.fullNumber}`;
2504
+ // Fire onSpawnStart so the reporter shows which story is being created
2505
+ this.invokeCallback('onSpawnStart', this.callbacks?.onSpawnStart, {
2506
+ agentType: 'sm',
2507
+ itemId: `story-${story.fullNumber}`,
2508
+ itemTitle: story.title,
2509
+ phaseName: 'story',
2510
+ spawnId,
2511
+ startTime: spawnStartTime,
2512
+ });
2513
+ try {
2514
+ // Generate story file path with prefix
2515
+ const storyFileName = this.generateStoryFileName(prefix, story.fullNumber);
2516
+ const storyFilePath = `${storyDir}/${storyFileName}`;
2517
+ // Check if file already exists (might have been created by parallel process)
2518
+ const fileExists = await this.fileManager.fileExists(storyFilePath);
2519
+ if (fileExists) {
2520
+ this.logger.info({
2521
+ filePath: storyFilePath,
2522
+ storyNumber: story.fullNumber,
2523
+ storyTitle: story.title,
2524
+ }, 'Story file already exists, skipping creation');
2525
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
2526
+ agentType: 'sm',
2527
+ duration: Date.now() - spawnStartTime,
2528
+ endTime: Date.now(),
2529
+ itemId: `story-${story.fullNumber}`,
2530
+ itemTitle: story.title,
2531
+ phaseName: 'story',
2532
+ spawnId,
2533
+ startTime: spawnStartTime,
2534
+ success: true,
2535
+ });
2536
+ return storyFilePath;
2537
+ }
2538
+ // Step 1: Create scaffolded file with structured sections and populated metadata
2539
+ const scaffoldedContent = this.fileScaffolder.scaffoldStory({
2540
+ epicNumber: story.epicNumber,
2541
+ storyNumber: story.number,
2542
+ storyTitle: story.title,
2543
+ });
2544
+ await this.fileManager.writeFile(storyFilePath, scaffoldedContent);
1682
2545
  this.logger.info({
1683
2546
  filePath: storyFilePath,
1684
2547
  storyNumber: story.fullNumber,
1685
2548
  storyTitle: story.title,
1686
- }, 'Story file already exists, skipping creation');
1687
- return storyFilePath;
1688
- }
1689
- // Step 1: Create scaffolded file with structured sections and populated metadata
1690
- const scaffoldedContent = this.fileScaffolder.scaffoldStory({
1691
- epicNumber: story.epicNumber,
1692
- storyNumber: story.number,
1693
- storyTitle: story.title,
1694
- });
1695
- await this.fileManager.writeFile(storyFilePath, scaffoldedContent);
1696
- this.logger.info({
1697
- filePath: storyFilePath,
1698
- storyNumber: story.fullNumber,
1699
- storyTitle: story.title,
1700
- }, 'Story scaffolded file created');
1701
- // Step 2: Use the epic file path that was passed to this method
1702
- // Step 3: Build Claude prompt to populate the scaffolded file
1703
- const prompt = this.buildStoryPrompt(story, {
1704
- cwd: config.cwd,
1705
- epicPath: epicFilePath,
1706
- outputPath: storyFilePath,
1707
- prefix,
1708
- references: config.references,
1709
- });
1710
- // Log prompt if verbose
1711
- if (config.verbose) {
1712
- this.logger.info({
2549
+ }, 'Story scaffolded file created');
2550
+ // Step 2: Use the epic file path that was passed to this method
2551
+ // Step 3: Build Claude prompt to populate the scaffolded file
2552
+ const mcpPrefix = await this.getMcpPromptPrefix('story', 'sm', config);
2553
+ const basePrompt = this.buildStoryPrompt(story, {
2554
+ cwd: config.cwd,
1713
2555
  epicPath: epicFilePath,
1714
2556
  outputPath: storyFilePath,
1715
- prompt,
1716
- storyNumber: story.fullNumber,
1717
- storyTitle: story.title,
1718
- }, 'Claude Prompt (Story)');
1719
- }
1720
- // Step 4: Run Claude agent to populate content sections
1721
- const result = await runAgentWithRetry(this.agentRunner, prompt, {
1722
- agentType: 'sm',
1723
- references: config.references,
1724
- timeout: config.timeout ?? 2_700_000,
1725
- }, {
1726
- backoffMs: config.retryBackoffMs,
1727
- logger: this.logger,
1728
- maxRetries: config.maxRetries,
1729
- });
1730
- // Log output if verbose
1731
- if (config.verbose) {
1732
- this.logger.info({
1733
- duration: result.duration,
1734
- errors: result.errors,
1735
- output: result.output,
1736
- outputLength: result.output.length,
1737
- storyNumber: story.fullNumber,
1738
- success: result.success,
1739
- }, 'Claude Response (Story)');
1740
- }
1741
- if (!result.success) {
1742
- throw new Error(result.errors);
1743
- }
1744
- // Step 5: Verify file was updated by Claude
1745
- const updatedContent = await this.fileManager.readFile(storyFilePath);
1746
- if (updatedContent === scaffoldedContent) {
1747
- throw new Error(`Claude did not update the story file at ${storyFilePath}`);
1748
- }
1749
- // Invoke callback after successful story creation
1750
- if (onStoryComplete) {
1751
- try {
1752
- const metadata = {
1753
- epicNumber: story.epicNumber,
1754
- filePath: storyFilePath,
1755
- id: story.fullNumber,
1756
- number: story.fullNumber,
1757
- status: 'Draft',
1758
- storyNumber: story.number,
1759
- title: story.title,
1760
- type: 'epic-based',
1761
- };
1762
- this.logger.debug({
1763
- metadata,
2557
+ prefix,
2558
+ references: config.references,
2559
+ });
2560
+ const prompt = mcpPrefix ? `${mcpPrefix}\n\n${basePrompt}` : basePrompt;
2561
+ // Log prompt if verbose
2562
+ if (config.verbose) {
2563
+ this.logger.info({
2564
+ epicPath: epicFilePath,
2565
+ outputPath: storyFilePath,
2566
+ prompt,
1764
2567
  storyNumber: story.fullNumber,
1765
- }, 'Invoking story completion callback');
1766
- await onStoryComplete(metadata);
2568
+ storyTitle: story.title,
2569
+ }, 'Claude Prompt (Story)');
1767
2570
  }
1768
- catch (error) {
1769
- this.logger.error({
1770
- error: error.message,
2571
+ // Step 4: Run Claude agent to populate content sections
2572
+ const result = await runAgentWithRetry(this.agentRunner, prompt, {
2573
+ agentType: 'sm',
2574
+ references: config.references,
2575
+ timeout: config.timeout ?? 2_700_000,
2576
+ }, {
2577
+ backoffMs: config.retryBackoffMs,
2578
+ logger: this.logger,
2579
+ maxRetries: config.maxRetries,
2580
+ });
2581
+ // Log output if verbose
2582
+ if (config.verbose) {
2583
+ this.logger.info({
2584
+ duration: result.duration,
2585
+ errors: result.errors,
2586
+ output: result.output,
2587
+ outputLength: result.output.length,
1771
2588
  storyNumber: story.fullNumber,
1772
- }, 'Story completion callback failed, continuing story creation');
2589
+ success: result.success,
2590
+ }, 'Claude Response (Story)');
2591
+ }
2592
+ if (!result.success) {
2593
+ throw new Error(result.errors);
2594
+ }
2595
+ // Step 5: Verify file was updated by Claude
2596
+ const updatedContent = await this.fileManager.readFile(storyFilePath);
2597
+ if (updatedContent === scaffoldedContent) {
2598
+ throw new Error(`Claude did not update the story file at ${storyFilePath}`);
1773
2599
  }
2600
+ // Invoke callback after successful story creation
2601
+ if (onStoryComplete) {
2602
+ try {
2603
+ const metadata = {
2604
+ epicNumber: story.epicNumber,
2605
+ filePath: storyFilePath,
2606
+ id: story.fullNumber,
2607
+ number: story.fullNumber,
2608
+ status: 'Draft',
2609
+ storyNumber: story.number,
2610
+ title: story.title,
2611
+ type: 'epic-based',
2612
+ };
2613
+ this.logger.debug({
2614
+ metadata,
2615
+ storyNumber: story.fullNumber,
2616
+ }, 'Invoking story completion callback');
2617
+ await onStoryComplete(metadata);
2618
+ }
2619
+ catch (error) {
2620
+ this.logger.error({
2621
+ error: error.message,
2622
+ storyNumber: story.fullNumber,
2623
+ }, 'Story completion callback failed, continuing story creation');
2624
+ }
2625
+ }
2626
+ // Fire onSpawnComplete for success
2627
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
2628
+ agentType: 'sm',
2629
+ duration: Date.now() - spawnStartTime,
2630
+ endTime: Date.now(),
2631
+ itemId: `story-${story.fullNumber}`,
2632
+ itemTitle: story.title,
2633
+ phaseName: 'story',
2634
+ spawnId,
2635
+ startTime: spawnStartTime,
2636
+ success: true,
2637
+ });
2638
+ return storyFilePath;
2639
+ }
2640
+ catch (spawnError) {
2641
+ // Fire onSpawnComplete for failure
2642
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
2643
+ agentType: 'sm',
2644
+ duration: Date.now() - spawnStartTime,
2645
+ endTime: Date.now(),
2646
+ error: spawnError.message,
2647
+ itemId: `story-${story.fullNumber}`,
2648
+ itemTitle: story.title,
2649
+ phaseName: 'story',
2650
+ spawnId,
2651
+ startTime: spawnStartTime,
2652
+ success: false,
2653
+ });
2654
+ throw spawnError;
1774
2655
  }
1775
- return storyFilePath;
1776
2656
  }, (info) => {
1777
2657
  this.logger.info({
1778
2658
  completedItems: info.completedItems,