@hyperdrive.bot/bmad-workflow 1.0.16 → 1.0.18

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.
@@ -60,6 +60,7 @@ import { StoryQueue } from './story-queue.js';
60
60
  export class WorkflowOrchestrator {
61
61
  agentRunner;
62
62
  batchProcessor;
63
+ callbacks;
63
64
  epicParser;
64
65
  fileManager;
65
66
  fileScaffolder;
@@ -86,6 +87,7 @@ export class WorkflowOrchestrator {
86
87
  this.storyTypeDetector = config.storyTypeDetector;
87
88
  this.logger = config.logger;
88
89
  this.workflowLogger = config.workflowLogger;
90
+ this.callbacks = config.callbacks;
89
91
  this.fileScaffolder = new FileScaffolder(config.logger);
90
92
  this.prdFixer = new PrdFixer(config.agentRunner, config.fileManager, config.logger);
91
93
  this.logger.debug('WorkflowOrchestrator initialized');
@@ -222,6 +224,83 @@ Write output to: ${outputPath}`;
222
224
  totalFilesProcessed,
223
225
  };
224
226
  }
227
+ /**
228
+ * Invoke a callback safely, catching and logging any errors
229
+ *
230
+ * All callback invocations are wrapped in try-catch to prevent
231
+ * callback errors from interrupting workflow execution.
232
+ *
233
+ * @param callbackName - Name of the callback for logging
234
+ * @param callback - The callback function to invoke
235
+ * @param context - The context to pass to the callback
236
+ * @private
237
+ */
238
+ invokeCallback(callbackName, callback, context) {
239
+ if (!callback)
240
+ return;
241
+ try {
242
+ callback(context);
243
+ }
244
+ catch (error) {
245
+ this.logger.warn({
246
+ callbackName,
247
+ error: error.message,
248
+ }, 'Callback error (ignored to prevent workflow interruption)');
249
+ }
250
+ }
251
+ /**
252
+ * Invoke the onSpawnOutput callback safely
253
+ *
254
+ * @param callback - The callback function to invoke
255
+ * @param context - The spawn context
256
+ * @param output - The output text
257
+ * @private
258
+ */
259
+ invokeSpawnOutputCallback(callback, context, output) {
260
+ if (!callback)
261
+ return;
262
+ try {
263
+ callback(context, output);
264
+ }
265
+ catch (error) {
266
+ this.logger.warn({
267
+ callbackName: 'onSpawnOutput',
268
+ error: error.message,
269
+ }, 'Callback error (ignored to prevent workflow interruption)');
270
+ }
271
+ }
272
+ /**
273
+ * Invoke the onError callback safely
274
+ *
275
+ * @param error - The error that occurred
276
+ * @param options - Additional context for the error
277
+ * @private
278
+ */
279
+ invokeErrorCallback(error, options = {}) {
280
+ const callback = this.callbacks?.onError;
281
+ if (!callback)
282
+ return;
283
+ const context = {
284
+ error,
285
+ itemId: options.itemId,
286
+ message: error.message,
287
+ metadata: options.metadata,
288
+ phaseName: options.phaseName,
289
+ recoverable: options.recoverable ?? true,
290
+ spawnId: options.spawnId,
291
+ stack: error.stack,
292
+ timestamp: Date.now(),
293
+ };
294
+ try {
295
+ callback(context);
296
+ }
297
+ catch (callbackError) {
298
+ this.logger.warn({
299
+ callbackName: 'onError',
300
+ error: callbackError.message,
301
+ }, 'Callback error (ignored to prevent workflow interruption)');
302
+ }
303
+ }
225
304
  /**
226
305
  * Check which files already exist in a directory
227
306
  *
@@ -297,7 +376,7 @@ Write output to: ${outputPath}`;
297
376
  const content = await this.fileManager.readFile(filePath);
298
377
  const isScaffolded = content.includes('_[AI Agent will populate');
299
378
  if (isScaffolded) {
300
- this.logger.warn({
379
+ this.logger.info({
301
380
  fileName,
302
381
  filePath,
303
382
  }, 'Epic file exists but is only scaffolded (will be re-populated)');
@@ -418,6 +497,7 @@ Write output to: ${outputPath}`;
418
497
  const workerLogger = this.logger.child({ workerId });
419
498
  let successCount = 0;
420
499
  const failures = [];
500
+ let devPhaseStarted = false;
421
501
  workerLogger.info('Worker started');
422
502
  try {
423
503
  const storyDir = await this.pathResolver.getStoryDir();
@@ -430,6 +510,16 @@ Write output to: ${outputPath}`;
430
510
  workerLogger.info('Queue closed and empty, worker terminating');
431
511
  break;
432
512
  }
513
+ // Fire onPhaseStart on first dequeue (after story phase is done creating)
514
+ if (!devPhaseStarted) {
515
+ devPhaseStarted = true;
516
+ this.invokeCallback('onPhaseStart', this.callbacks?.onPhaseStart, {
517
+ itemCount: 0,
518
+ metadata: { mode: 'pipeline', workerId },
519
+ phaseName: 'dev',
520
+ startTime: Date.now(),
521
+ });
522
+ }
433
523
  workerLogger.info({
434
524
  storyNumber: story.fullNumber,
435
525
  storyTitle: story.title,
@@ -500,6 +590,20 @@ Write output to: ${outputPath}`;
500
590
  prompt += '\n';
501
591
  }
502
592
  prompt += '*yolo mode*\n';
593
+ // Generate unique spawn ID
594
+ const spawnId = `dev-${story.fullNumber}-${storyStartTime}`;
595
+ // Invoke onSpawnStart callback
596
+ this.invokeCallback('onSpawnStart', this.callbacks?.onSpawnStart, {
597
+ agentType: 'dev',
598
+ itemId: story.fullNumber,
599
+ itemTitle: story.title,
600
+ outputPath: storyFilePath,
601
+ phaseName: 'dev',
602
+ prompt,
603
+ spawnId,
604
+ startTime: storyStartTime,
605
+ workerId,
606
+ });
503
607
  // Execute dev agent with retry on timeout/killed
504
608
  const result = await runAgentWithRetry(this.agentRunner, prompt, {
505
609
  agentType: 'dev',
@@ -510,7 +614,24 @@ Write output to: ${outputPath}`;
510
614
  logger: this.logger,
511
615
  maxRetries: config.maxRetries,
512
616
  });
617
+ const spawnEndTime = Date.now();
618
+ const spawnDuration = spawnEndTime - storyStartTime;
513
619
  if (result.success) {
620
+ // Invoke onSpawnComplete callback for success
621
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
622
+ agentType: 'dev',
623
+ duration: spawnDuration,
624
+ endTime: spawnEndTime,
625
+ itemId: story.fullNumber,
626
+ itemTitle: story.title,
627
+ output: result.output,
628
+ outputPath: storyFilePath,
629
+ phaseName: 'dev',
630
+ spawnId,
631
+ startTime: storyStartTime,
632
+ success: true,
633
+ workerId,
634
+ });
514
635
  // Update story status to Done
515
636
  await this.updateStoryStatus(storyFilePath, 'Done');
516
637
  // Move to QA folder
@@ -530,6 +651,28 @@ Write output to: ${outputPath}`;
530
651
  workerLogger.info({ storyNumber: story.fullNumber }, 'Story development completed successfully');
531
652
  }
532
653
  else {
654
+ // Invoke onSpawnComplete callback for failure
655
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
656
+ agentType: 'dev',
657
+ duration: spawnDuration,
658
+ endTime: spawnEndTime,
659
+ error: result.errors,
660
+ itemId: story.fullNumber,
661
+ itemTitle: story.title,
662
+ outputPath: storyFilePath,
663
+ phaseName: 'dev',
664
+ spawnId,
665
+ startTime: storyStartTime,
666
+ success: false,
667
+ workerId,
668
+ });
669
+ // Invoke onError callback
670
+ this.invokeErrorCallback(new Error(result.errors), {
671
+ itemId: story.fullNumber,
672
+ phaseName: 'dev',
673
+ recoverable: true,
674
+ spawnId,
675
+ });
533
676
  // Log story failed in pipeline mode
534
677
  const storyDuration = Date.now() - storyStartTime;
535
678
  if (this.workflowLogger) {
@@ -552,6 +695,12 @@ Write output to: ${outputPath}`;
552
695
  }
553
696
  }
554
697
  catch (error) {
698
+ // Invoke onError callback for unexpected errors
699
+ this.invokeErrorCallback(error, {
700
+ itemId: story.fullNumber,
701
+ phaseName: 'dev',
702
+ recoverable: true,
703
+ });
555
704
  // Catch errors for individual stories to prevent worker crash
556
705
  workerLogger.error({
557
706
  error: error.message,
@@ -636,10 +785,18 @@ Write output to: ${outputPath}`;
636
785
  const startTime = Date.now();
637
786
  const failures = [];
638
787
  let successCount = 0;
788
+ const itemCount = stories.length;
639
789
  this.logger.info({
640
790
  interval: config.storyInterval,
641
791
  storyCount: stories.length,
642
792
  }, 'Starting development phase');
793
+ // Invoke onPhaseStart callback
794
+ this.invokeCallback('onPhaseStart', this.callbacks?.onPhaseStart, {
795
+ itemCount,
796
+ metadata: { interval: config.storyInterval },
797
+ phaseName: 'dev',
798
+ startTime,
799
+ });
643
800
  try {
644
801
  const storyDir = await this.pathResolver.getStoryDir();
645
802
  const qaStoryDir = await this.pathResolver.getQaStoryDir();
@@ -701,6 +858,20 @@ Write output to: ${outputPath}`;
701
858
  prompt += '\n';
702
859
  }
703
860
  prompt += '*yolo mode*\n';
861
+ // Generate unique spawn ID and timestamp
862
+ const spawnStartTime = Date.now();
863
+ const spawnId = `dev-${story.fullNumber}-${spawnStartTime}`;
864
+ // Invoke onSpawnStart callback
865
+ this.invokeCallback('onSpawnStart', this.callbacks?.onSpawnStart, {
866
+ agentType: 'dev',
867
+ itemId: story.fullNumber,
868
+ itemTitle: story.title,
869
+ outputPath: storyFilePath,
870
+ phaseName: 'dev',
871
+ prompt,
872
+ spawnId,
873
+ startTime: spawnStartTime,
874
+ });
704
875
  const result = await runAgentWithRetry(this.agentRunner, prompt, {
705
876
  agentType: 'dev',
706
877
  references: config.references,
@@ -710,7 +881,23 @@ Write output to: ${outputPath}`;
710
881
  logger: this.logger,
711
882
  maxRetries: config.maxRetries,
712
883
  });
884
+ const spawnEndTime = Date.now();
885
+ const spawnDuration = spawnEndTime - spawnStartTime;
713
886
  if (result.success) {
887
+ // Invoke onSpawnComplete callback for success
888
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
889
+ agentType: 'dev',
890
+ duration: spawnDuration,
891
+ endTime: spawnEndTime,
892
+ itemId: story.fullNumber,
893
+ itemTitle: story.title,
894
+ output: result.output,
895
+ outputPath: storyFilePath,
896
+ phaseName: 'dev',
897
+ spawnId,
898
+ startTime: spawnStartTime,
899
+ success: true,
900
+ });
714
901
  // Update story status to Done
715
902
  await this.updateStoryStatus(storyFilePath, 'Done');
716
903
  // Move to QA folder (qaFilePath already defined above with correct filename)
@@ -719,6 +906,27 @@ Write output to: ${outputPath}`;
719
906
  this.logger.info({ storyNumber: story.fullNumber }, 'Story development completed successfully');
720
907
  }
721
908
  else {
909
+ // Invoke onSpawnComplete callback for failure
910
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
911
+ agentType: 'dev',
912
+ duration: spawnDuration,
913
+ endTime: spawnEndTime,
914
+ error: result.errors,
915
+ itemId: story.fullNumber,
916
+ itemTitle: story.title,
917
+ outputPath: storyFilePath,
918
+ phaseName: 'dev',
919
+ spawnId,
920
+ startTime: spawnStartTime,
921
+ success: false,
922
+ });
923
+ // Invoke onError callback for spawn failure
924
+ this.invokeErrorCallback(new Error(result.errors), {
925
+ itemId: story.fullNumber,
926
+ phaseName: 'dev',
927
+ recoverable: true,
928
+ spawnId,
929
+ });
722
930
  this.logger.error({
723
931
  error: result.errors,
724
932
  storyNumber: story.fullNumber,
@@ -735,11 +943,22 @@ Write output to: ${outputPath}`;
735
943
  }
736
944
  }
737
945
  const duration = Date.now() - startTime;
946
+ const endTime = Date.now();
738
947
  this.logger.info({
739
948
  duration,
740
949
  failures: failures.length,
741
950
  success: successCount,
742
951
  }, 'Development phase completed');
952
+ // Invoke onPhaseComplete callback
953
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
954
+ duration,
955
+ endTime,
956
+ failureCount: failures.length,
957
+ itemCount,
958
+ phaseName: 'dev',
959
+ startTime,
960
+ successCount,
961
+ });
743
962
  return {
744
963
  duration,
745
964
  failures,
@@ -749,9 +968,28 @@ Write output to: ${outputPath}`;
749
968
  };
750
969
  }
751
970
  catch (error) {
971
+ const duration = Date.now() - startTime;
972
+ const endTime = Date.now();
752
973
  this.logger.error({ error: error.message }, 'Development phase failed');
974
+ // Invoke onError callback
975
+ this.invokeErrorCallback(error, {
976
+ itemId: 'dev-phase',
977
+ phaseName: 'dev',
978
+ recoverable: false,
979
+ });
980
+ // Invoke onPhaseComplete callback even on error
981
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
982
+ duration,
983
+ endTime,
984
+ failureCount: failures.length + 1,
985
+ itemCount,
986
+ metadata: { error: error.message },
987
+ phaseName: 'dev',
988
+ startTime,
989
+ successCount,
990
+ });
753
991
  return {
754
- duration: Date.now() - startTime,
992
+ duration,
755
993
  failures: [
756
994
  ...failures,
757
995
  {
@@ -780,11 +1018,19 @@ Write output to: ${outputPath}`;
780
1018
  const startTime = Date.now();
781
1019
  const failures = [];
782
1020
  let successCount = 0;
1021
+ let itemCount = 0;
783
1022
  this.logger.info({
784
1023
  interval: config.prdInterval,
785
1024
  parallel: config.parallel,
786
1025
  prdFile: prdFilePath,
787
1026
  }, 'Starting epic creation phase');
1027
+ // Invoke onPhaseStart callback (itemCount will be updated once epics are parsed)
1028
+ this.invokeCallback('onPhaseStart', this.callbacks?.onPhaseStart, {
1029
+ itemCount: 0,
1030
+ metadata: { parallel: config.parallel, prdFilePath },
1031
+ phaseName: 'epic',
1032
+ startTime,
1033
+ });
788
1034
  try {
789
1035
  // Read PRD file
790
1036
  let prdContent = await this.fileManager.readFile(prdFilePath);
@@ -819,8 +1065,19 @@ Write output to: ${outputPath}`;
819
1065
  }
820
1066
  }
821
1067
  this.logger.info({ epicCount: epics.length }, 'Epics extracted from PRD');
1068
+ itemCount = epics.length;
822
1069
  if (epics.length === 0) {
823
1070
  this.logger.warn('No epics found in PRD file');
1071
+ // Invoke onPhaseComplete for empty phase
1072
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1073
+ duration: Date.now() - startTime,
1074
+ endTime: Date.now(),
1075
+ failureCount: 0,
1076
+ itemCount: 0,
1077
+ phaseName: 'epic',
1078
+ startTime,
1079
+ successCount: 0,
1080
+ });
824
1081
  return {
825
1082
  duration: Date.now() - startTime,
826
1083
  failures: [],
@@ -837,8 +1094,18 @@ Write output to: ${outputPath}`;
837
1094
  const epicsToCreate = epics.filter((epic) => !properlyPopulatedEpics.includes(this.generateEpicFileName(prefix, epic.number)));
838
1095
  if (epicsToCreate.length === 0) {
839
1096
  this.logger.info('All epics already exist and are properly populated, skipping creation');
1097
+ const duration = Date.now() - startTime;
1098
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1099
+ duration,
1100
+ endTime: Date.now(),
1101
+ failureCount: 0,
1102
+ itemCount: epics.length,
1103
+ phaseName: 'epic',
1104
+ startTime,
1105
+ successCount: epics.length,
1106
+ });
840
1107
  return {
841
- duration: Date.now() - startTime,
1108
+ duration,
842
1109
  failures: [],
843
1110
  phaseName: 'epic',
844
1111
  skipped: false,
@@ -851,99 +1118,140 @@ Write output to: ${outputPath}`;
851
1118
  }, 'Creating/re-populating epic files');
852
1119
  // Create epic files using BatchProcessor and ClaudeAgentRunner
853
1120
  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
1121
+ const spawnStartTime = Date.now();
1122
+ const spawnId = `epic-${epic.number}`;
1123
+ // Fire onSpawnStart so the reporter shows which epic is being generated
1124
+ this.invokeCallback('onSpawnStart', this.callbacks?.onSpawnStart, {
1125
+ agentType: 'architect',
1126
+ itemId: `epic-${epic.number}`,
1127
+ itemTitle: epic.title,
1128
+ phaseName: 'epic',
1129
+ spawnId,
1130
+ startTime: spawnStartTime,
1131
+ });
1132
+ try {
1133
+ // Generate epic file path with prefix
1134
+ const epicFileName = this.generateEpicFileName(prefix, epic.number);
1135
+ const epicFilePath = `${epicDir}/${epicFileName}`;
1136
+ // Check if file already exists and is properly populated
1137
+ const fileExists = await this.fileManager.fileExists(epicFilePath);
1138
+ if (fileExists) {
1139
+ // Check if the file is properly populated (has actual story entries, not placeholder text)
1140
+ const existingContent = await this.fileManager.readFile(epicFilePath);
1141
+ const isScaffolded = existingContent.includes('_[AI Agent will populate');
1142
+ if (!isScaffolded) {
1143
+ // File is properly populated, skip recreation
1144
+ this.logger.info({
1145
+ epicNumber: epic.number,
1146
+ epicTitle: epic.title,
1147
+ filePath: epicFilePath,
1148
+ }, 'Epic file already exists and is properly populated, skipping creation');
1149
+ return epicFilePath;
1150
+ }
1151
+ // File exists but is only scaffolded (incomplete) - will re-populate it
865
1152
  this.logger.info({
866
1153
  epicNumber: epic.number,
867
1154
  epicTitle: epic.title,
868
1155
  filePath: epicFilePath,
869
- }, 'Epic file already exists and is properly populated, skipping creation');
870
- return epicFilePath;
1156
+ }, 'Epic file exists but is only scaffolded (incomplete), re-populating with Claude agent');
871
1157
  }
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({
1158
+ // Step 1: Create scaffolded file with structured sections and populated metadata (if not exists)
1159
+ const scaffoldedContent = this.fileScaffolder.scaffoldEpic({
911
1160
  epicNumber: epic.number,
912
1161
  epicTitle: epic.title,
1162
+ prefix,
1163
+ });
1164
+ if (fileExists) {
1165
+ this.logger.info({
1166
+ epicNumber: epic.number,
1167
+ epicTitle: epic.title,
1168
+ filePath: epicFilePath,
1169
+ }, 'Using existing scaffolded epic file');
1170
+ }
1171
+ else {
1172
+ await this.fileManager.writeFile(epicFilePath, scaffoldedContent);
1173
+ this.logger.info({
1174
+ epicNumber: epic.number,
1175
+ epicTitle: epic.title,
1176
+ filePath: epicFilePath,
1177
+ }, 'Epic scaffolded file created');
1178
+ }
1179
+ // Step 2: Build Claude prompt to populate the scaffolded file
1180
+ const prompt = this.buildEpicPrompt(epic, {
1181
+ cwd: config.cwd,
913
1182
  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);
1183
+ prdPath: prdFilePath,
1184
+ prefix,
1185
+ references: config.references,
1186
+ });
1187
+ // Log prompt if verbose
1188
+ if (config.verbose) {
1189
+ this.logger.info({
1190
+ epicNumber: epic.number,
1191
+ epicTitle: epic.title,
1192
+ outputPath: epicFilePath,
1193
+ prompt,
1194
+ }, 'Claude Prompt (Epic)');
1195
+ }
1196
+ // Step 3: Run Claude agent to populate content sections
1197
+ const result = await runAgentWithRetry(this.agentRunner, prompt, {
1198
+ agentType: 'architect',
1199
+ references: config.references,
1200
+ timeout: config.timeout ?? 2_700_000,
1201
+ }, {
1202
+ backoffMs: config.retryBackoffMs,
1203
+ logger: this.logger,
1204
+ maxRetries: config.maxRetries,
1205
+ });
1206
+ // Log output if verbose
1207
+ if (config.verbose) {
1208
+ this.logger.info({
1209
+ duration: result.duration,
1210
+ epicNumber: epic.number,
1211
+ errors: result.errors,
1212
+ output: result.output,
1213
+ outputLength: result.output.length,
1214
+ success: result.success,
1215
+ }, 'Claude Response (Epic)');
1216
+ }
1217
+ if (!result.success) {
1218
+ throw new Error(result.errors);
1219
+ }
1220
+ // Step 4: Verify file was updated by Claude
1221
+ const updatedContent = await this.fileManager.readFile(epicFilePath);
1222
+ if (updatedContent === scaffoldedContent) {
1223
+ throw new Error(`Claude did not update the epic file at ${epicFilePath}`);
1224
+ }
1225
+ // Fire onSpawnComplete for success
1226
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
1227
+ agentType: 'architect',
1228
+ duration: Date.now() - spawnStartTime,
1229
+ endTime: Date.now(),
1230
+ itemId: `epic-${epic.number}`,
1231
+ itemTitle: epic.title,
1232
+ phaseName: 'epic',
1233
+ spawnId,
1234
+ startTime: spawnStartTime,
1235
+ success: true,
1236
+ });
1237
+ return epicFilePath;
940
1238
  }
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}`);
1239
+ catch (spawnError) {
1240
+ // Fire onSpawnComplete for failure
1241
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
1242
+ agentType: 'architect',
1243
+ duration: Date.now() - spawnStartTime,
1244
+ endTime: Date.now(),
1245
+ error: spawnError.message,
1246
+ itemId: `epic-${epic.number}`,
1247
+ itemTitle: epic.title,
1248
+ phaseName: 'epic',
1249
+ spawnId,
1250
+ startTime: spawnStartTime,
1251
+ success: false,
1252
+ });
1253
+ throw spawnError;
945
1254
  }
946
- return epicFilePath;
947
1255
  }, (info) => {
948
1256
  this.logger.info({
949
1257
  completedItems: info.completedItems,
@@ -967,11 +1275,23 @@ Write output to: ${outputPath}`;
967
1275
  // Add properly populated epics to success count
