@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.
- package/dist/commands/config/show.js +8 -2
- package/dist/commands/decompose.js +26 -5
- package/dist/commands/epics/create.d.ts +1 -0
- package/dist/commands/mcp/add.d.ts +16 -0
- package/dist/commands/mcp/add.js +77 -0
- package/dist/commands/mcp/credential/get.d.ts +14 -0
- package/dist/commands/mcp/credential/get.js +35 -0
- package/dist/commands/mcp/credential/list.d.ts +17 -0
- package/dist/commands/mcp/credential/list.js +67 -0
- package/dist/commands/mcp/credential/remove.d.ts +18 -0
- package/dist/commands/mcp/credential/remove.js +84 -0
- package/dist/commands/mcp/credential/set.d.ts +16 -0
- package/dist/commands/mcp/credential/set.js +41 -0
- package/dist/commands/mcp/credential/validate.d.ts +12 -0
- package/dist/commands/mcp/credential/validate.js +150 -0
- package/dist/commands/mcp/list.d.ts +17 -0
- package/dist/commands/mcp/list.js +80 -0
- package/dist/commands/mcp/logs.d.ts +15 -0
- package/dist/commands/mcp/logs.js +64 -0
- package/dist/commands/mcp/preset.d.ts +15 -0
- package/dist/commands/mcp/preset.js +84 -0
- package/dist/commands/mcp/remove.d.ts +14 -0
- package/dist/commands/mcp/remove.js +36 -0
- package/dist/commands/mcp/start.d.ts +12 -0
- package/dist/commands/mcp/start.js +80 -0
- package/dist/commands/mcp/status.d.ts +30 -0
- package/dist/commands/mcp/status.js +180 -0
- package/dist/commands/mcp/stop.d.ts +12 -0
- package/dist/commands/mcp/stop.js +47 -0
- package/dist/commands/stories/create.d.ts +1 -0
- package/dist/commands/stories/develop.d.ts +1 -0
- package/dist/commands/stories/qa.js +34 -75
- package/dist/commands/stories/review.d.ts +124 -0
- package/dist/commands/stories/review.js +516 -0
- package/dist/commands/workflow.d.ts +89 -0
- package/dist/commands/workflow.js +487 -14
- package/dist/mcp/types.d.ts +99 -0
- package/dist/mcp/types.js +7 -0
- package/dist/mcp/utils/docker-utils.d.ts +56 -0
- package/dist/mcp/utils/docker-utils.js +108 -0
- package/dist/mcp/utils/template-loader.d.ts +21 -0
- package/dist/mcp/utils/template-loader.js +60 -0
- package/dist/models/agent-options.d.ts +10 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.js +1 -0
- package/dist/models/workflow-callbacks.d.ts +251 -0
- package/dist/models/workflow-callbacks.js +10 -0
- package/dist/models/workflow-config.d.ts +77 -0
- package/dist/models/workflow-result.d.ts +7 -0
- package/dist/services/WorkflowReporter.d.ts +165 -0
- package/dist/services/WorkflowReporter.js +691 -0
- package/dist/services/agents/claude-agent-runner.js +25 -4
- package/dist/services/file-system/path-resolver.d.ts +10 -0
- package/dist/services/file-system/path-resolver.js +12 -0
- package/dist/services/mcp/mcp-config-manager.d.ts +54 -0
- package/dist/services/mcp/mcp-config-manager.js +146 -0
- package/dist/services/mcp/mcp-context-injector.d.ts +92 -0
- package/dist/services/mcp/mcp-context-injector.js +168 -0
- package/dist/services/mcp/mcp-credential-manager.d.ts +48 -0
- package/dist/services/mcp/mcp-credential-manager.js +124 -0
- package/dist/services/mcp/mcp-health-checker.d.ts +56 -0
- package/dist/services/mcp/mcp-health-checker.js +162 -0
- package/dist/services/mcp/types/health-types.d.ts +31 -0
- package/dist/services/mcp/types/health-types.js +7 -0
- package/dist/services/orchestration/dependency-graph-executor.js +1 -1
- package/dist/services/orchestration/task-decomposition-service.d.ts +2 -1
- package/dist/services/orchestration/task-decomposition-service.js +90 -36
- package/dist/services/orchestration/workflow-orchestrator.d.ts +87 -3
- package/dist/services/orchestration/workflow-orchestrator.js +1169 -289
- package/dist/services/review/ai-review-scanner.d.ts +66 -0
- package/dist/services/review/ai-review-scanner.js +142 -0
- package/dist/services/review/coderabbit-scanner.d.ts +25 -0
- package/dist/services/review/coderabbit-scanner.js +31 -0
- package/dist/services/review/index.d.ts +20 -0
- package/dist/services/review/index.js +15 -0
- package/dist/services/review/lint-scanner.d.ts +46 -0
- package/dist/services/review/lint-scanner.js +172 -0
- package/dist/services/review/review-config.d.ts +62 -0
- package/dist/services/review/review-config.js +91 -0
- package/dist/services/review/review-phase-executor.d.ts +69 -0
- package/dist/services/review/review-phase-executor.js +152 -0
- package/dist/services/review/review-queue.d.ts +98 -0
- package/dist/services/review/review-queue.js +174 -0
- package/dist/services/review/review-reporter.d.ts +94 -0
- package/dist/services/review/review-reporter.js +386 -0
- package/dist/services/review/scanner-factory.d.ts +42 -0
- package/dist/services/review/scanner-factory.js +60 -0
- package/dist/services/review/self-heal-loop.d.ts +58 -0
- package/dist/services/review/self-heal-loop.js +132 -0
- package/dist/services/review/severity-classifier.d.ts +17 -0
- package/dist/services/review/severity-classifier.js +314 -0
- package/dist/services/review/tech-debt-tracker.d.ts +52 -0
- package/dist/services/review/tech-debt-tracker.js +245 -0
- package/dist/services/review/types.d.ts +93 -0
- package/dist/services/review/types.js +23 -0
- package/dist/services/scaffolding/workflow-session-scaffolder.d.ts +182 -0
- package/dist/services/scaffolding/workflow-session-scaffolder.js +236 -0
- package/dist/services/validation/config-validator.d.ts +84 -0
- package/dist/services/validation/config-validator.js +78 -0
- package/dist/utils/colors.d.ts +10 -10
- package/dist/utils/colors.js +15 -15
- package/dist/utils/credential-utils.d.ts +14 -0
- package/dist/utils/credential-utils.js +19 -0
- package/dist/utils/duration.d.ts +41 -0
- package/dist/utils/duration.js +89 -0
- package/dist/utils/listr2-helpers.d.ts +216 -0
- package/dist/utils/listr2-helpers.js +334 -0
- package/dist/utils/shared-flags.d.ts +1 -0
- package/dist/utils/shared-flags.js +11 -2
- 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
|
+
}
|