@lumenflow/initiatives 1.0.0

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.
@@ -0,0 +1,1026 @@
1
+ /**
2
+ * Initiative Orchestrator (WU-1581, WU-1821)
3
+ *
4
+ * Core orchestration logic for parallel agent execution of initiative WUs.
5
+ * Builds execution plans based on WU dependencies and manages wave-based execution.
6
+ *
7
+ * Architecture:
8
+ * - Loads initiative(s) and their WUs
9
+ * - Builds dependency graph for topological ordering
10
+ * - Groups independent WUs into parallel execution waves
11
+ * - Generates spawn commands for agent delegation
12
+ *
13
+ * WU-1821 additions:
14
+ * - Checkpoint-per-wave pattern for context management
15
+ * - Wave manifest files for idempotent resumption
16
+ * - Compact output for token discipline
17
+ *
18
+ * @see {@link tools/orchestrate-initiative.mjs} - CLI entry point
19
+ * @see {@link tools/lib/initiative-yaml.mjs} - Initiative loading
20
+ * @see {@link tools/lib/dependency-graph.mjs} - Dependency graph utilities
21
+ */
22
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { findInitiative, getInitiativeWUs } from './initiative-yaml.js';
25
+ import { buildDependencyGraph, validateGraph } from '@lumenflow/core/lib/dependency-graph.js';
26
+ import { createError, ErrorCodes } from '@lumenflow/core/lib/error-handler.js';
27
+ import { WU_STATUS, STRING_LITERALS } from '@lumenflow/core/lib/wu-constants.js';
28
+ import { WU_PATHS } from '@lumenflow/core/lib/wu-paths.js';
29
+ import { parseYAML } from '@lumenflow/core/lib/wu-yaml.js';
30
+ // WU-2027: Import spawn generation for embedding in orchestration output
31
+ import { generateTaskInvocation } from '@lumenflow/core/lib/wu-spawn.js';
32
+ /**
33
+ * Wave manifest directory path (gitignored).
34
+ */
35
+ const WAVE_MANIFEST_DIR = '.beacon/artifacts/waves';
36
+ /**
37
+ * Stamps directory path.
38
+ */
39
+ const STAMPS_DIR = '.beacon/stamps';
40
+ /**
41
+ * Log prefix for orchestrator messages.
42
+ */
43
+ const LOG_PREFIX = '[orchestrate:initiative]';
44
+ /**
45
+ * WU-2280: Banner separator for ACTION REQUIRED output.
46
+ * Used to make it unambiguous that agents have NOT been spawned yet.
47
+ */
48
+ const BANNER_SEPARATOR = '==============================================================================';
49
+ /**
50
+ * WU-2040: XML tag patterns for Task invocation extraction.
51
+ * Split to avoid XML parsing issues in agent tools.
52
+ */
53
+ const ANTML_NS = 'antml:';
54
+ const XML_PATTERNS = {
55
+ FUNCTION_CALLS_OPEN: `<${ANTML_NS}function_calls>`,
56
+ FUNCTION_CALLS_CLOSE: `</${ANTML_NS}function_calls>`,
57
+ INVOKE_OPEN: `<${ANTML_NS}invoke`,
58
+ INVOKE_CLOSE: `</${ANTML_NS}invoke>`,
59
+ };
60
+ /**
61
+ * WU-1828: Auto-detection thresholds for checkpoint mode.
62
+ *
63
+ * These thresholds determine when checkpoint mode is automatically enabled
64
+ * to prevent "prompt too long" errors for large initiatives.
65
+ *
66
+ * @type {{WU_COUNT: number, WAVE_COUNT: number}}
67
+ */
68
+ export const CHECKPOINT_AUTO_THRESHOLDS = {
69
+ /** Auto-enable checkpoint mode if pending WU count exceeds this (>3 = 4+) */
70
+ WU_COUNT: 3,
71
+ /** Auto-enable checkpoint mode if wave count exceeds this (>2 = 3+) */
72
+ WAVE_COUNT: 2,
73
+ };
74
+ /**
75
+ * Load initiative and its WUs.
76
+ *
77
+ * @param {string} initRef - Initiative ID or slug
78
+ * @returns {{initiative: object, wus: Array<{id: string, doc: object}>}}
79
+ * @throws {Error} If initiative not found
80
+ */
81
+ export function loadInitiativeWUs(initRef) {
82
+ const initiative = findInitiative(initRef);
83
+ if (!initiative) {
84
+ throw createError(ErrorCodes.INIT_NOT_FOUND, `Initiative '${initRef}' not found. Check the ID or slug.`, { initRef });
85
+ }
86
+ const wus = getInitiativeWUs(initRef);
87
+ return {
88
+ initiative: initiative.doc,
89
+ wus,
90
+ };
91
+ }
92
+ /**
93
+ * Load multiple initiatives and combine their WUs.
94
+ *
95
+ * Used for cross-initiative parallel execution.
96
+ *
97
+ * @param {string[]} initRefs - Array of initiative IDs or slugs
98
+ * @returns {Array<{id: string, doc: object}>} Combined WUs from all initiatives
99
+ * @throws {Error} If any initiative not found
100
+ */
101
+ export function loadMultipleInitiatives(initRefs) {
102
+ const allWUs = [];
103
+ const seenIds = new Set();
104
+ for (const ref of initRefs) {
105
+ const { wus } = loadInitiativeWUs(ref);
106
+ for (const wu of wus) {
107
+ if (!seenIds.has(wu.id)) {
108
+ seenIds.add(wu.id);
109
+ allWUs.push(wu);
110
+ }
111
+ }
112
+ }
113
+ return allWUs;
114
+ }
115
+ /**
116
+ * Build execution plan from WUs.
117
+ *
118
+ * Groups WUs into waves based on dependencies:
119
+ * - Wave 0: All WUs with no blockers (can run in parallel)
120
+ * - Wave 1: WUs blocked by wave 0 WUs only
121
+ * - Wave N: WUs blocked by wave N-1 WUs
122
+ *
123
+ * WU-2430: Enhanced filtering:
124
+ * - Only schedules status: ready WUs (not blocked/in_progress)
125
+ * - Reports skipped WUs with reasons (skippedWithReasons)
126
+ * - Defers WUs with unstamped external dependencies (deferred)
127
+ *
128
+ * @param {Array<{id: string, doc: object}>} wus - WUs to plan
129
+ * @returns {{waves: Array<Array<{id: string, doc: object}>>, skipped: string[], skippedWithReasons: Array<{id: string, reason: string}>, deferred: Array<{id: string, blockedBy: string[], reason: string}>}}
130
+ * @throws {Error} If circular dependencies detected
131
+ */
132
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- wave-building logic inherently complex
133
+ export function buildExecutionPlan(wus) {
134
+ // WU-2430: Enhanced categorisation of WUs
135
+ const skipped = []; // IDs of done WUs (backwards compat)
136
+ const skippedWithReasons = []; // WU-2430: Non-ready WUs with reasons
137
+ const deferred = []; // WU-2430: Ready WUs waiting on external blockers
138
+ const doneStatuses = new Set([WU_STATUS.DONE, WU_STATUS.COMPLETED]);
139
+ // Categorise WUs by status
140
+ for (const wu of wus) {
141
+ const status = wu.doc.status ?? 'unknown';
142
+ if (doneStatuses.has(status)) {
143
+ skipped.push(wu.id);
144
+ }
145
+ else if (status !== WU_STATUS.READY) {
146
+ skippedWithReasons.push({ id: wu.id, reason: `status: ${status}` });
147
+ }
148
+ }
149
+ // WU-2430: Only ready WUs are candidates for execution
150
+ const readyWUs = wus.filter((wu) => wu.doc.status === WU_STATUS.READY);
151
+ if (readyWUs.length === 0) {
152
+ return { waves: [], skipped, skippedWithReasons, deferred };
153
+ }
154
+ // Build a map for quick lookup
155
+ const wuMap = new Map(readyWUs.map((wu) => [wu.id, wu]));
156
+ const wuIds = new Set(wuMap.keys());
157
+ const allWuMap = new Map(wus.map((wu) => [wu.id, wu]));
158
+ const allWuIds = new Set(allWuMap.keys());
159
+ // Build dependency graph for validation (check cycles)
160
+ const graph = buildDependencyGraph();
161
+ const { cycles } = validateGraph(graph);
162
+ // Filter cycles to only those involving our WUs
163
+ const relevantCycles = cycles.filter((cycle) => cycle.some((id) => wuIds.has(id)));
164
+ if (relevantCycles.length > 0) {
165
+ const cycleStr = relevantCycles.map((c) => c.join(' → ')).join('; ');
166
+ throw createError(ErrorCodes.VALIDATION_ERROR, `Circular dependencies detected: ${cycleStr}`, {
167
+ cycles: relevantCycles,
168
+ });
169
+ }
170
+ // WU-2430: Check for external blockers without stamps
171
+ // A WU with blocked_by dependencies that are NOT in the initiative
172
+ // and do NOT have stamps should be deferred
173
+ const deferredIds = new Set();
174
+ const deferredReasons = new Map();
175
+ const deferredBlockers = new Map();
176
+ const addDeferredEntry = (wuId, blockers, reason) => {
177
+ deferredIds.add(wuId);
178
+ if (!deferredReasons.has(wuId)) {
179
+ deferredReasons.set(wuId, new Set());
180
+ }
181
+ if (!deferredBlockers.has(wuId)) {
182
+ deferredBlockers.set(wuId, new Set());
183
+ }
184
+ const reasonSet = deferredReasons.get(wuId);
185
+ const blockerSet = deferredBlockers.get(wuId);
186
+ for (const blockerId of blockers) {
187
+ blockerSet.add(blockerId);
188
+ }
189
+ reasonSet.add(reason);
190
+ };
191
+ for (const wu of readyWUs) {
192
+ const blockers = wu.doc.blocked_by ?? [];
193
+ const externalBlockers = blockers.filter((blockerId) => !allWuIds.has(blockerId));
194
+ const internalBlockers = blockers.filter((blockerId) => allWuIds.has(blockerId));
195
+ if (externalBlockers.length > 0) {
196
+ // Check if any external blockers lack stamps
197
+ const unstampedBlockers = externalBlockers.filter((blockerId) => !hasStamp(blockerId));
198
+ if (unstampedBlockers.length > 0) {
199
+ addDeferredEntry(wu.id, unstampedBlockers, `waiting for external: ${unstampedBlockers.join(', ')}`);
200
+ }
201
+ }
202
+ if (internalBlockers.length > 0) {
203
+ const nonReadyInternal = internalBlockers.filter((blockerId) => {
204
+ const blocker = allWuMap.get(blockerId);
205
+ const status = blocker?.doc?.status ?? 'unknown';
206
+ if (status === WU_STATUS.READY) {
207
+ return false;
208
+ }
209
+ return !doneStatuses.has(status);
210
+ });
211
+ if (nonReadyInternal.length > 0) {
212
+ const details = nonReadyInternal.map((blockerId) => {
213
+ const status = allWuMap.get(blockerId)?.doc?.status ?? 'unknown';
214
+ return `${blockerId} (status: ${status})`;
215
+ });
216
+ addDeferredEntry(wu.id, nonReadyInternal, `waiting for internal: ${details.join(', ')}`);
217
+ }
218
+ }
219
+ }
220
+ let hasNewDeferral = true;
221
+ while (hasNewDeferral) {
222
+ hasNewDeferral = false;
223
+ for (const wu of readyWUs) {
224
+ if (deferredIds.has(wu.id)) {
225
+ continue;
226
+ }
227
+ const blockers = wu.doc.blocked_by || [];
228
+ const deferredInternal = blockers.filter((blockerId) => allWuIds.has(blockerId) && deferredIds.has(blockerId));
229
+ if (deferredInternal.length > 0) {
230
+ const details = deferredInternal.map((blockerId) => {
231
+ const status = allWuMap.get(blockerId)?.doc?.status ?? 'unknown';
232
+ return `${blockerId} (status: ${status})`;
233
+ });
234
+ addDeferredEntry(wu.id, deferredInternal, `waiting for internal: ${details.join(', ')}`);
235
+ hasNewDeferral = true;
236
+ }
237
+ }
238
+ }
239
+ for (const wu of readyWUs) {
240
+ if (deferredIds.has(wu.id)) {
241
+ const blockerSet = deferredBlockers.get(wu.id) || new Set();
242
+ const reasonSet = deferredReasons.get(wu.id) || new Set();
243
+ deferred.push({
244
+ id: wu.id,
245
+ blockedBy: Array.from(blockerSet),
246
+ reason: reasonSet.size > 0 ? Array.from(reasonSet).join('; ') : 'waiting for dependencies',
247
+ });
248
+ }
249
+ }
250
+ // Remove deferred WUs from candidates
251
+ const schedulableWUs = readyWUs.filter((wu) => !deferredIds.has(wu.id));
252
+ const schedulableMap = new Map(schedulableWUs.map((wu) => [wu.id, wu]));
253
+ const schedulableIds = new Set(schedulableMap.keys());
254
+ if (schedulableIds.size === 0) {
255
+ return { waves: [], skipped, skippedWithReasons, deferred };
256
+ }
257
+ // Build waves using Kahn's algorithm (topological sort by levels)
258
+ // WU-1618: Also enforce lane WIP=1 constraint (no two WUs with same lane in same wave)
259
+ const waves = [];
260
+ const remaining = new Set(schedulableIds);
261
+ const completed = new Set(skipped); // Treat done WUs as completed for dependency resolution
262
+ // Also treat stamped external deps as completed
263
+ for (const wu of wus) {
264
+ const blockers = wu.doc.blocked_by || [];
265
+ for (const blockerId of blockers) {
266
+ if (!allWuIds.has(blockerId) && hasStamp(blockerId)) {
267
+ completed.add(blockerId);
268
+ }
269
+ }
270
+ }
271
+ while (remaining.size > 0) {
272
+ const wave = [];
273
+ const lanesInWave = new Set(); // WU-1618: Track lanes used in this wave
274
+ const deferredToNextWave = []; // WUs that could run but lane is occupied
275
+ for (const id of remaining) {
276
+ const wu = schedulableMap.get(id);
277
+ const blockers = wu.doc.blocked_by || [];
278
+ // Check if all blockers are either done or completed in previous waves
279
+ const allBlockersDone = blockers.every((blockerId) => completed.has(blockerId));
280
+ if (allBlockersDone) {
281
+ // WU-1618: Check if lane is already occupied in this wave
282
+ const lane = wu.doc.lane;
283
+ if (lanesInWave.has(lane)) {
284
+ // Defer to next wave (lane conflict)
285
+ deferredToNextWave.push(wu);
286
+ }
287
+ else {
288
+ wave.push(wu);
289
+ lanesInWave.add(lane);
290
+ }
291
+ }
292
+ }
293
+ // Deadlock detection: if no WUs can be scheduled but remaining exist
294
+ // WU-1618: Account for deferred WUs (they can run in next wave, not stuck)
295
+ if (wave.length === 0 && remaining.size > 0 && deferredToNextWave.length === 0) {
296
+ const stuckIds = Array.from(remaining);
297
+ throw createError(ErrorCodes.VALIDATION_ERROR, `Circular or unresolvable dependencies detected. Stuck WUs: ${stuckIds.join(', ')}`, { stuckIds });
298
+ }
299
+ // Add wave and mark WUs as completed
300
+ waves.push(wave);
301
+ for (const wu of wave) {
302
+ remaining.delete(wu.id);
303
+ completed.add(wu.id);
304
+ }
305
+ }
306
+ return { waves, skipped, skippedWithReasons, deferred };
307
+ }
308
+ /**
309
+ * WU-1828: Determine if checkpoint mode should be auto-enabled based on initiative size.
310
+ *
311
+ * Auto-detection triggers checkpoint mode when:
312
+ * - Pending WU count exceeds WU_COUNT threshold (>3)
313
+ * - OR wave count exceeds WAVE_COUNT threshold (>2)
314
+ *
315
+ * This prevents "prompt too long" errors for large initiatives by using
316
+ * checkpoint-per-wave execution instead of polling mode.
317
+ *
318
+ * @param {Array<{id: string, doc: object}>} wus - WUs to analyse
319
+ * @returns {{autoEnabled: boolean, reason: string, pendingCount: number, waveCount: number}}
320
+ */
321
+ export function shouldAutoEnableCheckpoint(wus) {
322
+ // Count only pending WUs (not done)
323
+ const pendingWUs = wus.filter((wu) => wu.doc.status !== WU_STATUS.DONE);
324
+ const pendingCount = pendingWUs.length;
325
+ // Check WU count threshold first (faster check)
326
+ if (pendingCount > CHECKPOINT_AUTO_THRESHOLDS.WU_COUNT) {
327
+ return {
328
+ autoEnabled: true,
329
+ reason: `${pendingCount} pending WUs exceeds threshold (>${CHECKPOINT_AUTO_THRESHOLDS.WU_COUNT})`,
330
+ pendingCount,
331
+ waveCount: -1, // Not computed (early return)
332
+ };
333
+ }
334
+ // Only compute waves if WU count didn't trigger
335
+ if (pendingCount === 0) {
336
+ return {
337
+ autoEnabled: false,
338
+ reason: 'No pending WUs',
339
+ pendingCount: 0,
340
+ waveCount: 0,
341
+ };
342
+ }
343
+ // Build execution plan to count waves
344
+ const plan = buildExecutionPlan(wus);
345
+ const waveCount = plan.waves.length;
346
+ // Check wave count threshold
347
+ if (waveCount > CHECKPOINT_AUTO_THRESHOLDS.WAVE_COUNT) {
348
+ return {
349
+ autoEnabled: true,
350
+ reason: `${waveCount} waves exceeds threshold (>${CHECKPOINT_AUTO_THRESHOLDS.WAVE_COUNT})`,
351
+ pendingCount,
352
+ waveCount,
353
+ };
354
+ }
355
+ return {
356
+ autoEnabled: false,
357
+ reason: `${pendingCount} pending WUs and ${waveCount} waves within thresholds`,
358
+ pendingCount,
359
+ waveCount,
360
+ };
361
+ }
362
+ /**
363
+ * WU-1828: Resolve checkpoint mode from CLI flags and auto-detection.
364
+ * WU-2430: Updated to suppress auto-detection in dry-run mode.
365
+ *
366
+ * Flag precedence:
367
+ * 1. --checkpoint-per-wave (-c): Explicitly enables checkpoint mode
368
+ * 2. --no-checkpoint: Explicitly disables checkpoint mode (overrides auto-detection)
369
+ * 3. --dry-run: Suppresses auto-detection (dry-run uses polling mode for preview)
370
+ * 4. Auto-detection: Enabled based on initiative size if no explicit flags
371
+ *
372
+ * @param {{checkpointPerWave?: boolean, noCheckpoint?: boolean, dryRun?: boolean}} options - CLI options
373
+ * @param {Array<{id: string, doc: object}>} wus - WUs for auto-detection
374
+ * @returns {{enabled: boolean, source: 'explicit'|'override'|'auto'|'dryrun', reason?: string}}
375
+ */
376
+ export function resolveCheckpointMode(options, wus) {
377
+ const { checkpointPerWave = false, noCheckpoint = false, dryRun = false } = options;
378
+ // Explicit enable via -c flag
379
+ if (checkpointPerWave) {
380
+ return {
381
+ enabled: true,
382
+ source: 'explicit',
383
+ reason: 'Enabled via -c/--checkpoint-per-wave flag',
384
+ };
385
+ }
386
+ // Explicit disable via --no-checkpoint flag
387
+ if (noCheckpoint) {
388
+ return {
389
+ enabled: false,
390
+ source: 'override',
391
+ reason: 'Disabled via --no-checkpoint flag',
392
+ };
393
+ }
394
+ // WU-2430: Dry-run suppresses auto-detection (preview should use polling mode)
395
+ if (dryRun) {
396
+ return {
397
+ enabled: false,
398
+ source: 'dryrun',
399
+ reason: 'Disabled in dry-run mode (preview uses polling mode)',
400
+ };
401
+ }
402
+ // Auto-detection
403
+ const autoResult = shouldAutoEnableCheckpoint(wus);
404
+ return {
405
+ enabled: autoResult.autoEnabled,
406
+ source: 'auto',
407
+ reason: autoResult.reason,
408
+ };
409
+ }
410
+ /**
411
+ * Get bottleneck WUs from a set of WUs based on how many downstream WUs they block.
412
+ * A bottleneck is a WU that blocks multiple other WUs.
413
+ *
414
+ * @param {Array<{id: string, doc: object}>} wus - WUs to analyse
415
+ * @param {number} [limit=5] - Maximum number of bottlenecks to return
416
+ * @returns {Array<{id: string, title: string, blocksCount: number}>} Bottleneck WUs sorted by impact
417
+ */
418
+ export function getBottleneckWUs(wus, limit = 5) {
419
+ // Build a map of WU ID -> count of WUs that depend on it
420
+ const blocksCounts = new Map();
421
+ // Initialise all WUs with 0
422
+ for (const wu of wus) {
423
+ blocksCounts.set(wu.id, 0);
424
+ }
425
+ // Count how many WUs each WU blocks
426
+ for (const wu of wus) {
427
+ const blockers = wu.doc.blocked_by || [];
428
+ for (const blockerId of blockers) {
429
+ if (blocksCounts.has(blockerId)) {
430
+ blocksCounts.set(blockerId, blocksCounts.get(blockerId) + 1);
431
+ }
432
+ }
433
+ }
434
+ // Convert to array and filter out WUs that don't block anything
435
+ const bottlenecks = [];
436
+ for (const wu of wus) {
437
+ const blocksCount = blocksCounts.get(wu.id);
438
+ if (blocksCount !== undefined && blocksCount > 0) {
439
+ bottlenecks.push({
440
+ id: wu.id,
441
+ title: wu.doc.title ?? wu.id,
442
+ blocksCount,
443
+ });
444
+ }
445
+ }
446
+ // Sort by blocks count descending
447
+ bottlenecks.sort((a, b) => b.blocksCount - a.blocksCount);
448
+ return bottlenecks.slice(0, limit);
449
+ }
450
+ /**
451
+ * Format execution plan for display.
452
+ *
453
+ * WU-2430: Enhanced to show skippedWithReasons and deferred WUs.
454
+ *
455
+ * @param {object} initiative - Initiative document
456
+ * @param {{waves: Array<Array<{id: string, doc: object}>>, skipped: string[], skippedWithReasons?: Array<{id: string, reason: string}>, deferred?: Array<{id: string, blockedBy: string[], reason: string}>}} plan - Execution plan
457
+ * @returns {string} Formatted plan output
458
+ */
459
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- display formatting inherently complex
460
+ export function formatExecutionPlan(initiative, plan) {
461
+ const lines = [];
462
+ lines.push(`Initiative: ${initiative.id} — ${initiative.title}`);
463
+ lines.push('');
464
+ if (plan.skipped.length > 0) {
465
+ lines.push(`Skipped (already done): ${plan.skipped.join(', ')}`);
466
+ lines.push('');
467
+ }
468
+ // WU-2430: Show WUs skipped due to non-ready status
469
+ if (plan.skippedWithReasons && plan.skippedWithReasons.length > 0) {
470
+ lines.push('Skipped (not ready):');
471
+ for (const entry of plan.skippedWithReasons) {
472
+ lines.push(` - ${entry.id}: ${entry.reason}`);
473
+ }
474
+ lines.push('');
475
+ }
476
+ // WU-2430: Show WUs deferred due to unmet dependencies
477
+ if (plan.deferred && plan.deferred.length > 0) {
478
+ lines.push('Deferred (waiting for dependencies):');
479
+ for (const entry of plan.deferred) {
480
+ lines.push(` - ${entry.id}: ${entry.reason}`);
481
+ if (entry.blockedBy && entry.blockedBy.length > 0) {
482
+ lines.push(` blocked by: ${entry.blockedBy.join(', ')}`);
483
+ }
484
+ }
485
+ lines.push('');
486
+ }
487
+ if (plan.waves.length === 0) {
488
+ lines.push('No pending WUs to execute.');
489
+ return lines.join(STRING_LITERALS.NEWLINE);
490
+ }
491
+ lines.push(`Execution Plan: ${plan.waves.length} wave(s)`);
492
+ lines.push('');
493
+ // Identify bottleneck WUs (WU-1596)
494
+ const allWUs = plan.waves.flat();
495
+ const bottleneckWUs = getBottleneckWUs(allWUs);
496
+ if (bottleneckWUs.length > 0) {
497
+ lines.push('Bottleneck WUs (prioritise these for fastest unblocking):');
498
+ for (const bottleneck of bottleneckWUs) {
499
+ lines.push(` - ${bottleneck.id}: ${bottleneck.title} [blocks ${bottleneck.blocksCount} WU${bottleneck.blocksCount !== 1 ? 's' : ''}]`);
500
+ }
501
+ lines.push('');
502
+ }
503
+ for (let i = 0; i < plan.waves.length; i++) {
504
+ const wave = plan.waves[i];
505
+ lines.push(`Wave ${i} (${wave.length} WU${wave.length !== 1 ? 's' : ''} in parallel):`);
506
+ for (const wu of wave) {
507
+ const blockers = wu.doc.blocked_by || [];
508
+ const blockerStr = blockers.length > 0 ? ` [blocked by: ${blockers.join(', ')}]` : '';
509
+ // Mark bottleneck WUs (WU-1596)
510
+ const isBottleneck = bottleneckWUs.some((b) => b.id === wu.id);
511
+ const bottleneckMarker = isBottleneck ? ' *BOTTLENECK*' : '';
512
+ lines.push(` - ${wu.id}: ${wu.doc.title}${blockerStr}${bottleneckMarker}`);
513
+ }
514
+ lines.push('');
515
+ }
516
+ // Add coordination guidance for multi-wave plans (WU-1592)
517
+ if (plan.waves.length > 1) {
518
+ lines.push('Coordination Guidance:');
519
+ lines.push(' - Poll mem:inbox between waves: pnpm mem:inbox --unread');
520
+ lines.push(' - Check for bug discoveries from sub-agents');
521
+ lines.push(' - Review signals before proceeding to next wave');
522
+ lines.push('');
523
+ }
524
+ return lines.join(STRING_LITERALS.NEWLINE);
525
+ }
526
+ /**
527
+ * Generate spawn commands for a wave of WUs.
528
+ *
529
+ * @param {Array<{id: string, doc: object}>} wave - WUs in the wave
530
+ * @returns {string[]} Array of spawn command strings
531
+ */
532
+ export function generateSpawnCommands(wave) {
533
+ return wave.map((wu) => `pnpm wu:spawn --id ${wu.id}`);
534
+ }
535
+ /**
536
+ * Calculate progress statistics for WUs.
537
+ *
538
+ * @param {Array<{id: string, doc: object}>} wus - WUs to calculate progress for
539
+ * @returns {{total: number, done: number, active: number, pending: number, blocked: number, percentage: number}}
540
+ */
541
+ export function calculateProgress(wus) {
542
+ const stats = {
543
+ total: wus.length,
544
+ done: 0,
545
+ active: 0,
546
+ pending: 0,
547
+ blocked: 0,
548
+ percentage: 0,
549
+ };
550
+ for (const { doc } of wus) {
551
+ switch (doc.status) {
552
+ case WU_STATUS.DONE:
553
+ stats.done++;
554
+ break;
555
+ case WU_STATUS.IN_PROGRESS:
556
+ stats.active++;
557
+ break;
558
+ case WU_STATUS.BLOCKED:
559
+ stats.blocked++;
560
+ break;
561
+ case WU_STATUS.READY:
562
+ stats.pending++;
563
+ break;
564
+ default:
565
+ // Skip other statuses (e.g., cancelled) - counted in total only
566
+ break;
567
+ }
568
+ }
569
+ stats.percentage = stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0;
570
+ return stats;
571
+ }
572
+ /**
573
+ * Format progress for display.
574
+ *
575
+ * @param {{total: number, done: number, active: number, pending: number, blocked: number, percentage: number}} progress
576
+ * @returns {string} Formatted progress string
577
+ */
578
+ export function formatProgress(progress) {
579
+ const bar = createProgressBar(progress.percentage);
580
+ return [
581
+ `Progress: ${bar} ${progress.percentage}%`,
582
+ ` Done: ${progress.done}/${progress.total}`,
583
+ ` Active: ${progress.active}`,
584
+ ` Pending: ${progress.pending}`,
585
+ ` Blocked: ${progress.blocked}`,
586
+ ].join(STRING_LITERALS.NEWLINE);
587
+ }
588
+ /**
589
+ * Create a visual progress bar.
590
+ *
591
+ * @param {number} percentage - Completion percentage (0-100)
592
+ * @param {number} [width=20] - Bar width in characters
593
+ * @returns {string} Visual progress bar
594
+ */
595
+ function createProgressBar(percentage, width = 20) {
596
+ const filled = Math.round((percentage / 100) * width);
597
+ const empty = width - filled;
598
+ return `[${'█'.repeat(filled)}${'░'.repeat(empty)}]`;
599
+ }
600
+ /**
601
+ * Check if a stamp file exists for a WU.
602
+ *
603
+ * @param {string} wuId - WU ID (e.g., 'WU-001')
604
+ * @returns {boolean} True if stamp exists
605
+ */
606
+ function hasStamp(wuId) {
607
+ const stampPath = join(STAMPS_DIR, `${wuId}.done`);
608
+ return existsSync(stampPath);
609
+ }
610
+ /**
611
+ * WU-2040: Filter WUs by dependency stamp status.
612
+ *
613
+ * A WU is only spawnable if ALL its blocked_by dependencies have stamps.
614
+ * This implements the wait-for-completion pattern per Anthropic multi-agent research.
615
+ *
616
+ * @param {Array<{id: string, doc: {blocked_by?: string[], lane: string, status: string}}>} candidates - WU candidates
617
+ * @returns {{spawnable: Array<object>, blocked: Array<object>, blockingDeps: string[], waitingMessage: string}}
618
+ */
619
+ export function filterByDependencyStamps(candidates) {
620
+ const spawnable = [];
621
+ const blocked = [];
622
+ const blockingDeps = new Set();
623
+ for (const wu of candidates) {
624
+ const deps = wu.doc.blocked_by || [];
625
+ // Check if ALL dependencies have stamps
626
+ const unmetDeps = deps.filter((depId) => !hasStamp(depId));
627
+ if (unmetDeps.length === 0) {
628
+ // All deps satisfied (or no deps)
629
+ spawnable.push(wu);
630
+ }
631
+ else {
632
+ // Has unmet dependencies
633
+ blocked.push(wu);
634
+ for (const depId of unmetDeps) {
635
+ blockingDeps.add(depId);
636
+ }
637
+ }
638
+ }
639
+ // Build waiting message if needed
640
+ let waitingMessage = '';
641
+ if (spawnable.length === 0 && blockingDeps.size > 0) {
642
+ const depsArray = Array.from(blockingDeps);
643
+ waitingMessage = `Waiting for ${depsArray.join(', ')} to complete. No WUs can spawn until ${depsArray.length === 1 ? 'this dependency has' : 'these dependencies have'} a stamp.`;
644
+ }
645
+ return {
646
+ spawnable,
647
+ blocked,
648
+ blockingDeps: Array.from(blockingDeps),
649
+ waitingMessage,
650
+ };
651
+ }
652
+ /**
653
+ * Get existing wave manifests for an initiative.
654
+ *
655
+ * @param {string} initId - Initiative ID
656
+ * @returns {Array<{wave: number, wus: Array<{id: string}>}>} Parsed manifests
657
+ */
658
+ function getExistingWaveManifests(initId) {
659
+ if (!existsSync(WAVE_MANIFEST_DIR)) {
660
+ return [];
661
+ }
662
+ const files = readdirSync(WAVE_MANIFEST_DIR);
663
+ const pattern = new RegExp(`^${initId}-wave-(\\d+)\\.json$`);
664
+ const manifests = [];
665
+ for (const file of files) {
666
+ const match = file.match(pattern);
667
+ if (match) {
668
+ try {
669
+ const content = readFileSync(join(WAVE_MANIFEST_DIR, file), 'utf8');
670
+ const manifest = JSON.parse(content);
671
+ manifests.push(manifest);
672
+ }
673
+ catch {
674
+ // Skip invalid manifests
675
+ }
676
+ }
677
+ }
678
+ return manifests.sort((a, b) => a.wave - b.wave);
679
+ }
680
+ /**
681
+ * Get WU IDs that have already been spawned in previous manifests.
682
+ *
683
+ * @param {string} initId - Initiative ID
684
+ * @returns {Set<string>} Set of WU IDs already in manifests
685
+ */
686
+ function getSpawnedWUIds(initId) {
687
+ const manifests = getExistingWaveManifests(initId);
688
+ const spawnedIds = new Set();
689
+ for (const manifest of manifests) {
690
+ if (manifest.wus) {
691
+ for (const wu of manifest.wus) {
692
+ spawnedIds.add(wu.id);
693
+ }
694
+ }
695
+ }
696
+ return spawnedIds;
697
+ }
698
+ /**
699
+ * Determine the next wave number for an initiative.
700
+ *
701
+ * @param {string} initId - Initiative ID
702
+ * @returns {number} Next wave number (0-indexed)
703
+ */
704
+ function getNextWaveNumber(initId) {
705
+ const manifests = getExistingWaveManifests(initId);
706
+ if (manifests.length === 0) {
707
+ return 0;
708
+ }
709
+ const maxWave = Math.max(...manifests.map((m) => m.wave));
710
+ return maxWave + 1;
711
+ }
712
+ /**
713
+ * Validate checkpoint-per-wave flag combinations.
714
+ *
715
+ * WU-1828: Extended to validate --no-checkpoint flag combinations.
716
+ *
717
+ * @param {{checkpointPerWave?: boolean, dryRun?: boolean, noCheckpoint?: boolean}} options - CLI options
718
+ * @throws {Error} If invalid flag combination
719
+ */
720
+ export function validateCheckpointFlags(options) {
721
+ if (options.checkpointPerWave && options.dryRun) {
722
+ throw createError(ErrorCodes.VALIDATION_ERROR, 'Cannot combine --checkpoint-per-wave (-c) with --dry-run (-d). ' +
723
+ 'Checkpoint mode writes manifests and spawns agents.', { flags: { checkpointPerWave: true, dryRun: true } });
724
+ }
725
+ // WU-1828: Validate -c and --no-checkpoint are mutually exclusive
726
+ if (options.checkpointPerWave && options.noCheckpoint) {
727
+ throw createError(ErrorCodes.VALIDATION_ERROR, 'Cannot combine --checkpoint-per-wave (-c) with --no-checkpoint. ' +
728
+ 'These flags are mutually exclusive.', { flags: { checkpointPerWave: true, noCheckpoint: true } });
729
+ }
730
+ }
731
+ /**
732
+ * Build a checkpoint wave for an initiative.
733
+ *
734
+ * WU-1821: Creates a wave manifest file and returns spawn candidates.
735
+ * Implements idempotency: skips WUs with stamps or already in previous manifests.
736
+ *
737
+ * Idempotency precedence (single source of truth):
738
+ * 1. Stamp (highest): .beacon/stamps/WU-XXXX.done exists → WU is done
739
+ * 2. Manifest: WU already in previous wave manifest → skip
740
+ * 3. Status: Only spawn status: ready WUs
741
+ *
742
+ * @param {string} initRef - Initiative ID or slug
743
+ * @returns {{wave: number, wus: Array<{id: string, lane: string, status: string}>, manifestPath: string, initiative: string}|null}
744
+ * Wave data or null if all WUs complete
745
+ */
746
+ export function buildCheckpointWave(initRef, options = {}) {
747
+ const { dryRun = false } = options;
748
+ // Load initiative and WUs
749
+ const initData = findInitiative(initRef);
750
+ if (!initData) {
751
+ throw createError(ErrorCodes.INIT_NOT_FOUND, `Initiative '${initRef}' not found.`, { initRef });
752
+ }
753
+ const initId = initData.id;
754
+ const wus = getInitiativeWUs(initRef);
755
+ // Get already spawned WU IDs from previous manifests
756
+ const spawnedIds = getSpawnedWUIds(initId);
757
+ // Filter to spawn candidates:
758
+ // 1. status: ready only
759
+ // 2. No stamp exists (idempotency)
760
+ // 3. Not already in a previous manifest
761
+ const readyCandidates = wus.filter((wu) => {
762
+ // Only ready WUs
763
+ if (wu.doc.status !== WU_STATUS.READY) {
764
+ return false;
765
+ }
766
+ // Skip if stamp exists (highest precedence)
767
+ if (hasStamp(wu.id)) {
768
+ return false;
769
+ }
770
+ // Skip if already in previous manifest
771
+ if (spawnedIds.has(wu.id)) {
772
+ return false;
773
+ }
774
+ return true;
775
+ });
776
+ // If no ready candidates, all work is done
777
+ if (readyCandidates.length === 0) {
778
+ return null;
779
+ }
780
+ // WU-2040: Filter by dependency stamps (wait-for-completion pattern)
781
+ // A WU is only spawnable if ALL its blocked_by dependencies have stamps
782
+ const depResult = filterByDependencyStamps(readyCandidates);
783
+ // If no spawnable WUs due to unmet dependencies, return blocking info
784
+ if (depResult.spawnable.length === 0) {
785
+ return {
786
+ initiative: initId,
787
+ wave: -1,
788
+ wus: [],
789
+ manifestPath: null,
790
+ blockedBy: depResult.blockingDeps,
791
+ waitingMessage: depResult.waitingMessage,
792
+ };
793
+ }
794
+ // Apply lane WIP=1 constraint: max one WU per lane per wave
795
+ const selectedWUs = [];
796
+ const usedLanes = new Set();
797
+ for (const wu of depResult.spawnable) {
798
+ const lane = wu.doc.lane;
799
+ if (!usedLanes.has(lane)) {
800
+ selectedWUs.push(wu);
801
+ usedLanes.add(lane);
802
+ }
803
+ }
804
+ // Determine wave number
805
+ const waveNum = getNextWaveNumber(initId);
806
+ // Build manifest
807
+ const manifest = {
808
+ initiative: initId,
809
+ wave: waveNum,
810
+ created_at: new Date().toISOString(),
811
+ wus: selectedWUs.map((wu) => ({
812
+ id: wu.id,
813
+ lane: wu.doc.lane,
814
+ status: 'spawned',
815
+ })),
816
+ lane_validation: 'pass',
817
+ done_criteria: 'All stamps exist in .beacon/stamps/',
818
+ };
819
+ // WU-2277: Skip file creation in dry-run mode
820
+ const manifestPath = join(WAVE_MANIFEST_DIR, `${initId}-wave-${waveNum}.json`);
821
+ if (!dryRun) {
822
+ // Ensure directory exists
823
+ if (!existsSync(WAVE_MANIFEST_DIR)) {
824
+ mkdirSync(WAVE_MANIFEST_DIR, { recursive: true });
825
+ }
826
+ // Write manifest
827
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
828
+ }
829
+ return {
830
+ initiative: initId,
831
+ wave: waveNum,
832
+ wus: manifest.wus,
833
+ manifestPath,
834
+ };
835
+ }
836
+ /**
837
+ * Format checkpoint wave output with Task invocations.
838
+ *
839
+ * WU-1821: Token discipline - keep output minimal for context management.
840
+ * WU-2040: Output full Task invocation blocks instead of pnpm wu:spawn meta-prompts.
841
+ * WU-2280: Prevent false wave spawned confusion - use markdown code blocks and ACTION REQUIRED banner.
842
+ * WU-2430: Handle dry-run mode - indicate preview mode clearly.
843
+ *
844
+ * @param {{initiative: string, wave: number, wus: Array<{id: string, lane: string}>, manifestPath: string, blockedBy?: string[], waitingMessage?: string, dryRun?: boolean}} waveData
845
+ * @returns {string} Formatted output with embedded Task invocations
846
+ */
847
+ export function formatCheckpointOutput(waveData) {
848
+ const lines = [];
849
+ const isDryRun = waveData.dryRun === true;
850
+ // WU-2040: Handle blocked case with waiting message
851
+ if (waveData.blockedBy && waveData.blockedBy.length > 0) {
852
+ lines.push(`Waiting for dependencies to complete:`);
853
+ for (const depId of waveData.blockedBy) {
854
+ lines.push(` - ${depId}`);
855
+ }
856
+ lines.push('');
857
+ lines.push(waveData.waitingMessage || 'No WUs can spawn until dependencies have stamps.');
858
+ lines.push('');
859
+ lines.push('Check dependency progress with:');
860
+ lines.push(` pnpm mem:inbox --unread`);
861
+ lines.push(` pnpm orchestrate:initiative -i ${waveData.initiative} -c`);
862
+ return lines.join(STRING_LITERALS.NEWLINE);
863
+ }
864
+ // WU-2430: Dry-run header
865
+ if (isDryRun) {
866
+ lines.push('[DRY-RUN PREVIEW] Checkpoint mode output (no manifest written)');
867
+ lines.push('');
868
+ }
869
+ lines.push(`Wave ${waveData.wave} manifest: ${waveData.manifestPath}`);
870
+ lines.push(`WUs in this wave: ${waveData.wus.length}`);
871
+ for (const wu of waveData.wus) {
872
+ lines.push(` - ${wu.id} (${wu.lane})`);
873
+ }
874
+ lines.push('');
875
+ // WU-2280: ACTION REQUIRED banner - per Anthropic skill best practices
876
+ // Make it unambiguous that agents have NOT been spawned yet
877
+ lines.push(BANNER_SEPARATOR);
878
+ lines.push('ACTION REQUIRED: Agents have NOT been spawned yet.');
879
+ lines.push('');
880
+ lines.push('To spawn agents, copy the XML below and invoke the Task tool.');
881
+ lines.push('The output below is documentation only - it will NOT execute automatically.');
882
+ lines.push(BANNER_SEPARATOR);
883
+ lines.push('');
884
+ // WU-2280: Wrap XML in markdown code block to prevent confusion with actual tool calls
885
+ // Raw XML output could be mistaken for a tool invocation by agents
886
+ lines.push('```xml');
887
+ // Build the Task invocation content
888
+ const xmlLines = [];
889
+ xmlLines.push(XML_PATTERNS.FUNCTION_CALLS_OPEN);
890
+ for (const wu of waveData.wus) {
891
+ try {
892
+ // Generate full Task invocation with embedded spawn prompt
893
+ const fullInvocation = generateEmbeddedSpawnPrompt(wu.id);
894
+ // Extract just the inner invoke block (remove outer function_calls wrapper)
895
+ const startIdx = fullInvocation.indexOf(XML_PATTERNS.INVOKE_OPEN);
896
+ const endIdx = fullInvocation.indexOf(XML_PATTERNS.INVOKE_CLOSE);
897
+ if (startIdx !== -1 && endIdx !== -1) {
898
+ const invokeBlock = fullInvocation.substring(startIdx, endIdx + XML_PATTERNS.INVOKE_CLOSE.length);
899
+ xmlLines.push(invokeBlock);
900
+ }
901
+ }
902
+ catch {
903
+ // Fallback to simple reference if WU file not found
904
+ xmlLines.push(`<!-- Could not generate Task invocation for ${wu.id} -->`);
905
+ }
906
+ }
907
+ xmlLines.push(XML_PATTERNS.FUNCTION_CALLS_CLOSE);
908
+ lines.push(xmlLines.join(STRING_LITERALS.NEWLINE));
909
+ lines.push('```');
910
+ lines.push('');
911
+ lines.push('Resume with:');
912
+ lines.push(` pnpm mem:ready --wu WU-ORCHESTRATOR`);
913
+ lines.push(` pnpm orchestrate:initiative -i ${waveData.initiative} -c`);
914
+ return lines.join(STRING_LITERALS.NEWLINE);
915
+ }
916
+ /**
917
+ * WU-2027: Generate embedded spawn prompt for a WU.
918
+ *
919
+ * Instead of outputting a meta-prompt like "Run: pnpm wu:spawn --id WU-XXX",
920
+ * this function runs the spawn logic internally and returns the full ~3KB
921
+ * prompt content ready for embedding in a Task invocation.
922
+ *
923
+ * This follows Anthropic guidance that sub-agent prompts must be fully
924
+ * self-contained to prevent delegation failures.
925
+ *
926
+ * @param {string} wuId - WU ID (e.g., 'WU-001')
927
+ * @returns {string} Escaped spawn prompt content ready for XML embedding
928
+ * @throws {Error} If WU file not found or cannot be parsed
929
+ */
930
+ export function generateEmbeddedSpawnPrompt(wuId) {
931
+ const wuPath = WU_PATHS.WU(wuId);
932
+ if (!existsSync(wuPath)) {
933
+ throw createError(ErrorCodes.WU_NOT_FOUND, `WU file not found: ${wuPath}`, {
934
+ wuId,
935
+ path: wuPath,
936
+ });
937
+ }
938
+ // Read and parse WU YAML
939
+ const text = readFileSync(wuPath, 'utf8');
940
+ const doc = parseYAML(text);
941
+ // Generate the full Task invocation (includes XML wrapper)
942
+ // The prompt is already XML-escaped in generateTaskInvocation
943
+ return generateTaskInvocation(doc, wuId, {});
944
+ }
945
+ /**
946
+ * WU-2027: Format a Task invocation with embedded spawn content for a WU.
947
+ *
948
+ * Creates a complete Task tool invocation block with the full spawn prompt
949
+ * embedded directly, rather than a meta-prompt referencing wu:spawn.
950
+ *
951
+ * @param {{id: string, doc: object}} wu - WU with id and YAML doc
952
+ * @returns {string} Complete Task invocation with embedded spawn content
953
+ */
954
+ export function formatTaskInvocationWithEmbeddedSpawn(wu) {
955
+ // Generate the full Task invocation for this WU
956
+ return generateTaskInvocation(wu.doc, wu.id, {});
957
+ }
958
+ /**
959
+ * WU-2027: Format execution plan with embedded spawns (no meta-prompts).
960
+ * WU-2280: Updated to use markdown code blocks and ACTION REQUIRED banner.
961
+ *
962
+ * Generates Task invocation blocks for all WUs in the execution plan,
963
+ * with full spawn content embedded directly. This replaces the meta-prompt
964
+ * pattern that was causing delegation failures.
965
+ *
966
+ * @param {{waves: Array<Array<{id: string, doc: object}>>, skipped: string[]}} plan - Execution plan
967
+ * @returns {string} Formatted output with embedded Task invocations
968
+ */
969
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- spawn formatting inherently complex
970
+ export function formatExecutionPlanWithEmbeddedSpawns(plan) {
971
+ const lines = [];
972
+ if (plan.waves.length === 0) {
973
+ return 'No pending WUs to execute.';
974
+ }
975
+ for (let waveIndex = 0; waveIndex < plan.waves.length; waveIndex++) {
976
+ const wave = plan.waves[waveIndex];
977
+ lines.push(`## Wave ${waveIndex} (${wave.length} WU${wave.length !== 1 ? 's' : ''} in parallel)`);
978
+ lines.push('');
979
+ // WU-2280: ACTION REQUIRED banner - per Anthropic skill best practices
980
+ lines.push(BANNER_SEPARATOR);
981
+ lines.push('ACTION REQUIRED: Agents have NOT been spawned yet.');
982
+ lines.push('');
983
+ lines.push('To spawn agents, copy the XML below and invoke the Task tool.');
984
+ lines.push('The output below is documentation only - it will NOT execute automatically.');
985
+ lines.push(BANNER_SEPARATOR);
986
+ lines.push('');
987
+ // WU-2280: Wrap XML in markdown code block to prevent confusion with actual tool calls
988
+ lines.push('```xml');
989
+ // Build parallel spawn block for this wave
990
+ const xmlLines = [];
991
+ const openTag = '<' + 'antml:function_calls>';
992
+ const closeTag = '</' + 'antml:function_calls>';
993
+ xmlLines.push(openTag);
994
+ for (const wu of wave) {
995
+ const fullInvocation = generateTaskInvocation(wu.doc, wu.id, {});
996
+ // Extract just the inner invoke block (remove outer function_calls wrapper)
997
+ // Use indexOf for reliable extraction (regex can have escaping issues)
998
+ const startPattern = '<' + 'antml:invoke';
999
+ const endPattern = '</' + 'antml:invoke>';
1000
+ const startIdx = fullInvocation.indexOf(startPattern);
1001
+ const endIdx = fullInvocation.indexOf(endPattern);
1002
+ if (startIdx !== -1 && endIdx !== -1) {
1003
+ let invokeBlock = fullInvocation.substring(startIdx, endIdx + endPattern.length);
1004
+ // Add run_in_background parameter for parallel execution
1005
+ if (!invokeBlock.includes('run_in_background')) {
1006
+ const paramOpen = '<' + 'antml:parameter name="';
1007
+ const paramClose = '</' + 'antml:parameter>';
1008
+ const invokeTag = '<' + 'antml:invoke name="Task">';
1009
+ invokeBlock = invokeBlock.replace(invokeTag, `${invokeTag}\n${paramOpen}run_in_background">true${paramClose}`);
1010
+ }
1011
+ xmlLines.push(invokeBlock);
1012
+ }
1013
+ }
1014
+ xmlLines.push(closeTag);
1015
+ lines.push(xmlLines.join(STRING_LITERALS.NEWLINE));
1016
+ lines.push('```');
1017
+ lines.push('');
1018
+ if (waveIndex < plan.waves.length - 1) {
1019
+ lines.push(`After all Wave ${waveIndex} agents complete, proceed to Wave ${waveIndex + 1}.`);
1020
+ lines.push('Before next wave: pnpm mem:inbox --unread (check for bug discoveries)');
1021
+ lines.push('');
1022
+ }
1023
+ }
1024
+ return lines.join(STRING_LITERALS.NEWLINE);
1025
+ }
1026
+ export { LOG_PREFIX };