@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.
@@ -1,4 +1,5 @@
1
1
  import { Args, Command, Flags } from '@oclif/core';
2
+ import { basename } from 'node:path';
2
3
  import { createAgentRunner, isProviderSupported } from '../services/agents/agent-runner-factory.js';
3
4
  import { FileManager } from '../services/file-system/file-manager.js';
4
5
  import { PathResolver } from '../services/file-system/path-resolver.js';
@@ -8,6 +9,8 @@ import { StoryTypeDetector } from '../services/orchestration/story-type-detector
8
9
  import { WorkflowOrchestrator } from '../services/orchestration/workflow-orchestrator.js';
9
10
  import { EpicParser } from '../services/parsers/epic-parser.js';
10
11
  import { PrdParser } from '../services/parsers/prd-parser.js';
12
+ import { WorkflowSessionScaffolder } from '../services/scaffolding/workflow-session-scaffolder.js';
13
+ import { WorkflowReporter } from '../services/WorkflowReporter.js';
11
14
  import * as colors from '../utils/colors.js';
12
15
  import { formatBox, formatTable } from '../utils/formatters.js';
13
16
  import { createLogger } from '../utils/logger.js';
@@ -77,7 +80,10 @@ export default class Workflow extends Command {
77
80
  }),
78
81
  prefix: Flags.string({
79
82
  default: '',
80
- description: 'Filename prefix for generated files',
83
+ description: 'Filename prefix for generated files and session directory name',
84
+ }),
85
+ 'session-prefix': Flags.string({
86
+ description: 'Session directory prefix (default: derived from input filename, e.g., PRD-feature.md → feature)',
81
87
  }),