968
1276
  successCount += properlyPopulatedEpics.length;
969
1277
  const duration = Date.now() - startTime;
1278
+ const endTime = Date.now();
970
1279
  this.logger.info({
971
1280
  duration,
972
1281
  failures: failures.length,
973
1282
  success: successCount,
974
1283
  }, 'Epic creation phase completed');
1284
+ // Invoke onPhaseComplete callback
1285
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1286
+ duration,
1287
+ endTime,
1288
+ failureCount: failures.length,
1289
+ itemCount,
1290
+ metadata: { parallel: config.parallel, prdFilePath },
1291
+ phaseName: 'epic',
1292
+ startTime,
1293
+ successCount,
1294
+ });
975
1295
  return {
976
1296
  duration,
977
1297
  failures,
@@ -981,9 +1301,28 @@ Write output to: ${outputPath}`;
981
1301
  };
982
1302
  }
983
1303
  catch (error) {
1304
+ const duration = Date.now() - startTime;
1305
+ const endTime = Date.now();
984
1306
  this.logger.error({ error: error.message }, 'Epic phase failed');
1307
+ // Invoke onError callback
1308
+ this.invokeErrorCallback(error, {
1309
+ itemId: 'epic-phase',
1310
+ phaseName: 'epic',
1311
+ recoverable: false,
1312
+ });
1313
+ // Invoke onPhaseComplete callback even on error
1314
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1315
+ duration,
1316
+ endTime,
1317
+ failureCount: failures.length + 1,
1318
+ itemCount,
1319
+ metadata: { error: error.message },
1320
+ phaseName: 'epic',
1321
+ startTime,
1322
+ successCount,
1323
+ });
985
1324
  return {
986
- duration: Date.now() - startTime,
1325
+ duration,
987
1326
  failures: [
988
1327
  ...failures,
989
1328
  {
@@ -1079,6 +1418,15 @@ Write output to: ${outputPath}`;
1079
1418
  success: totalSuccess,
1080
1419
  workerCount,
1081
1420
  }, 'Pipelined development phase completed');
