@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
@@ -0,0 +1,691 @@
1
+ /**
2
+ * WorkflowReporter
3
+ *
4
+ * Real-time progress visualization for multi-layer parallel workflow execution
5
+ * using listr2. Implements the WorkflowCallbacks interface to observe workflow
6
+ * lifecycle events without coupling to orchestration internals.
7
+ *
8
+ * Features:
9
+ * - Top-level phase task groups with emoji-annotated titles
10
+ * - Nested concurrent task groups for parallel spawn layers
11
+ * - Live status updates for spawn transitions (queued → running → completed/failed)
12
+ * - Collapsible layer summaries on completion
13
+ */
14
+ import Table from 'cli-table3';
15
+ import { Listr } from 'listr2';
16
+ import { formatBox, formatDuration } from '../utils/formatters.js';
17
+ import { DEFAULT_TERMINAL_WIDTH, formatLayerSummary, formatPlainPhaseHeader, formatPlainSpawnMarker, formatPlainSummary, formatVerboseSpawnOutput, getPhaseActionVerb, getPhaseEmoji, isTTY, PHASE_TITLES, STATUS_INDICATORS, stripAnsi, truncateText, } from '../utils/listr2-helpers.js';
18
+ /**
19
+ * WorkflowReporter provides real-time structured progress visualization
20
+ * of multi-layer parallel execution using listr2.
21
+ *
22
+ * Usage:
23
+ * ```typescript
24
+ * const reporter = new WorkflowReporter({ verbose: false })
25
+ * orchestrator.setCallbacks(reporter.getCallbacks())
26
+ * // ... workflow execution ...
27
+ * reporter.dispose()
28
+ * ```
29
+ */
30
+ export class WorkflowReporter {
31
+ currentPhaseIndex = 0;
32
+ currentPhaseName = null;
33
+ disposed = false;
34
+ options;
35
+ // Phase tracking for display
36
+ phaseOrder = [];
37
+ phases = new Map();
38
+ // Listr2 instance management — one per active phase, chained transitions
39
+ listrInstance = null;
40
+ listrPromise = null;
41
+ phaseTransitionChain = Promise.resolve();
42
+ // Phase task resolver — resolves the phase-level listr2 task on phase complete
43
+ phaseTaskResolver = null;
44
+ // Phase task output updater — updates the spinner line with spawn progress
45
+ phaseTaskOutput = null;
46
+ // Phase task title updater — updates the spinner title dynamically
47
+ phaseTaskTitle = null;
48
+ // Which phase the current phaseTaskOutput/phaseTaskTitle closures belong to.
49
+ // Prevents cross-phase contamination in pipelined mode where story and dev overlap.
50
+ currentTaskPhase = null;
51
+ // Buffered values for title/output set before listr task is ready
52
+ pendingTitle = null;
53
+ pendingOutput = null;
54
+ // Live ticker — refreshes spawn lines with spinners + elapsed time
55
+ tickerInterval = null;
56
+ tickerFrame = 0;
57
+ static SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
58
+ constructor(options = {}) {
59
+ // Auto-detect non-TTY mode if not explicitly set
60
+ const isNonTTY = options.nonTTY ?? !isTTY();
61
+ this.options = {
62
+ maxWidth: DEFAULT_TERMINAL_WIDTH,
63
+ noColor: false,
64
+ nonTTY: isNonTTY,
65
+ verbose: false,
66
+ ...options,
67
+ // Ensure nonTTY is set based on detection if not provided
68
+ ...(options.nonTTY === undefined ? { nonTTY: isNonTTY } : {}),
69
+ };
70
+ }
71
+ /**
72
+ * Display final boxed dashboard with workflow summary (AC: #4)
73
+ */
74
+ displayFinalDashboard(summary) {
75
+ if (this.options.nonTTY) {
76
+ this.displayPlainTextDashboard(summary);
77
+ return;
78
+ }
79
+ this.displayRichDashboard(summary);
80
+ }
81
+ /**
82
+ * Dispose of resources and clean up listr2 instance
83
+ */
84
+ dispose() {
85
+ if (this.disposed)
86
+ return;
87
+ this.disposed = true;
88
+ this.stopTicker();
89
+ // Resolve any pending phase task to prevent hanging
90
+ if (this.phaseTaskResolver) {
91
+ this.phaseTaskResolver.resolve();
92
+ this.phaseTaskResolver = null;
93
+ }
94
+ this.phaseTaskOutput = null;
95
+ this.phaseTaskTitle = null;
96
+ this.currentTaskPhase = null;
97
+ this.pendingTitle = null;
98
+ this.pendingOutput = null;
99
+ // Clear state
100
+ this.phases.clear();
101
+ this.currentPhaseName = null;
102
+ this.listrInstance = null;
103
+ this.listrPromise = null;
104
+ }
105
+ /**
106
+ * Get the workflow callbacks interface for registering with orchestrator
107
+ */
108
+ getCallbacks() {
109
+ return {
110
+ onError: this.handleError.bind(this),
111
+ onLayerComplete: this.handleLayerComplete.bind(this),
112
+ onLayerStart: this.handleLayerStart.bind(this),
113
+ onPhaseComplete: this.handlePhaseComplete.bind(this),
114
+ onPhaseStart: this.handlePhaseStart.bind(this),
115
+ onSpawnComplete: this.handleSpawnComplete.bind(this),
116
+ onSpawnOutput: this.handleSpawnOutput.bind(this),
117
+ onSpawnStart: this.handleSpawnStart.bind(this),
118
+ };
119
+ }
120
+ /**
121
+ * Get the configured max width for output formatting
122
+ */
123
+ getMaxWidth() {
124
+ return this.options.maxWidth ?? DEFAULT_TERMINAL_WIDTH;
125
+ }
126
+ /**
127
+ * Check if reporter has been disposed
128
+ */
129
+ isDisposed() {
130
+ return this.disposed;
131
+ }
132
+ /**
133
+ * Check if reporter is in TTY mode
134
+ */
135
+ isTTYMode() {
136
+ return !this.options.nonTTY;
137
+ }
138
+ /**
139
+ * Check if reporter is in verbose mode
140
+ */
141
+ isVerboseMode() {
142
+ return this.options.verbose ?? false;
143
+ }
144
+ /**
145
+ * Wait for all listr2 tasks to complete (called after orchestrator finishes)
146
+ */
147
+ async waitForCompletion() {
148
+ // Wait for any pending phase transitions to finish
149
+ try {
150
+ await this.phaseTransitionChain;
151
+ }
152
+ catch { /* handled internally */ }
153
+ // Wait for the final listr instance to finish rendering
154
+ if (this.listrPromise) {
155
+ try {
156
+ await this.listrPromise;
157
+ }
158
+ catch { /* handled internally */ }
159
+ }
160
+ }
161
+ // ============================================================================
162
+ // Listr2 Phase Management
163
+ // ============================================================================
164
+ /**
165
+ * Clean up the current listr2 instance — resolve pending tasks and wait for
166
+ * the renderer to finish so the next phase gets a clean terminal.
167
+ */
168
+ async cleanupCurrentListr() {
169
+ this.stopTicker();
170
+ // Resolve the current phase task if still pending
171
+ if (this.phaseTaskResolver) {
172
+ this.phaseTaskResolver.resolve();
173
+ this.phaseTaskResolver = null;
174
+ }
175
+ this.phaseTaskOutput = null;
176
+ this.phaseTaskTitle = null;
177
+ this.currentTaskPhase = null;
178
+ // NOTE: Do NOT clear pendingTitle/pendingOutput here — they may have been
179
+ // set by handleSpawnStart between onPhaseStart and the promise chain resolving.
180
+ // They are consumed (and cleared) by the flush in createPhaseListr's task function.
181
+ // Wait for the listr run() promise to settle (renderer finishes)
182
+ if (this.listrPromise) {
183
+ try {
184
+ await this.listrPromise;
185
+ }
186
+ catch { /* errors tracked in state */ }
187
+ }
188
+ this.listrInstance = null;
189
+ this.listrPromise = null;
190
+ }
191
+ /**
192
+ * Create a new listr2 instance for a phase. The phase gets a single top-level
193
+ * task with a spinner. Spawn progress is piped as task output (shown below the
194
+ * spinner line). The task resolves when handlePhaseComplete fires.
195
+ *
196
+ * Phase transitions are chained: old instance is fully cleaned up before the
197
+ * new one starts rendering, preventing two renderers fighting over stdout.
198
+ */
199
+ createPhaseListr(phaseState) {
200
+ // Chain: cleanup old instance → create new instance
201
+ this.phaseTransitionChain = this.phaseTransitionChain
202
+ .then(() => this.cleanupCurrentListr())
203
+ .then(() => {
204
+ if (this.disposed)
205
+ return;
206
+ const phaseName = phaseState.phaseName;
207
+ const emoji = getPhaseEmoji(phaseName);
208
+ const title = PHASE_TITLES[phaseName] || `${phaseName} Phase`;
209
+ const phaseTitle = `${title} ${emoji}`;
210
+ this.listrInstance = new Listr([
211
+ {
212
+ rendererOptions: {
213
+ outputBar: 1,
214
+ persistentOutput: false,
215
+ },
216
+ task: (_ctx, task) => new Promise((resolve, reject) => {
217
+ this.phaseTaskResolver = { resolve, reject };
218
+ this.phaseTaskOutput = (output) => {
219
+ task.output = output;
220
+ };
221
+ this.phaseTaskTitle = (title) => {
222
+ task.title = title;
223
+ };
224
+ this.currentTaskPhase = phaseName;
225
+ // Flush any buffered title/output set before listr was ready
226
+ if (this.pendingTitle) {
227
+ task.title = this.pendingTitle;
228
+ this.pendingTitle = null;
229
+ }
230
+ if (this.pendingOutput) {
231
+ task.output = this.pendingOutput;
232
+ this.pendingOutput = null;
233
+ }
234
+ }),
235
+ title: phaseTitle,
236
+ },
237
+ ], {
238
+ concurrent: false,
239
+ exitOnError: false,
240
+ fallbackRendererCondition: () => this.options.nonTTY ?? false,
241
+ renderer: this.options.nonTTY ? 'simple' : 'default',
242
+ ...(this.options.nonTTY ? {} : {
243
+ rendererOptions: {
244
+ collapseErrors: false,
245
+ collapseSubtasks: true,
246
+ formatOutput: 'wrap',
247
+ lazy: false,
248
+ removeEmptyLines: false,
249
+ showSubtasks: true,
250
+ showTimer: false,
251
+ suffixSkips: false,
252
+ },
253
+ }),
254
+ });
255
+ // Run the listr (non-blocking — we chain via phaseTransitionChain)
256
+ this.listrPromise = this.listrInstance.run().catch(() => {
257
+ // Errors handled in state tracking
258
+ });
259
+ });
260
+ }
261
+ // ============================================================================
262
+ // Phase Banner
263
+ // ============================================================================
264
+ /**
265
+ * Display a phase banner. In verbose mode, shows a full formatBox banner.
266
+ * In non-verbose TTY mode, the listr2 spinner is the only display (no banner).
267
+ * In non-TTY mode, shows a plain-text header.
268
+ */
269
+ displayPhaseBanner(phaseName, itemCount) {
270
+ if (this.options.nonTTY) {
271
+ const header = formatPlainPhaseHeader(phaseName);
272
+ if (itemCount > 0) {
273
+ console.log('\n' + header);
274
+ console.log(`${getPhaseActionVerb(phaseName)} ${itemCount} ${itemCount === 1 ? 'item' : 'items'}`);
275
+ }
276
+ else {
277
+ console.log('\n' + header);
278
+ }
279
+ return;
280
+ }
281
+ // In verbose mode, show a full banner box
282
+ if (this.options.verbose) {
283
+ const emoji = getPhaseEmoji(phaseName);
284
+ const title = PHASE_TITLES[phaseName] || `${phaseName} Phase`;
285
+ const totalPhases = 4;
286
+ const phaseNumber = this.currentPhaseIndex;
287
+ const bannerTitle = `${emoji} ${title} (${phaseNumber}/${totalPhases})`;
288
+ const bannerContent = itemCount > 0
289
+ ? `${getPhaseActionVerb(phaseName)} ${itemCount} ${itemCount === 1 ? 'item' : 'items'}`
290
+ : '';
291
+ const banner = formatBox(bannerTitle, bannerContent);
292
+ console.log('\n' + banner);
293
+ }
294
+ // In non-verbose TTY mode: no banner — the listr2 spinner is enough
295
+ }
296
+ // ============================================================================
297
+ // Dashboard Display
298
+ // ============================================================================
299
+ displayPlainTextDashboard(summary) {
300
+ console.log('');
301
+ console.log(formatPlainSummary(summary.totalSpawns, summary.passedCount, summary.failedCount, summary.totalDuration));
302
+ if (summary.sessionReportPath) {
303
+ console.log('');
304
+ console.log('Session Report:');
305
+ console.log(summary.sessionReportPath);
306
+ }
307
+ console.log('');
308
+ }
309
+ displayRichDashboard(summary) {
310
+ const table = new Table({
311
+ chars: {
312
+ 'bottom': '─', 'bottom-left': '└', 'bottom-mid': '┴', 'bottom-right': '┘',
313
+ 'left': '│', 'left-mid': '├', 'mid': '─', 'mid-mid': '┼', 'middle': '│',
314
+ 'right': '│', 'right-mid': '┤', 'top': '─', 'top-left': '┌', 'top-mid': '┬', 'top-right': '┐',
315
+ },
316
+ head: ['Phase', 'Passed', 'Failed', 'Duration'],
317
+ style: { border: ['gray'], head: ['cyan'] },
318
+ });
319
+ for (const phase of summary.phases) {
320
+ const emoji = getPhaseEmoji(phase.name);
321
+ const phaseName = `${emoji} ${PHASE_TITLES[phase.name] || phase.name}`;
322
+ const passedStr = phase.passed > 0 ? STATUS_INDICATORS.completed + ' ' + phase.passed : '0';
323
+ const failedStr = phase.failed > 0 ? STATUS_INDICATORS.failed + ' ' + phase.failed : '0';
324
+ const durationStr = formatDuration(phase.duration);
325
+ table.push([phaseName, passedStr, failedStr, durationStr]);
326
+ }
327
+ const totalPassedStr = summary.passedCount > 0
328
+ ? STATUS_INDICATORS.completed + ' ' + summary.passedCount : '0';
329
+ const totalFailedStr = summary.failedCount > 0
330
+ ? STATUS_INDICATORS.failed + ' ' + summary.failedCount : '0';
331
+ const totalDurationStr = formatDuration(summary.totalDuration);
332
+ table.push(['Total', totalPassedStr, totalFailedStr, totalDurationStr]);
333
+ const overallStatus = summary.failedCount === 0
334
+ ? STATUS_INDICATORS.completed + ' All spawns passed'
335
+ : STATUS_INDICATORS.failed + ` ${summary.failedCount} spawn(s) failed`;
336
+ // Print table and summary directly (no formatBox — ANSI codes break width calculation)
337
+ console.log('\n Workflow Complete\n');
338
+ console.log(table.toString());
339
+ console.log('\n ' + overallStatus);
340
+ if (summary.sessionReportPath) {
341
+ console.log(`\n Session Report: ${summary.sessionReportPath}`);
342
+ }
343
+ console.log('');
344
+ }
345
+ // ============================================================================
346
+ // Phase Event Handlers
347
+ // ============================================================================
348
+ handlePhaseStart(context) {
349
+ if (this.disposed)
350
+ return;
351
+ const { itemCount, phaseName, startTime } = context;
352
+ const phaseKey = phaseName.toLowerCase();
353
+ // Track phase order for numbering
354
+ if (!this.phaseOrder.includes(phaseKey)) {
355
+ this.phaseOrder.push(phaseKey);
356
+ }
357
+ this.currentPhaseIndex = this.phaseOrder.indexOf(phaseKey) + 1;
358
+ // Create phase state
359
+ const phaseState = {
360
+ currentLayerIndex: -1,
361
+ failureCount: 0,
362
+ itemCount,
363
+ layers: new Map(),
364
+ phaseName: phaseKey,
365
+ startTime,
366
+ successCount: 0,
367
+ };
368
+ this.phases.set(phaseKey, phaseState);
369
+ this.currentPhaseName = phaseKey;
370
+ // Display phase banner
371
+ this.displayPhaseBanner(phaseKey, itemCount);
372
+ // Create listr2 spinner for this phase (chained after previous phase cleanup)
373
+ this.createPhaseListr(phaseState);
374
+ }
375
+ handlePhaseComplete(context) {
376
+ if (this.disposed)
377
+ return;
378
+ const { duration, failureCount, itemCount, phaseName, successCount } = context;
379
+ const phaseKey = phaseName.toLowerCase();
380
+ const phaseState = this.phases.get(phaseKey);
381
+ // Stop the live ticker
382
+ this.stopTicker();
383
+ if (phaseState) {
384
+ phaseState.successCount = successCount ?? 0;
385
+ phaseState.failureCount = failureCount ?? 0;
386
+ const emoji = getPhaseEmoji(phaseKey);
387
+ const title = PHASE_TITLES[phaseKey] || `${phaseKey} Phase`;
388
+ const durationStr = formatDuration(duration ?? 0);
389
+ const countStr = `${successCount ?? 0}/${itemCount}`;
390
+ const failNote = (failureCount ?? 0) > 0 ? ` (${failureCount} failed)` : '';
391
+ // Update the title to a clean one-liner summary (output lines disappear since persistentOutput=false)
392
+ // listr2's ✔/✖ already signals success/failure, so no custom icon needed
393
+ // Guard: only write to closures if they belong to THIS phase (prevents cross-phase contamination)
394
+ if (this.phaseTaskTitle && this.currentTaskPhase === phaseKey) {
395
+ this.phaseTaskTitle(`${title} ${emoji} ${countStr} (${durationStr})${failNote}`);
396
+ }
397
+ // Resolve the listr2 phase task — this completes the spinner with a checkmark
398
+ if (this.phaseTaskResolver) {
399
+ this.phaseTaskResolver.resolve();
400
+ this.phaseTaskResolver = null;
401
+ }
402
+ }
403
+ // Null out stale task references AFTER resolving — prevents late spawn callbacks
404
+ // from writing to a completed listr2 task whose renderer buffers are already cleaned up.
405
+ // Without this, listr2's OUTPUT event handler does buffer.output.get(task.id).write()
406
+ // on a cleared map → undefined.write() → TypeError.
407
+ // Only null if closures belong to this phase — another phase may have already claimed them.
408
+ if (this.currentTaskPhase === phaseKey) {
409
+ this.phaseTaskOutput = null;
410
+ this.phaseTaskTitle = null;
411
+ this.currentTaskPhase = null;
412
+ }
413
+ if (this.currentPhaseName === phaseKey) {
414
+ this.currentPhaseName = null;
415
+ }
416
+ }
417
+ // ============================================================================
418
+ // Layer Event Handlers
419
+ // ============================================================================
420
+ handleLayerStart(context) {
421
+ if (this.disposed)
422
+ return;
423
+ const { layerIndex, phaseName, spawnCount, startTime, totalLayers } = context;
424
+ const phaseKey = phaseName.toLowerCase();
425
+ const phaseState = this.phases.get(phaseKey);
426
+ if (!phaseState)
427
+ return;
428
+ const layerState = {
429
+ completedCount: 0,
430
+ failureCount: 0,
431
+ layerIndex,
432
+ spawnCount,
433
+ spawns: new Map(),
434
+ startTime,
435
+ successCount: 0,
436
+ totalLayers,
437
+ };
438
+ phaseState.layers.set(layerIndex, layerState);
439
+ phaseState.currentLayerIndex = layerIndex;
440
+ }
441
+ handleLayerComplete(context) {
442
+ if (this.disposed)
443
+ return;
444
+ const { duration, failureCount, layerIndex, phaseName, spawnCount, successCount } = context;
445
+ const phaseKey = phaseName.toLowerCase();
446
+ const phaseState = this.phases.get(phaseKey);
447
+ if (!phaseState)
448
+ return;
449
+ const layerState = phaseState.layers.get(layerIndex);
450
+ if (layerState) {
451
+ layerState.successCount = successCount ?? 0;
452
+ layerState.failureCount = failureCount ?? 0;
453
+ }
454
+ const layerResult = {
455
+ durationMs: duration ?? 0,
456
+ failureCount: failureCount ?? 0,
457
+ layerIndex,
458
+ spawnCount,
459
+ successCount: successCount ?? 0,
460
+ };
461
+ const summary = formatLayerSummary(layerResult);
462
+ const plainSummary = stripAnsi(summary);
463
+ // Update listr2 phase task with layer summary
464
+ // Guard: only write if closures belong to THIS phase
465
+ if (this.phaseTaskOutput && this.currentTaskPhase === phaseKey) {
466
+ this.phaseTaskOutput(plainSummary);
467
+ }
468
+ // Also log to console for non-TTY and verbose
469
+ if (this.options.nonTTY || this.options.verbose) {
470
+ console.log(plainSummary);
471
+ }
472
+ }
473
+ // ============================================================================
474
+ // Spawn Event Handlers
475
+ // ============================================================================
476
+ handleSpawnStart(context) {
477
+ if (this.disposed)
478
+ return;
479
+ const { agentType, itemId, itemTitle, phaseName, spawnId, startTime } = context;
480
+ const phaseKey = phaseName.toLowerCase();
481
+ const phaseState = this.phases.get(phaseKey);
482
+ if (!phaseState)
483
+ return;
484
+ // Auto-create a default layer if none exists (epic/story phases don't fire onLayerStart)
485
+ let layerState = phaseState.layers.get(phaseState.currentLayerIndex);
486
+ if (!layerState) {
487
+ layerState = {
488
+ completedCount: 0,
489
+ failureCount: 0,
490
+ layerIndex: 0,
491
+ spawnCount: 0,
492
+ spawns: new Map(),
493
+ startTime: Date.now(),
494
+ successCount: 0,
495
+ totalLayers: 1,
496
+ };
497
+ phaseState.layers.set(0, layerState);
498
+ phaseState.currentLayerIndex = 0;
499
+ }
500
+ // Track spawn state
501
+ const spawnState = {
502
+ agentType,
503
+ itemId,
504
+ itemTitle,
505
+ spawnId,
506
+ startTime,
507
+ status: 'running',
508
+ };
509
+ layerState.spawns.set(spawnId, spawnState);
510
+ // Render all spawn lines immediately
511
+ this.tickSpawns(phaseState);
512
+ // Start the live ticker if not already running (refreshes every 1s)
513
+ if (!this.tickerInterval) {
514
+ this.tickerInterval = setInterval(() => {
515
+ const currentPhase = this.currentPhaseName ? this.phases.get(this.currentPhaseName) : null;
516
+ if (currentPhase) {
517
+ this.tickSpawns(currentPhase);
518
+ }
519
+ }, 1000);
520
+ }
521
+ // Log spawn start marker (only in verbose/nonTTY)
522
+ if (this.options.nonTTY || this.options.verbose) {
523
+ const marker = formatPlainSpawnMarker(itemId, 'STARTED');
524
+ console.log(marker);
525
+ }
526
+ }
527
+ /**
528
+ * Live-tick the phase title — rotates through running spawns with elapsed time.
529
+ * Called every 2s by the ticker interval AND once immediately on spawn start.
530
+ */
531
+ stopTicker() {
532
+ if (this.tickerInterval) {
533
+ clearInterval(this.tickerInterval);
534
+ this.tickerInterval = null;
535
+ }
536
+ this.tickerFrame = 0;
537
+ }
538
+ /**
539
+ * Build and render all spawn lines with per-item spinners + elapsed time.
540
+ * Called every second by the ticker interval AND once on spawn start/complete.
541
+ */
542
+ tickSpawns(phaseState) {
543
+ const phaseName = phaseState.phaseName;
544
+ const emoji = getPhaseEmoji(phaseName);
545
+ const title = PHASE_TITLES[phaseName] || `${phaseName} Phase`;
546
+ const actionVerb = getPhaseActionVerb(phaseName);
547
+ // Collect all spawns (running + recently completed)
548
+ const allSpawns = [];
549
+ let runningCount = 0;
550
+ for (const [, ls] of phaseState.layers) {
551
+ for (const [, ss] of ls.spawns) {
552
+ allSpawns.push(ss);
553
+ if (ss.status === 'running')
554
+ runningCount++;
555
+ }
556
+ }
557
+ // Update phase title
558
+ const phaseTitle = runningCount > 1
559
+ ? `${title} ${emoji} ${actionVerb} ${runningCount} in parallel`
560
+ : runningCount === 1
561
+ ? `${title} ${emoji} ${actionVerb}...`
562
+ : `${title} ${emoji}`;
563
+ // Guard against listr2 renderer errors AND cross-phase contamination:
564
+ // 1. After a phase task resolves, listr2 cleans up its buffer map → TypeError.
565
+ // 2. In pipelined mode, a story callback could write to the dev phase's listr2 task.
566
+ // The currentTaskPhase check ensures closures only write to their owning phase.
567
+ try {
568
+ if (this.phaseTaskTitle && this.currentTaskPhase === phaseName) {
569
+ this.phaseTaskTitle(phaseTitle);
570
+ }
571
+ else {
572
+ this.pendingTitle = phaseTitle;
573
+ }
574
+ }
575
+ catch {
576
+ // listr2 renderer already cleaned up — buffer to pending instead
577
+ this.phaseTaskTitle = null;
578
+ this.pendingTitle = phaseTitle;
579
+ }
580
+ // Build per-item output lines
581
+ const frame = WorkflowReporter.SPINNER_FRAMES[this.tickerFrame % WorkflowReporter.SPINNER_FRAMES.length];
582
+ this.tickerFrame++;
583
+ const lines = allSpawns.map((ss) => {
584
+ const truncTitle = ss.itemTitle ? truncateText(ss.itemTitle, 50) : '...';
585
+ if (ss.status === 'running') {
586
+ const elapsed = formatDuration(Date.now() - ss.startTime);
587
+ return `${frame} ${ss.itemId} — ${truncTitle} (${elapsed})`;
588
+ }
589
+ else if (ss.status === 'completed') {
590
+ const elapsed = ss.endTime ? formatDuration(ss.endTime - ss.startTime) : '';
591
+ return `${STATUS_INDICATORS.completed} ${ss.itemId} — ${truncTitle}${elapsed ? ` (${elapsed})` : ''}`;
592
+ }
593
+ else {
594
+ const errMsg = ss.error ? truncateText(ss.error, 30) : 'failed';
595
+ return `${STATUS_INDICATORS.failed} ${ss.itemId} — ${errMsg}`;
596
+ }
597
+ });
598
+ const output = lines.join('\n');
599
+ try {
600
+ if (this.phaseTaskOutput && this.currentTaskPhase === phaseName) {
601
+ this.phaseTaskOutput(output);
602
+ }
603
+ else {
604
+ this.pendingOutput = output;
605
+ }
606
+ }
607
+ catch {
608
+ // listr2 renderer already cleaned up — buffer to pending instead
609
+ this.phaseTaskOutput = null;
610
+ this.pendingOutput = output;
611
+ }
612
+ }
613
+ handleSpawnComplete(context) {
614
+ if (this.disposed)
615
+ return;
616
+ const { agentType, duration, error, itemId, itemTitle, phaseName, spawnId, success } = context;
617
+ const phaseKey = phaseName.toLowerCase();
618
+ const phaseState = this.phases.get(phaseKey);
619
+ if (!phaseState)
620
+ return;
621
+ // Auto-create a default layer if none exists (epic/story phases don't fire onLayerStart)
622
+ let layerState = phaseState.layers.get(phaseState.currentLayerIndex);
623
+ if (!layerState) {
624
+ layerState = {
625
+ completedCount: 0,
626
+ failureCount: 0,
627
+ layerIndex: 0,
628
+ spawnCount: 0,
629
+ spawns: new Map(),
630
+ startTime: Date.now(),
631
+ successCount: 0,
632
+ totalLayers: 1,
633
+ };
634
+ phaseState.layers.set(0, layerState);
635
+ phaseState.currentLayerIndex = 0;
636
+ }
637
+ const spawnState = layerState.spawns.get(spawnId);
638
+ if (spawnState) {
639
+ spawnState.status = success ? 'completed' : 'failed';
640
+ spawnState.endTime = Date.now();
641
+ spawnState.error = error;
642
+ }
643
+ layerState.completedCount++;
644
+ if (success) {
645
+ layerState.successCount++;
646
+ }
647
+ else {
648
+ layerState.failureCount++;
649
+ }
650
+ // Re-render all spawn lines immediately (shows ✓/✗ for completed spawn)
651
+ this.tickSpawns(phaseState);
652
+ // Log spawn completion marker (only in verbose/nonTTY)
653
+ if (this.options.nonTTY || this.options.verbose) {
654
+ const completionStatus = success ? 'COMPLETED' : 'FAILED';
655
+ const completionMarker = formatPlainSpawnMarker(itemId, completionStatus, duration, error);
656
+ console.log(completionMarker);
657
+ }
658
+ }
659
+ handleSpawnOutput(context, output) {
660
+ if (this.disposed)
661
+ return;
662
+ if (!this.options.verbose)
663
+ return;
664
+ const { itemId, spawnId } = context;
665
+ const maxWidth = this.options.maxWidth ?? DEFAULT_TERMINAL_WIDTH;
666
+ if (this.options.nonTTY) {
667
+ const cleanOutput = stripAnsi(output);
668
+ const wrappedOutput = formatVerboseSpawnOutput(itemId || spawnId, cleanOutput, maxWidth);
669
+ console.log(wrappedOutput);
670
+ }
671
+ else {
672
+ const wrappedOutput = formatVerboseSpawnOutput(itemId || spawnId, output, maxWidth);
673
+ console.log(wrappedOutput);
674
+ }
675
+ }
676
+ // ============================================================================
677
+ // Error Handler
678
+ // ============================================================================
679
+ handleError(context) {
680
+ if (this.disposed)
681
+ return;
682
+ const { message, phaseName, recoverable, spawnId } = context;
683
+ const prefix = recoverable ? '[WARN]' : '[ERROR]';
684
+ const location = spawnId
685
+ ? `spawn:${spawnId}`
686
+ : phaseName
687
+ ? `phase:${phaseName}`
688
+ : 'workflow';
689
+ console.error(`${prefix} [${location}] ${message}`);
690
+ }
691
+ }