82
88
  provider: Flags.string({
83
89
  default: 'claude',
@@ -133,12 +139,15 @@ export default class Workflow extends Command {
133
139
  verbose: Flags.boolean({
134
140
  char: 'v',
135
141
  default: false,
136
- description: 'Detailed output mode',
142
+ description: 'Show detailed output with spawn output streamed inline. In verbose mode, each spawn\'s output is displayed in real-time with visual delimiters. In non-TTY environments (e.g., CI/CD pipelines), output automatically falls back to plain-text format without ANSI escape codes.',
137
143
  }),
138
144
  };
139
145
  cancelled = false;
140
146
  logger;
141
147
  orchestrator;
148
+ reporter;
149
+ scaffolder;
150
+ sessionDir = null;
142
151
  /**
143
152
  * Main command execution
144
153
  *
@@ -147,14 +156,24 @@ export default class Workflow extends Command {
147
156
  async run() {
148
157
  const { args, flags } = await this.parse(Workflow);
149
158
  try {
159
+ // Early validation: check input file exists before initializing anything
160
+ const inputPath = args.input;
161
+ if (!inputPath.includes('*') && !inputPath.includes('?')) {
162
+ const { existsSync } = await import('node:fs');
163
+ if (!existsSync(inputPath)) {
164
+ this.error(`File not found: ${inputPath}`, { exit: 1 });
165
+ }
166
+ }
150
167
  // Validate provider
151
168
  if (!isProviderSupported(flags.provider)) {
152
169
  this.error(`Unsupported provider: ${flags.provider}. Use 'claude', 'gemini', or 'opencode'.`, { exit: 1 });
153
170
  }
154
171
  // Initialize services with parallel concurrency and provider
155
- await this.initializeServices(flags.parallel, flags.provider);
172
+ await this.initializeServices(flags.parallel, flags.provider, flags.verbose);
156
173
  // Register signal handlers
157
174
  this.registerSignalHandlers();
175
+ // Initialize session scaffolder and create session structure
176
+ await this.initializeSession(args.input, flags['session-prefix']);
158
177
  // Show dry-run banner if applicable
159
178
  if (flags['dry-run']) {
160
179
  this.displayDryRunBanner();
@@ -193,28 +212,289 @@ export default class Workflow extends Command {
193
212
  // Execute workflow
194
213
  this.log(colors.info('\nStarting workflow orchestration...\n'));
195
214
  const result = await this.orchestrator.execute(config);
215
+ // Wait for reporter listr2 tasks to complete and clean up
216
+ await this.reporter?.waitForCompletion();
217
+ // Write session report on completion (includes cancellation case)
218
+ await this.finalizeSession(result, config);
219
+ // Build dashboard summary from workflow result
220
+ const dashboardSummary = this.buildDashboardSummary(result);
221
+ // Display final dashboard using reporter (AC: #4)
222
+ this.reporter?.displayFinalDashboard(dashboardSummary);
223
+ this.reporter?.dispose();
224
+ // Display failure details if any
225
+ if (result.totalFailures > 0) {
226
+ this.displayFailureDetails(result);
227
+ }
228
+ // Dry run reminder
229
+ if (config.dryRun) {
230
+ this.log('\n' + colors.warning('No files were created (dry-run mode)'));
231
+ }
196
232
  // Check for cancellation
197
233
  if (this.cancelled) {
198
234
  this.log('\n' + colors.warning('Workflow cancelled by user'));
199
- this.displayFinalSummary(result, config);
200
235
  process.exit(130); // Standard interrupted exit code
201
236
  }
202
- // Display final summary
203
- this.displayFinalSummary(result, config);
204
237
  // Exit with appropriate code
205
238
  if (!result.overallSuccess) {
206
239
  this.error('Workflow completed with failures', { exit: 1 });
207
240
  }
208
- this.log('\n' + colors.success('Workflow completed successfully!'));
241
+ this.log('\n' + colors.success('Workflow completed successfully!'));
209
242
  }
210
243
  catch (error) {
211
- this.logger.error({ error: error.message }, 'Workflow command failed');
244
+ // Clean up reporter on error
245
+ this.reporter?.dispose();
246
+ this.logger?.error({ error: error.message }, 'Workflow command failed');
212
247
  // Display user-friendly error
213
248
  this.log('\n' + colors.error('✗ Workflow failed:'));
214
249
  this.log(colors.error(` ${error.message}`));
215
250
  this.error('Workflow execution failed', { exit: 1 });
216
251
  }
217
252
  }
253
+ /**
254
+ * Collect all errors from workflow result into a flat list
255
+ *
256
+ * @param result - Workflow execution result
257
+ * @returns Array of error entries with phase context
258
+ * @private
259
+ */
260
+ collectErrors(result) {
261
+ const errors = [];
262
+ const now = new Date().toISOString();
263
+ if (result.epicPhase?.failures) {
264
+ for (const failure of result.epicPhase.failures) {
265
+ errors.push({
266
+ error: failure.error,
267
+ identifier: failure.identifier,
268
+ phase: 'epic',
269
+ timestamp: now,
270
+ });
271
+ }
272
+ }
273
+ if (result.storyPhase?.failures) {
274
+ for (const failure of result.storyPhase.failures) {
275
+ errors.push({
276
+ error: failure.error,
277
+ identifier: failure.identifier,
278
+ phase: 'story',
279
+ timestamp: now,
280
+ });
281
+ }
282
+ }
283
+ if (result.devPhase?.failures) {
284
+ for (const failure of result.devPhase.failures) {
285
+ errors.push({
286
+ error: failure.error,
287
+ identifier: failure.identifier,
288
+ phase: 'dev',
289
+ timestamp: now,
290
+ });
291
+ }
292
+ }
293
+ if (result.qaPhase?.failures) {
294
+ for (const failure of result.qaPhase.failures) {
295
+ errors.push({
296
+ error: failure.error,
297
+ identifier: failure.identifier,
298
+ phase: 'qa',
299
+ timestamp: now,
300
+ });
301
+ }
302
+ }
303
+ return errors;
304
+ }
305
+ /**
306
+ * Build dashboard summary from workflow result for final display
307
+ *
308
+ * @param result - Workflow execution result
309
+ * @returns DashboardSummary for the reporter's final dashboard
310
+ * @private
311
+ */
312
+ buildDashboardSummary(result) {
313
+ const phases = [];
314
+ // Add phases in order: epic, story, dev, qa
315
+ if (result.epicPhase && !result.epicPhase.skipped) {
316
+ phases.push({
317
+ duration: result.epicPhase.duration,
318
+ failed: result.epicPhase.failures.length,
319
+ name: 'epic',
320
+ passed: result.epicPhase.success,
321
+ });
322
+ }
323
+ if (result.storyPhase && !result.storyPhase.skipped) {
324
+ phases.push({
325
+ duration: result.storyPhase.duration,
326
+ failed: result.storyPhase.failures.length,
327
+ name: 'story',
328
+ passed: result.storyPhase.success,
329
+ });
330
+ }
331
+ if (result.devPhase && !result.devPhase.skipped) {
332
+ phases.push({
333
+ duration: result.devPhase.duration,
334
+ failed: result.devPhase.failures.length,
335
+ name: 'dev',
336
+ passed: result.devPhase.success,
337
+ });
338
+ }
339
+ if (result.qaPhase && !result.qaPhase.skipped) {
340
+ phases.push({
341
+ duration: result.qaPhase.duration,
342
+ failed: result.qaPhase.failures.length,
343
+ name: 'qa',
344
+ passed: result.qaPhase.success,
345
+ });
346
+ }
347
+ // Build session report path from session directory
348
+ const sessionReportPath = this.sessionDir ? `${this.sessionDir}/SESSION_REPORT.md` : undefined;
349
+ return {
350
+ failedCount: result.totalFailures,
351
+ passedCount: result.totalFilesProcessed - result.totalFailures,
352
+ phases,
353
+ sessionReportPath,
354
+ totalDuration: result.totalDuration,
355
+ totalSpawns: result.totalFilesProcessed,
356
+ };
357
+ }
358
+ /**
359
+ * Create callbacks object wired to scaffolder for incremental artifact writing
360
+ *
361
+ * All callbacks are wrapped in try-catch to prevent scaffolder errors from
362
+ * interrupting workflow execution (AC: #9).
363
+ * @returns WorkflowCallbacks object with scaffolder integration
364
+ * @private
365
+ */
366
+ createScaffolderCallbacks() {
367
+ return {
368
+ onSpawnComplete: (context) => {
369
+ // Write spawn output immediately for crash resilience (AC: #8)
370
+ try {
371
+ // Map phaseName to spawn type
372
+ const typeMap = {
373
+ dev: 'dev',
374
+ epic: 'epic',
375
+ story: 'story',
376
+ };
377
+ const spawnType = typeMap[context.phaseName] || 'dev';
378
+ // Fire-and-forget write to not block workflow
379
+ this.scaffolder
380
+ .writeSpawnOutput({
381
+ completedAt: context.endTime ? new Date(context.endTime).toISOString() : undefined,
382
+ content: context.output || '',
383
+ duration: context.duration,
384
+ errors: context.error,
385
+ spawnId: context.spawnId,
386
+ success: context.success,
387
+ type: spawnType,
388
+ })
389
+ .catch((writeError) => {
390
+ this.logger.warn({ error: writeError.message, spawnId: context.spawnId }, 'Async spawn output write failed');
391
+ });
392
+ }
393
+ catch (error) {
394
+ // Log but don't propagate - workflow must continue (AC: #9)
395
+ this.logger.warn({ error: error.message, spawnId: context.spawnId }, 'Failed to write spawn output');
396
+ }
397
+ },
398
+ };
399
+ }
400
+ /**
401
+ * Merge multiple callback objects into a single callbacks object
402
+ *
403
+ * When the same callback is defined in multiple sources, all handlers are called
404
+ * in sequence (fire-and-forget). This enables dual-channel output where scaffolder
405
+ * and reporter receive events independently.
406
+ *
407
+ * @param sources - Array of WorkflowCallbacks objects to merge
408
+ * @returns Merged WorkflowCallbacks object
409
+ * @private
410
+ */
411
+ mergeCallbacks(...sources) {
412
+ const merged = {};
413
+ const callbackNames = [
414
+ 'onPhaseStart',
415
+ 'onPhaseComplete',
416
+ 'onLayerStart',
417
+ 'onLayerComplete',
418
+ 'onSpawnStart',
419
+ 'onSpawnComplete',
420
+ 'onSpawnOutput',
421
+ 'onError',
422
+ ];
423
+ for (const name of callbackNames) {
424
+ const handlers = sources.map((s) => s[name]).filter(Boolean);
425
+ if (handlers.length > 0) {
426
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
427
+ ;
428
+ merged[name] = (...args) => {
429
+ for (const handler of handlers) {
430
+ try {
431
+ handler(...args);
432
+ }
433
+ catch (error) {
434
+ // Fire-and-forget: log but don't propagate callback errors
435
+ const errMsg = error instanceof Error ? error.message : String(error);
436
+ this.logger?.warn({ callback: name, error: errMsg }, 'Callback error');
437
+ }
438
+ }
439
+ };
440
+ }
441
+ }
442
+ return merged;
443
+ }
444
+ /**
445
+ * Derive session prefix from input filename
446
+ *
447
+ * Extracts meaningful prefix from input path:
448
+ * - PRD-feature.md → feature
449
+ * - PRD-my-project.md → my-project
450
+ * - epic-001.md → epic-001
451
+ *
452
+ * @param inputPath - Input file path
453
+ * @returns Derived prefix string
454
+ * @private
455
+ */
456
+ derivePrefixFromFilename(inputPath) {
457
+ const filename = basename(inputPath, '.md');
458
+ // Handle PRD-* pattern
459
+ if (filename.startsWith('PRD-')) {
460
+ return filename.slice(4); // Remove 'PRD-' prefix
461
+ }
462
+ // Use filename as-is for other patterns
463
+ return filename;
464
+ }
465
+ /**
466
+ * Display detailed failure information for each phase
467
+ *
468
+ * @param result - Workflow result with all phase data
469
+ * @private
470
+ */
471
+ displayFailureDetails(result) {
472
+ this.log('\n' + colors.error('Failures:'));
473
+ if (result.epicPhase?.failures.length) {
474
+ this.log(colors.error(` Epic Phase:`));
475
+ for (const failure of result.epicPhase.failures) {
476
+ this.log(colors.error(` • ${failure.identifier}: ${failure.error}`));
477
+ }
478
+ }
479
+ if (result.storyPhase?.failures.length) {
480
+ this.log(colors.error(` Story Phase:`));
481
+ for (const failure of result.storyPhase.failures) {
482
+ this.log(colors.error(` • ${failure.identifier}: ${failure.error}`));
483
+ }
484
+ }
485
+ if (result.devPhase?.failures.length) {
486
+ this.log(colors.error(` Development Phase:`));
487
+ for (const failure of result.devPhase.failures) {
488
+ this.log(colors.error(` • ${failure.identifier}: ${failure.error}`));
489
+ }
490
+ }
491
+ if (result.qaPhase?.failures.length) {
492
+ this.log(colors.error(` QA Phase:`));
493
+ for (const failure of result.qaPhase.failures) {
494
+ this.log(colors.error(` • ${failure.identifier}: ${failure.error}`));
495
+ }
496
+ }
497
+ }
218
498
  /**
219
499
  * Display dry-run banner
220
500
  *
@@ -337,6 +617,11 @@ export default class Workflow extends Command {
337
617
  if (config.dryRun) {
338
618
  this.log('\n' + colors.warning('No files were created (dry-run mode)'));
339
619
  }
620
+ // Display session directory path (AC: #7)
621
+ if (this.sessionDir) {
622
+ const sessionBanner = formatBox('Session Directory', `${this.sessionDir}\n\nView spawn outputs, session report, and workflow log at this location.`);
623
+ this.log('\n' + colors.info(sessionBanner));
624
+ }
340
625
  }
341
626
  /**
342
627
  * Display phase header with visual separator
@@ -353,17 +638,73 @@ export default class Workflow extends Command {
353
638
  this.log(colors.bold(`║ ${header} ║`));
354
639
  this.log(colors.bold(`╚${separator}╝`) + '\n');
355
640
  }
641
+ /**
642
+ * Finalize session by writing session report and workflow log
643
+ *
644
+ * Called on workflow completion (success or failure).
645
+ * Errors are caught and logged but don't interrupt workflow completion.
646
+ *
647
+ * @param result - Workflow execution result
648
+ * @param _config - Workflow configuration used (unused for now)
649
+ * @private
650
+ */
651
+ async finalizeSession(result, _config) {
652
+ if (!this.sessionDir) {
653
+ this.logger.warn('Session directory not initialized, skipping session finalization');
654
+ return;
655
+ }
656
+ try {
657
+ // Build session report summary from workflow result
658
+ const sessionId = basename(this.sessionDir);
659
+ const now = new Date().toISOString();
660
+ const summary = {
661
+ duration: result.totalDuration,
662
+ endedAt: now,
663
+ errors: this.collectErrors(result),
664
+ overallSuccess: result.overallSuccess,
665
+ phases: {
666
+ developments: {
667
+ completed: result.devPhase?.success ?? 0,
668
+ failed: result.devPhase?.failures.length ?? 0,
669
+ total: (result.devPhase?.success ?? 0) + (result.devPhase?.failures.length ?? 0),
670
+ },
671
+ epics: {
672
+ completed: result.epicPhase?.success ?? 0,
673
+ failed: result.epicPhase?.failures.length ?? 0,
674
+ total: (result.epicPhase?.success ?? 0) + (result.epicPhase?.failures.length ?? 0),
675
+ },
676
+ stories: {
677
+ completed: result.storyPhase?.success ?? 0,
678
+ failed: result.storyPhase?.failures.length ?? 0,
679
+ total: (result.storyPhase?.success ?? 0) + (result.storyPhase?.failures.length ?? 0),
680
+ },
681
+ },
682
+ sessionId,
683
+ spawnFiles: [], // Will be populated by scaffolder internals if needed
684
+ startedAt: new Date(Date.now() - result.totalDuration).toISOString(),
685
+ };
686
+ await this.scaffolder.writeSessionReport(summary);
687
+ this.logger.info({ sessionDir: this.sessionDir }, 'Session report written');
688
+ // Note: workflow-log.yaml would require WorkflowLogger integration
689
+ // which is optional per the orchestrator config. For now, session report
690
+ // is the primary artifact.
691
+ }
692
+ catch (error) {
693
+ this.logger.warn({ error: error.message }, 'Failed to finalize session');
694
+ }
695
+ }
356
696
  /**
357
697
  * Initialize services and dependencies
358
698
  *
359
699
  * Creates all service instances needed for workflow orchestration.
360
700
  * @param maxConcurrency - Maximum number of concurrent operations
361
701
  * @param provider - AI provider to use (claude or gemini)
702
+ * @param verbose - Enable verbose output mode
362
703
  * @private
363
704
  */
364
- async initializeServices(maxConcurrency = 3, provider = 'claude') {
365
- // Create logger
366
- this.logger = createLogger({ namespace: 'commands:workflow' });
705
+ async initializeServices(maxConcurrency = 3, provider = 'claude', verbose = false) {
706
+ // Create logger — suppress INFO logs in non-verbose mode to keep terminal clean
707
+ this.logger = createLogger({ level: verbose ? 'info' : 'warn', namespace: 'commands:workflow' });
367
708
  this.logger.info({ provider }, 'Initializing services with AI provider');
368
709
  // Create file system services
369
710
  const fileManager = new FileManager(this.logger);
@@ -379,10 +720,20 @@ export default class Workflow extends Command {
379
720
  const inputDetector = new InputDetector(fileManager, this.logger);
380
721
  // Create story type detector for auto-documentation
381
722
  const storyTypeDetector = new StoryTypeDetector(this.logger);
382
- // Create orchestrator
723
+ // Create session scaffolder for workflow artifacts
724
+ this.scaffolder = new WorkflowSessionScaffolder(fileManager, this.logger);
725
+ // Create workflow reporter for live progress visualization (AC: #1)
726
+ this.reporter = new WorkflowReporter({
727
+ nonTTY: !process.stdout.isTTY,
728
+ verbose,
729
+ });
730
+ // Merge scaffolder callbacks with reporter callbacks for dual-channel output (AC: #1)
731
+ const callbacks = this.mergeCallbacks(this.createScaffolderCallbacks(), this.reporter.getCallbacks());
732
+ // Create orchestrator with merged callbacks
383
733
  this.orchestrator = new WorkflowOrchestrator({
384
734
  agentRunner,
385
735
  batchProcessor,
736
+ callbacks,
386
737
  epicParser,
387
738
  fileManager,
388
739
  inputDetector,
@@ -391,7 +742,29 @@ export default class Workflow extends Command {
391
742
  prdParser,
392
743
  storyTypeDetector,
393
744
  });
394
- this.logger.info({ provider }, 'Services initialized successfully');
745
+ this.logger.info({ provider, verbose }, 'Services initialized successfully');
746
+ }
747
+ /**
748
+ * Initialize session scaffolder and create session directory structure
749
+ *
750
+ * @param inputPath - Input file path for prefix derivation
751
+ * @param sessionPrefix - Optional explicit session prefix
752
+ * @private
753
+ */
754
+ async initializeSession(inputPath, sessionPrefix) {
755
+ try {
756
+ // Derive prefix: explicit flag > derived from filename
757
+ const prefix = sessionPrefix || this.derivePrefixFromFilename(inputPath);
758
+ this.sessionDir = await this.scaffolder.createSessionStructure({
759
+ baseDir: 'docs/workflow-sessions',
760
+ prefix,
761
+ });
762
+ this.logger.info({ prefix, sessionDir: this.sessionDir }, 'Workflow session initialized');
763
+ }
764
+ catch (error) {
765
+ // Log but don't fail workflow - session is optional (AC: #9)
766
+ this.logger.warn({ error: error.message }, 'Failed to initialize session structure');
767
+ }
395
768
  }
396
769
  /**
397
770
  * Register SIGINT handler for graceful cancellation
@@ -6,5 +6,6 @@ export * from './agent-result.js';
6
6
  export * from './phase-result.js';
7
7
  export * from './provider.js';
8
8
  export * from './story.js';
9
+ export * from './workflow-callbacks.js';
9
10
  export * from './workflow-config.js';
10
11
  export * from './workflow-result.js';
@@ -6,5 +6,6 @@ export * from './agent-result.js';
6
6
  export * from './phase-result.js';
7
7
  export * from './provider.js';
8
8
  export * from './story.js';
9
+ export * from './workflow-callbacks.js';
9
10
  export * from './workflow-config.js';
10
11
  export * from './workflow-result.js';