1421
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1422
+ duration,
1423
+ endTime: Date.now(),
1424
+ failureCount: allFailures.length,
1425
+ itemCount: totalSuccess + allFailures.length,
1426
+ phaseName: 'dev',
1427
+ startTime,
1428
+ successCount: totalSuccess,
1429
+ });
1082
1430
  return {
1083
1431
  duration,
1084
1432
  failures: allFailures,
@@ -1089,6 +1437,15 @@ Write output to: ${outputPath}`;
1089
1437
  }
1090
1438
  catch (error) {
1091
1439
  this.logger.error({ error: error.message }, 'Pipelined development phase failed');
1440
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1441
+ duration: Date.now() - startTime,
1442
+ endTime: Date.now(),
1443
+ failureCount: 1,
1444
+ itemCount: 1,
1445
+ phaseName: 'dev',
1446
+ startTime,
1447
+ successCount: 0,
1448
+ });
1092
1449
  return {
1093
1450
  duration: Date.now() - startTime,
1094
1451
  failures: [
@@ -1146,9 +1503,17 @@ Write output to: ${outputPath}`;
1146
1503
  const startTime = Date.now();
1147
1504
  const failures = [];
1148
1505
  let successCount = 0;
1506
+ let itemCount = 0;
1149
1507
  this.logger.info({
1150
1508
  qaRetries: config.qaRetries ?? 2,
1151
1509
  }, 'Starting QA phase');
1510
+ // Invoke onPhaseStart callback
1511
+ this.invokeCallback('onPhaseStart', this.callbacks?.onPhaseStart, {
1512
+ itemCount: 0,
1513
+ metadata: { qaRetries: config.qaRetries ?? 2 },
1514
+ phaseName: 'qa',
1515
+ startTime,
1516
+ });
1152
1517
  try {
1153
1518
  // Get QA story directory
1154
1519
  const qaStoryDir = await this.pathResolver.getQaStoryDir();
@@ -1158,6 +1523,17 @@ Write output to: ${outputPath}`;
1158
1523
  const storyFiles = await this.fileManager.listFiles(qaStoryDir, storyPattern);
1159
1524
  if (storyFiles.length === 0) {
1160
1525
  this.logger.info('No stories found in QA folder, skipping QA phase');
1526
+ // Invoke onPhaseComplete for skipped phase
1527
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1528
+ duration: Date.now() - startTime,
1529
+ endTime: Date.now(),
1530
+ failureCount: 0,
1531
+ itemCount: 0,
1532
+ phaseName: 'qa',
1533
+ skipped: true,
1534
+ startTime,
1535
+ successCount: 0,
1536
+ });
1161
1537
  return {
1162
1538
  duration: Date.now() - startTime,
1163
1539
  failures: [],
@@ -1166,6 +1542,7 @@ Write output to: ${outputPath}`;
1166
1542
  success: 0,
1167
1543
  };
1168
1544
  }
1545
+ itemCount = storyFiles.length;
1169
1546
  this.logger.info({ storyCount: storyFiles.length }, 'Found stories for QA phase');
1170
1547
  // Dynamically import QA command to avoid circular dependencies
1171
1548
  const { default: StoriesQaCommand } = await import('../../commands/stories/qa.js');
@@ -1190,8 +1567,22 @@ Write output to: ${outputPath}`;
1190
1567
  qaFlags.push(`--reference=${ref}`);
1191
1568
  }
1192
1569
  }
1193
- // Run QA command
1194
- await StoriesQaCommand.run([...qaArgs, ...qaFlags]);
1570
+ // Suppress QA command's pino logger in non-verbose mode
1571
+ const prevLogLevel = process.env.LOG_LEVEL;
1572
+ if (!config.verbose) {
1573
+ process.env.LOG_LEVEL = 'silent';
1574
+ }
1575
+ try {
1576
+ await StoriesQaCommand.run([...qaArgs, ...qaFlags]);
1577
+ }
1578
+ finally {
1579
+ if (prevLogLevel === undefined) {
1580
+ delete process.env.LOG_LEVEL;
1581
+ }
1582
+ else {
1583
+ process.env.LOG_LEVEL = prevLogLevel;
1584
+ }
1585
+ }
1195
1586
  successCount++;
1196
1587
  this.logger.info({ storyPath }, 'QA workflow completed successfully for story');
1197
1588
  }
@@ -1205,11 +1596,22 @@ Write output to: ${outputPath}`;
1205
1596
  }
1206
1597
  }
1207
1598
  const duration = Date.now() - startTime;
1599
+ const endTime = Date.now();
1208
1600
  this.logger.info({
1209
1601
  duration,
1210
1602
  failures: failures.length,
1211
1603
  success: successCount,
1212
1604
  }, 'QA phase completed');
1605
+ // Invoke onPhaseComplete callback
1606
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1607
+ duration,
1608
+ endTime,
1609
+ failureCount: failures.length,
1610
+ itemCount,
1611
+ phaseName: 'qa',
1612
+ startTime,
1613
+ successCount,
1614
+ });
1213
1615
  return {
1214
1616
  duration,
1215
1617
  failures,
@@ -1219,9 +1621,28 @@ Write output to: ${outputPath}`;
1219
1621
  };
1220
1622
  }
1221
1623
  catch (error) {
1624
+ const duration = Date.now() - startTime;
1625
+ const endTime = Date.now();
1222
1626
  this.logger.error({ error: error.message }, 'QA phase failed');
1627
+ // Invoke onError callback
1628
+ this.invokeErrorCallback(error, {
1629
+ itemId: 'qa-phase',
1630
+ phaseName: 'qa',
1631
+ recoverable: false,
1632
+ });
1633
+ // Invoke onPhaseComplete callback even on error
1634
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1635
+ duration,
1636
+ endTime,
1637
+ failureCount: failures.length + 1,
1638
+ itemCount,
1639
+ metadata: { error: error.message },
1640
+ phaseName: 'qa',
1641
+ startTime,
1642
+ successCount,
1643
+ });
1223
1644
  return {
1224
- duration: Date.now() - startTime,
1645
+ duration,
1225
1646
  failures: [
1226
1647
  ...failures,
1227
1648
  {
@@ -1355,16 +1776,24 @@ Write output to: ${outputPath}`;
1355
1776
  const startTime = Date.now();
1356
1777
  const failures = [];
1357
1778
  let successCount = 0;
1779
+ let itemCount = 0;
1358
1780
  // In pipeline mode, stories must be created sequentially (one at a time)
1359
1781
  // to ensure they are queued in the correct order for development
1360
1782
  const isPipelineMode = Boolean(onStoryComplete);
1361
- const effectiveParallel = isPipelineMode ? 1 : config.parallel;
1783
+ const effectiveParallel = config.parallel;
1362
1784
  this.logger.info({
1363
1785
  epicCount: epics.length,
1364
1786
  interval: config.epicInterval,
1365
1787
  mode: isPipelineMode ? 'sequential (pipeline)' : 'parallel',
1366
1788
  parallel: effectiveParallel,
1367
1789
  }, 'Starting story creation phase');
1790
+ // Invoke onPhaseStart callback
1791
+ this.invokeCallback('onPhaseStart', this.callbacks?.onPhaseStart, {
1792
+ itemCount: 0,
1793
+ metadata: { epicCount: epics.length, parallel: effectiveParallel, pipelineMode: isPipelineMode },
1794
+ phaseName: 'story',
1795
+ startTime,
1796
+ });
1368
1797
  try {
1369
1798
  const allStories = [];
1370
1799
  // Get epic directory
@@ -1397,8 +1826,19 @@ Write output to: ${outputPath}`;
1397
1826
  // Flatten the array of story arrays
1398
1827
  allStories.push(...epicStories.flat());
1399
1828
  this.logger.info({ storyCount: allStories.length }, 'Stories extracted from epics');
1829
+ itemCount = allStories.length;
1400
1830
  if (allStories.length === 0) {
1401
1831
  this.logger.warn('No stories found in epic files');
1832
+ // Invoke onPhaseComplete for empty phase
1833
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1834
+ duration: Date.now() - startTime,
1835
+ endTime: Date.now(),
1836
+ failureCount: 0,
1837
+ itemCount: 0,
1838
+ phaseName: 'story',
1839
+ startTime,
1840
+ successCount: 0,
1841
+ });
1402
1842
  return {
1403
1843
  duration: Date.now() - startTime,
1404
1844
  failures: [],
@@ -1420,8 +1860,18 @@ Write output to: ${outputPath}`;
1420
1860
  if (onStoryComplete) {
1421
1861
  await this.enqueueExistingStoriesSequentially(allStories, storyDir, prefix, onStoryComplete);
1422
1862
  }
1863
+ const duration = Date.now() - startTime;
1864
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
1865
+ duration,
1866
+ endTime: Date.now(),
1867
+ failureCount: 0,
1868
+ itemCount: allStories.length,
1869
+ phaseName: 'story',
1870
+ startTime,
1871
+ successCount: allStories.length,
1872
+ });
1423
1873
  return {
1424
- duration: Date.now() - startTime,
1874
+ duration,
1425
1875
  failures: [],
1426
1876
  phaseName: 'story',
1427
1877
  skipped: false,
@@ -1441,107 +1891,159 @@ Write output to: ${outputPath}`;
1441
1891
  : this.batchProcessor;
1442
1892
  // Create story files using BatchProcessor and ClaudeAgentRunner
1443
1893
  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) {
1894
+ const spawnStartTime = Date.now();
1895
+ const spawnId = `story-${story.fullNumber}`;
1896
+ // Fire onSpawnStart so the reporter shows which story is being created
1897
+ this.invokeCallback('onSpawnStart', this.callbacks?.onSpawnStart, {
1898
+ agentType: 'sm',
1899
+ itemId: `story-${story.fullNumber}`,
1900
+ itemTitle: story.title,
1901
+ phaseName: 'story',
1902
+ spawnId,
1903
+ startTime: spawnStartTime,
1904
+ });
1905
+ try {
1906
+ // Generate story file path with prefix
1907
+ const storyFileName = this.generateStoryFileName(prefix, story.fullNumber);
1908
+ const storyFilePath = `${storyDir}/${storyFileName}`;
1909
+ // Check if file already exists (might have been created by parallel process)
1910
+ const fileExists = await this.fileManager.fileExists(storyFilePath);
1911
+ if (fileExists) {
1912
+ this.logger.info({
1913
+ filePath: storyFilePath,
1914
+ storyNumber: story.fullNumber,
1915
+ storyTitle: story.title,
1916
+ }, 'Story file already exists, skipping creation');
1917
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
1918
+ agentType: 'sm',
1919
+ duration: Date.now() - spawnStartTime,
1920
+ endTime: Date.now(),
1921
+ itemId: `story-${story.fullNumber}`,
1922
+ itemTitle: story.title,
1923
+ phaseName: 'story',
1924
+ spawnId,
1925
+ startTime: spawnStartTime,
1926
+ success: true,
1927
+ });
1928
+ return storyFilePath;
1929
+ }
1930
+ // Step 1: Create scaffolded file with structured sections and populated metadata
1931
+ const scaffoldedContent = this.fileScaffolder.scaffoldStory({
1932
+ epicNumber: story.epicNumber,
1933
+ storyNumber: story.number,
1934
+ storyTitle: story.title,
1935
+ });
1936
+ await this.fileManager.writeFile(storyFilePath, scaffoldedContent);
1450
1937
  this.logger.info({
1451
1938
  filePath: storyFilePath,
1452
1939
  storyNumber: story.fullNumber,
1453
1940
  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({
1941
+ }, 'Story scaffolded file created');
1942
+ // Step 2: Generate epic file path for this story
1943
+ const epicFileName = this.generateEpicFileName(prefix, story.epicNumber);
1944
+ const epicFilePath = `${epicDir}/${epicFileName}`;
1945
+ // Step 3: Build Claude prompt to populate the scaffolded file
1946
+ const prompt = this.buildStoryPrompt(story, {
1947
+ cwd: config.cwd,
1948
+ epicPath: epicFilePath,
1483
1949
  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,
1950
+ prefix,
1951
+ references: config.references,
1952
+ });
1953
+ // Log prompt if verbose
1954
+ if (config.verbose) {
1955
+ this.logger.info({
1956
+ outputPath: storyFilePath,
1957
+ prompt,
1533
1958
  storyNumber: story.fullNumber,
1534
- }, 'Invoking story completion callback');
1535
- await onStoryComplete(metadata);
1959
+ storyTitle: story.title,
1960
+ }, 'Claude Prompt (Story)');
1536
1961
  }
1537
- catch (error) {
1538
- this.logger.error({
1539
- error: error.message,
1962
+ // Step 4: Run Claude agent to populate content sections
1963
+ const result = await runAgentWithRetry(this.agentRunner, prompt, {
1964
+ agentType: 'sm',
1965
+ references: config.references,
1966
+ timeout: config.timeout ?? 2_700_000,
1967
+ }, {
1968
+ backoffMs: config.retryBackoffMs,
1969
+ logger: this.logger,
1970
+ maxRetries: config.maxRetries,
1971
+ });
1972
+ // Log output if verbose
1973
+ if (config.verbose) {
1974
+ this.logger.info({
1975
+ duration: result.duration,
1976
+ errors: result.errors,
1977
+ output: result.output,
1978
+ outputLength: result.output.length,
1540
1979
  storyNumber: story.fullNumber,
1541
- }, 'Story completion callback failed, continuing story creation');
1980
+ success: result.success,
1981
+ }, 'Claude Response (Story)');
1982
+ }
1983
+ if (!result.success) {
1984
+ throw new Error(result.errors);
1542
1985
  }
1986
+ // Step 5: Verify file was updated by Claude
1987
+ const updatedContent = await this.fileManager.readFile(storyFilePath);
1988
+ if (updatedContent === scaffoldedContent) {
1989
+ throw new Error(`Claude did not update the story file at ${storyFilePath}`);
1990
+ }
1991
+ // Invoke callback after successful story creation
1992
+ if (onStoryComplete) {
1993
+ try {
1994
+ const metadata = {
1995
+ epicNumber: story.epicNumber,
1996
+ filePath: storyFilePath,
1997
+ id: story.fullNumber,
1998
+ number: story.fullNumber,
1999
+ status: 'Draft',
2000
+ storyNumber: story.number,
2001
+ title: story.title,
2002
+ type: 'epic-based',
2003
+ };
2004
+ this.logger.debug({
2005
+ metadata,
2006
+ storyNumber: story.fullNumber,
2007
+ }, 'Invoking story completion callback');
2008
+ await onStoryComplete(metadata);
2009
+ }
2010
+ catch (error) {
2011
+ this.logger.error({
2012
+ error: error.message,
2013
+ storyNumber: story.fullNumber,
2014
+ }, 'Story completion callback failed, continuing story creation');
2015
+ }
2016
+ }
2017
+ // Fire onSpawnComplete for success
2018
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
2019
+ agentType: 'sm',
2020
+ duration: Date.now() - spawnStartTime,
2021
+ endTime: Date.now(),
2022
+ itemId: `story-${story.fullNumber}`,
2023
+ itemTitle: story.title,
2024
+ phaseName: 'story',
2025
+ spawnId,
2026
+ startTime: spawnStartTime,
2027
+ success: true,
2028
+ });
2029
+ return storyFilePath;
2030
+ }
2031
+ catch (spawnError) {
2032
+ // Fire onSpawnComplete for failure
2033
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
2034
+ agentType: 'sm',
2035
+ duration: Date.now() - spawnStartTime,
2036
+ endTime: Date.now(),
2037
+ error: spawnError.message,
2038
+ itemId: `story-${story.fullNumber}`,
2039
+ itemTitle: story.title,
2040
+ phaseName: 'story',
2041
+ spawnId,
2042
+ startTime: spawnStartTime,
2043
+ success: false,
2044
+ });
2045
+ throw spawnError;
1543
2046
  }
1544
- return storyFilePath;
1545
2047
  }, (info) => {
1546
2048
  this.logger.info({
1547
2049
  completedItems: info.completedItems,
@@ -1565,11 +2067,22 @@ Write output to: ${outputPath}`;
1565
2067
  // Add existing stories to success count
1566
2068
  successCount += existingStories.length;
1567
2069
  const duration = Date.now() - startTime;
2070
+ const endTime = Date.now();
1568
2071
  this.logger.info({
1569
2072
  duration,
1570
2073
  failures: failures.length,
1571
2074
  success: successCount,
1572
2075
  }, 'Story creation phase completed');
2076
+ // Invoke onPhaseComplete callback
2077
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
2078
+ duration,
2079
+ endTime,
2080
+ failureCount: failures.length,
2081
+ itemCount,
2082
+ phaseName: 'story',
2083
+ startTime,
2084
+ successCount,
2085
+ });
1573
2086
  return {
1574
2087
  duration,
1575
2088
  failures,
@@ -1579,9 +2092,28 @@ Write output to: ${outputPath}`;
1579
2092
  };
1580
2093
  }
1581
2094
  catch (error) {
2095
+ const duration = Date.now() - startTime;
2096
+ const endTime = Date.now();
1582
2097
  this.logger.error({ error: error.message }, 'Story phase failed');
2098
+ // Invoke onError callback
2099
+ this.invokeErrorCallback(error, {
2100
+ itemId: 'story-phase',
2101
+ phaseName: 'story',
2102
+ recoverable: false,
2103
+ });
2104
+ // Invoke onPhaseComplete callback even on error
2105
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
2106
+ duration,
2107
+ endTime,
2108
+ failureCount: failures.length + 1,
2109
+ itemCount,
2110
+ metadata: { error: error.message },
2111
+ phaseName: 'story',
2112
+ startTime,
2113
+ successCount,
2114
+ });
1583
2115
  return {
1584
- duration: Date.now() - startTime,
2116
+ duration,
1585
2117
  failures: [
1586
2118
  ...failures,
1587
2119
  {
@@ -1613,7 +2145,7 @@ Write output to: ${outputPath}`;
1613
2145
  // In pipeline mode, stories must be created sequentially (one at a time)
1614
2146
  // to ensure they are queued in the correct order for development
1615
2147
  const isPipelineMode = Boolean(onStoryComplete);
1616
- const effectiveParallel = isPipelineMode ? 1 : config.parallel;
2148
+ const effectiveParallel = config.parallel;
1617
2149
  this.logger.info({
1618
2150
  epicFilePath,
1619
2151
  mode: isPipelineMode ? 'sequential (pipeline)' : 'parallel',
@@ -1652,8 +2184,18 @@ Write output to: ${outputPath}`;
1652
2184
  if (onStoryComplete) {
1653
2185
  await this.enqueueExistingStoriesSequentially(allStories, storyDir, prefix, onStoryComplete);
1654
2186
  }
2187
+ const duration = Date.now() - startTime;
2188
+ this.invokeCallback('onPhaseComplete', this.callbacks?.onPhaseComplete, {
2189
+ duration,
2190
+ endTime: Date.now(),
2191
+ failureCount: 0,
2192
+ itemCount: allStories.length,
2193
+ phaseName: 'story',
2194
+ startTime,
2195
+ successCount: allStories.length,
2196
+ });
1655
2197
  return {
1656
- duration: Date.now() - startTime,
2198
+ duration,
1657
2199
  failures: [],
1658
2200
  phaseName: 'story',
1659
2201
  skipped: false,
@@ -1673,106 +2215,158 @@ Write output to: ${outputPath}`;
1673
2215
  : this.batchProcessor;
1674
2216
  // Create story files using BatchProcessor and ClaudeAgentRunner
1675
2217
  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) {
2218
+ const spawnStartTime = Date.now();
2219
+ const spawnId = `story-${story.fullNumber}`;
2220
+ // Fire onSpawnStart so the reporter shows which story is being created
2221
+ this.invokeCallback('onSpawnStart', this.callbacks?.onSpawnStart, {
2222
+ agentType: 'sm',
2223
+ itemId: `story-${story.fullNumber}`,
2224
+ itemTitle: story.title,
2225
+ phaseName: 'story',
2226
+ spawnId,
2227
+ startTime: spawnStartTime,
2228
+ });
2229
+ try {
2230
+ // Generate story file path with prefix
2231
+ const storyFileName = this.generateStoryFileName(prefix, story.fullNumber);
2232
+ const storyFilePath = `${storyDir}/${storyFileName}`;
2233
+ // Check if file already exists (might have been created by parallel process)
2234
+ const fileExists = await this.fileManager.fileExists(storyFilePath);
2235
+ if (fileExists) {
2236
+ this.logger.info({
2237
+ filePath: storyFilePath,
2238
+ storyNumber: story.fullNumber,
2239
+ storyTitle: story.title,
2240
+ }, 'Story file already exists, skipping creation');
2241
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
2242
+ agentType: 'sm',
2243
+ duration: Date.now() - spawnStartTime,
2244
+ endTime: Date.now(),
2245
+ itemId: `story-${story.fullNumber}`,
2246
+ itemTitle: story.title,
2247
+ phaseName: 'story',
2248
+ spawnId,
2249
+ startTime: spawnStartTime,
2250
+ success: true,
2251
+ });
2252
+ return storyFilePath;
2253
+ }
2254
+ // Step 1: Create scaffolded file with structured sections and populated metadata
2255
+ const scaffoldedContent = this.fileScaffolder.scaffoldStory({
2256
+ epicNumber: story.epicNumber,
2257
+ storyNumber: story.number,
2258
+ storyTitle: story.title,
2259
+ });
2260
+ await this.fileManager.writeFile(storyFilePath, scaffoldedContent);
1682
2261
  this.logger.info({
1683
2262
  filePath: storyFilePath,
1684
2263
  storyNumber: story.fullNumber,
1685
2264
  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({
2265
+ }, 'Story scaffolded file created');
2266
+ // Step 2: Use the epic file path that was passed to this method
2267
+ // Step 3: Build Claude prompt to populate the scaffolded file
2268
+ const prompt = this.buildStoryPrompt(story, {
2269
+ cwd: config.cwd,
1713
2270
  epicPath: epicFilePath,
1714
2271
  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,
2272
+ prefix,
2273
+ references: config.references,
2274
+ });
2275
+ // Log prompt if verbose
2276
+ if (config.verbose) {
2277
+ this.logger.info({
2278
+ epicPath: epicFilePath,
2279
+ outputPath: storyFilePath,
2280
+ prompt,
1764
2281
  storyNumber: story.fullNumber,
1765
- }, 'Invoking story completion callback');
1766
- await onStoryComplete(metadata);
2282
+ storyTitle: story.title,
2283
+ }, 'Claude Prompt (Story)');
1767
2284
  }
1768
- catch (error) {
1769
- this.logger.error({
1770
- error: error.message,
2285
+ // Step 4: Run Claude agent to populate content sections
2286
+ const result = await runAgentWithRetry(this.agentRunner, prompt, {
2287
+ agentType: 'sm',
2288
+ references: config.references,
2289
+ timeout: config.timeout ?? 2_700_000,
2290
+ }, {
2291
+ backoffMs: config.retryBackoffMs,
2292
+ logger: this.logger,
2293
+ maxRetries: config.maxRetries,
2294
+ });
2295
+ // Log output if verbose
2296
+ if (config.verbose) {
2297
+ this.logger.info({
2298
+ duration: result.duration,
2299
+ errors: result.errors,
2300
+ output: result.output,
2301
+ outputLength: result.output.length,
1771
2302
  storyNumber: story.fullNumber,
1772
- }, 'Story completion callback failed, continuing story creation');
2303
+ success: result.success,
2304
+ }, 'Claude Response (Story)');
2305
+ }
2306
+ if (!result.success) {
2307
+ throw new Error(result.errors);
2308
+ }
2309
+ // Step 5: Verify file was updated by Claude
2310
+ const updatedContent = await this.fileManager.readFile(storyFilePath);
2311
+ if (updatedContent === scaffoldedContent) {
2312
+ throw new Error(`Claude did not update the story file at ${storyFilePath}`);
2313
+ }
2314
+ // Invoke callback after successful story creation
2315
+ if (onStoryComplete) {
2316
+ try {
2317
+ const metadata = {
2318
+ epicNumber: story.epicNumber,
2319
+ filePath: storyFilePath,
2320
+ id: story.fullNumber,
2321
+ number: story.fullNumber,
2322
+ status: 'Draft',
2323
+ storyNumber: story.number,
2324
+ title: story.title,
2325
+ type: 'epic-based',
2326
+ };
2327
+ this.logger.debug({
2328
+ metadata,
2329
+ storyNumber: story.fullNumber,
2330
+ }, 'Invoking story completion callback');
2331
+ await onStoryComplete(metadata);
2332
+ }
2333
+ catch (error) {
2334
+ this.logger.error({
2335
+ error: error.message,
2336
+ storyNumber: story.fullNumber,
2337
+ }, 'Story completion callback failed, continuing story creation');
2338
+ }
1773
2339
  }
2340
+ // Fire onSpawnComplete for success
2341
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
2342
+ agentType: 'sm',
2343
+ duration: Date.now() - spawnStartTime,
2344
+ endTime: Date.now(),
2345
+ itemId: `story-${story.fullNumber}`,
2346
+ itemTitle: story.title,
2347
+ phaseName: 'story',
2348
+ spawnId,
2349
+ startTime: spawnStartTime,
2350
+ success: true,
2351
+ });
2352
+ return storyFilePath;
2353
+ }
2354
+ catch (spawnError) {
2355
+ // Fire onSpawnComplete for failure
2356
+ this.invokeCallback('onSpawnComplete', this.callbacks?.onSpawnComplete, {
2357
+ agentType: 'sm',
2358
+ duration: Date.now() - spawnStartTime,
2359
+ endTime: Date.now(),
2360
+ error: spawnError.message,
2361
+ itemId: `story-${story.fullNumber}`,
2362
+ itemTitle: story.title,
2363
+ phaseName: 'story',
2364
+ spawnId,
2365
+ startTime: spawnStartTime,
2366
+ success: false,
2367
+ });
2368
+ throw spawnError;
1774
2369
  }
1775
- return storyFilePath;
1776
2370
  }, (info) => {
1777
2371
  this.logger.info({
1778
2372
  completedItems: info.completedItems,