@lumenflow/initiatives 2.18.2 → 2.19.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.
Files changed (41) hide show
  1. package/dist/initiative-orchestrator.d.ts +19 -469
  2. package/dist/initiative-orchestrator.d.ts.map +1 -1
  3. package/dist/initiative-orchestrator.js +25 -1783
  4. package/dist/initiative-orchestrator.js.map +1 -1
  5. package/dist/orchestrator/checkpoint.d.ts +105 -0
  6. package/dist/orchestrator/checkpoint.d.ts.map +1 -0
  7. package/dist/orchestrator/checkpoint.js +444 -0
  8. package/dist/orchestrator/checkpoint.js.map +1 -0
  9. package/dist/orchestrator/execution-planning.d.ts +50 -0
  10. package/dist/orchestrator/execution-planning.d.ts.map +1 -0
  11. package/dist/orchestrator/execution-planning.js +364 -0
  12. package/dist/orchestrator/execution-planning.js.map +1 -0
  13. package/dist/orchestrator/formatting.d.ts +101 -0
  14. package/dist/orchestrator/formatting.d.ts.map +1 -0
  15. package/dist/orchestrator/formatting.js +415 -0
  16. package/dist/orchestrator/formatting.js.map +1 -0
  17. package/dist/orchestrator/index.d.ts +14 -0
  18. package/dist/orchestrator/index.d.ts.map +1 -0
  19. package/dist/orchestrator/index.js +14 -0
  20. package/dist/orchestrator/index.js.map +1 -0
  21. package/dist/orchestrator/initiative-loading.d.ts +30 -0
  22. package/dist/orchestrator/initiative-loading.d.ts.map +1 -0
  23. package/dist/orchestrator/initiative-loading.js +51 -0
  24. package/dist/orchestrator/initiative-loading.js.map +1 -0
  25. package/dist/orchestrator/lane-policy.d.ts +44 -0
  26. package/dist/orchestrator/lane-policy.d.ts.map +1 -0
  27. package/dist/orchestrator/lane-policy.js +129 -0
  28. package/dist/orchestrator/lane-policy.js.map +1 -0
  29. package/dist/orchestrator/shared.d.ts +49 -0
  30. package/dist/orchestrator/shared.d.ts.map +1 -0
  31. package/dist/orchestrator/shared.js +57 -0
  32. package/dist/orchestrator/shared.js.map +1 -0
  33. package/dist/orchestrator/spawn-status.d.ts +56 -0
  34. package/dist/orchestrator/spawn-status.d.ts.map +1 -0
  35. package/dist/orchestrator/spawn-status.js +95 -0
  36. package/dist/orchestrator/spawn-status.js.map +1 -0
  37. package/dist/orchestrator/types.d.ts +148 -0
  38. package/dist/orchestrator/types.d.ts.map +1 -0
  39. package/dist/orchestrator/types.js +9 -0
  40. package/dist/orchestrator/types.js.map +1 -0
  41. package/package.json +2 -2
@@ -1,1792 +1,34 @@
1
1
  /**
2
- * Initiative Orchestrator (WU-1581, WU-1821)
2
+ * Initiative Orchestrator (WU-1581, WU-1821, WU-1648)
3
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.
4
+ * Thin composition layer that re-exports domain modules for initiative orchestration.
5
+ * Actual logic lives in the orchestrator/ subdirectory, split by domain responsibility:
6
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
7
+ * - orchestrator/types.ts -- Shared type definitions
8
+ * - orchestrator/shared.ts -- Shared utilities (hasStamp, getAllDependencies)
9
+ * - orchestrator/execution-planning.ts -- Wave-based execution plan building
10
+ * - orchestrator/checkpoint.ts -- Checkpoint mode, wave manifests, auto-detection
11
+ * - orchestrator/formatting.ts -- Output formatting (plans, progress, spawn XML)
12
+ * - orchestrator/spawn-status.ts -- WU spawn status checking
13
+ * - orchestrator/lane-policy.ts -- Lane lock policy management
14
+ * - orchestrator/initiative-loading.ts -- Initiative/WU loading
17
15
  *
18
16
  * @see {@link packages/@lumenflow/cli/src/orchestrate-initiative.ts} - CLI entry point
19
17
  * @see {@link packages/@lumenflow/cli/src/lib/initiative-yaml.ts} - Initiative loading
20
18
  * @see {@link packages/@lumenflow/cli/src/lib/dependency-graph.ts} - Dependency graph utilities
21
19
  */
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, buildDependencyGraphAsync, validateGraph, } from '@lumenflow/core/dependency-graph';
26
- /**
27
- * WU-1251: Helper to get all dependencies from a WU doc.
28
- *
29
- * Combines both `blocked_by` and `dependencies` arrays for dependency resolution.
30
- * The WU YAML schema supports both:
31
- * - `blocked_by`: Legacy/explicit blockers
32
- * - `dependencies`: Semantic dependencies on other WUs
33
- *
34
- * Both arrays represent the same concept: WUs that must complete before this WU can start.
35
- *
36
- * @param {object} doc - WU document
37
- * @returns {string[]} Combined list of all dependency WU IDs (deduplicated)
38
- */
39
- function getAllDependencies(doc) {
40
- const blockedBy = doc.blocked_by ?? [];
41
- const dependencies = doc.dependencies ?? [];
42
- // Combine and deduplicate
43
- const allDeps = new Set([...blockedBy, ...dependencies]);
44
- return Array.from(allDeps);
45
- }
46
- import { createError, ErrorCodes } from '@lumenflow/core/error-handler';
47
- import { WU_STATUS, STRING_LITERALS } from '@lumenflow/core/wu-constants';
48
- import { WU_PATHS } from '@lumenflow/core/wu-paths';
49
- import { parseYAML } from '@lumenflow/core/wu-yaml';
50
- // WU-2027: Import spawn generation for embedding in orchestration output
51
- import { generateTaskInvocation } from '@lumenflow/core/wu-spawn';
52
- import { SpawnStrategyFactory } from '@lumenflow/core/spawn-strategy';
53
- /**
54
- * Wave manifest directory path (gitignored).
55
- */
56
- const WAVE_MANIFEST_DIR = '.lumenflow/artifacts/waves';
57
- /**
58
- * Stamps directory path.
59
- */
60
- const STAMPS_DIR = '.lumenflow/stamps';
61
- /**
62
- * Log prefix for orchestrator messages.
63
- */
64
- const LOG_PREFIX = '[orchestrate:initiative]';
65
- /**
66
- * WU-2280: Banner separator for ACTION REQUIRED output.
67
- * Used to make it unambiguous that agents have NOT been spawned yet.
68
- */
69
- const BANNER_SEPARATOR = '==============================================================================';
70
- /**
71
- * WU-2040: XML tag patterns for Task invocation extraction.
72
- * Split to avoid XML parsing issues in agent tools.
73
- */
74
- const ANTML_NS = 'antml:';
75
- const XML_PATTERNS = {
76
- FUNCTION_CALLS_OPEN: `<${ANTML_NS}function_calls>`,
77
- FUNCTION_CALLS_CLOSE: `</${ANTML_NS}function_calls>`,
78
- INVOKE_OPEN: `<${ANTML_NS}invoke`,
79
- INVOKE_CLOSE: `</${ANTML_NS}invoke>`,
80
- };
81
- /**
82
- * WU-1828: Auto-detection thresholds for checkpoint mode.
83
- *
84
- * These thresholds determine when checkpoint mode is automatically enabled
85
- * to prevent "prompt too long" errors for large initiatives.
86
- *
87
- * @type {{WU_COUNT: number, WAVE_COUNT: number}}
88
- */
89
- export const CHECKPOINT_AUTO_THRESHOLDS = {
90
- /** Auto-enable checkpoint mode if pending WU count exceeds this (>3 = 4+) */
91
- WU_COUNT: 3,
92
- /** Auto-enable checkpoint mode if wave count exceeds this (>2 = 3+) */
93
- WAVE_COUNT: 2,
94
- };
95
- /**
96
- * WU-1200: Wave manifest WU status constant.
97
- *
98
- * Changed from 'spawned' to 'queued' to prevent confusion.
99
- * 'spawned' implies an agent was launched, but the manifest is written
100
- * BEFORE an agent is actually invoked (when the prompt is output, not when
101
- * the user copies and executes it).
102
- *
103
- * Using 'queued' makes it clear the WU is ready to be spawned but not yet running.
104
- */
105
- const MANIFEST_WU_STATUS = 'queued';
106
- /**
107
- * Default reason string for deferred WUs when no specific reason is provided.
108
- * Extracted to constant to avoid sonarjs/no-duplicate-string lint warnings.
109
- */
110
- const DEFAULT_DEFERRED_REASON = 'waiting for dependencies';
111
- /**
112
- * WU-1200: Get the status string used in wave manifests for WUs.
113
- *
114
- * Returns 'queued' instead of 'spawned' to prevent confusion.
115
- * A WU is 'queued' in the manifest when the spawn prompt is output,
116
- * but it's not actually 'spawned' until an agent claims it.
117
- *
118
- * @returns {string} The manifest WU status ('queued')
119
- */
120
- export function getManifestWUStatus() {
121
- return MANIFEST_WU_STATUS;
122
- }
123
- /**
124
- * WU-1200: Check if a WU has actually been spawned (agent launched).
125
- *
126
- * This checks the WU YAML status, not the wave manifest. A WU is considered
127
- * "actually spawned" only if:
128
- * - Its YAML status is 'in_progress' (agent has claimed it)
129
- * - OR its YAML status is 'done' (agent has completed it)
130
- *
131
- * Wave manifests can have stale 'spawned' statuses from previous runs where
132
- * the prompt was output but no agent was ever invoked. This function provides
133
- * the authoritative check based on YAML status.
134
- *
135
- * @param {string} wuId - WU ID (e.g., 'WU-001')
136
- * @returns {boolean} True if the WU is actually in progress or done
137
- */
138
- export function isWUActuallySpawned(wuId) {
139
- const wuPath = WU_PATHS.WU(wuId);
140
- if (!existsSync(wuPath)) {
141
- // WU file not found - can't determine status, assume not spawned
142
- return false;
143
- }
144
- try {
145
- const text = readFileSync(wuPath, 'utf8');
146
- const doc = parseYAML(text);
147
- const status = doc.status ?? 'unknown';
148
- // WU is "actually spawned" if status indicates active or completed work
149
- return status === WU_STATUS.IN_PROGRESS || status === WU_STATUS.DONE;
150
- }
151
- catch {
152
- // Error reading/parsing WU file - assume not spawned
153
- return false;
154
- }
155
- }
156
- /**
157
- * WU-1200: Get spawn candidates with YAML status verification.
158
- *
159
- * Filters WUs to find candidates that can be spawned, checking YAML status
160
- * instead of relying solely on wave manifests. This prevents stale manifests
161
- * from blocking new orchestration runs.
162
- *
163
- * A WU is a spawn candidate if:
164
- * - Its YAML status is 'ready' (not in_progress, done, blocked, etc.)
165
- * - It's in the provided WU list (part of the initiative)
166
- *
167
- * This function ignores wave manifest status because:
168
- * - Manifests can be stale (prompt output but agent never launched)
169
- * - YAML status is the authoritative source of truth
170
- *
171
- * @param {string} _initId - Initiative ID (for logging/context, not used for filtering)
172
- * @param {Array<{id: string, doc: object}>} wus - WUs to filter
173
- * @returns {Array<{id: string, doc: object}>} WUs that can be spawned
174
- */
175
- export function getSpawnCandidatesWithYAMLCheck(_initId, wus) {
176
- // Filter to only 'ready' status WUs based on YAML, not manifest
177
- return wus.filter((wu) => {
178
- const status = wu.doc.status ?? 'unknown';
179
- return status === WU_STATUS.READY;
180
- });
181
- }
182
- /**
183
- * Load initiative and its WUs.
184
- *
185
- * @param {string} initRef - Initiative ID or slug
186
- * @returns {{initiative: object, wus: Array<{id: string, doc: object}>}}
187
- * @throws {Error} If initiative not found
188
- */
189
- export function loadInitiativeWUs(initRef) {
190
- const initiative = findInitiative(initRef);
191
- if (!initiative) {
192
- throw createError(ErrorCodes.INIT_NOT_FOUND, `Initiative '${initRef}' not found. Check the ID or slug.`, { initRef });
193
- }
194
- const wus = getInitiativeWUs(initRef);
195
- return {
196
- initiative: initiative.doc,
197
- wus,
198
- };
199
- }
200
- /**
201
- * Load multiple initiatives and combine their WUs.
202
- *
203
- * Used for cross-initiative parallel execution.
204
- *
205
- * @param {string[]} initRefs - Array of initiative IDs or slugs
206
- * @returns {Array<{id: string, doc: object}>} Combined WUs from all initiatives
207
- * @throws {Error} If any initiative not found
208
- */
209
- export function loadMultipleInitiatives(initRefs) {
210
- const allWUs = [];
211
- const seenIds = new Set();
212
- for (const ref of initRefs) {
213
- const { wus } = loadInitiativeWUs(ref);
214
- for (const wu of wus) {
215
- if (!seenIds.has(wu.id)) {
216
- seenIds.add(wu.id);
217
- allWUs.push(wu);
218
- }
219
- }
220
- }
221
- return allWUs;
222
- }
223
- /**
224
- * Build execution plan from WUs.
225
- *
226
- * Groups WUs into waves based on dependencies:
227
- * - Wave 0: All WUs with no blockers (can run in parallel)
228
- * - Wave 1: WUs blocked by wave 0 WUs only
229
- * - Wave N: WUs blocked by wave N-1 WUs
230
- *
231
- * WU-2430: Enhanced filtering:
232
- * - Only schedules status: ready WUs (not blocked/in_progress)
233
- * - Reports skipped WUs with reasons (skippedWithReasons)
234
- * - Defers WUs with unstamped external dependencies (deferred)
235
- *
236
- * @param {Array<{id: string, doc: object}>} wus - WUs to plan
237
- * @returns {{waves: Array<Array<{id: string, doc: object}>>, skipped: string[], skippedWithReasons: Array<{id: string, reason: string}>, deferred: Array<{id: string, blockedBy: string[], reason: string}>}}
238
- * @throws {Error} If circular dependencies detected
239
- */
240
- // eslint-disable-next-line sonarjs/cognitive-complexity -- wave-building logic inherently complex
241
- export function buildExecutionPlan(wus) {
242
- // WU-2430: Enhanced categorisation of WUs
243
- const skipped = []; // IDs of done WUs (backwards compat)
244
- const skippedWithReasons = []; // WU-2430: Non-ready WUs with reasons
245
- const deferred = []; // WU-2430: Ready WUs waiting on external blockers
246
- const doneStatuses = new Set([WU_STATUS.DONE, WU_STATUS.COMPLETED]);
247
- // Categorise WUs by status
248
- for (const wu of wus) {
249
- const status = wu.doc.status ?? 'unknown';
250
- if (doneStatuses.has(status)) {
251
- skipped.push(wu.id);
252
- }
253
- else if (status !== WU_STATUS.READY) {
254
- skippedWithReasons.push({ id: wu.id, reason: `status: ${status}` });
255
- }
256
- }
257
- // WU-2430: Only ready WUs are candidates for execution
258
- const readyWUs = wus.filter((wu) => wu.doc.status === WU_STATUS.READY);
259
- if (readyWUs.length === 0) {
260
- return { waves: [], skipped, skippedWithReasons, deferred };
261
- }
262
- // Build a map for quick lookup
263
- const wuMap = new Map(readyWUs.map((wu) => [wu.id, wu]));
264
- const wuIds = new Set(wuMap.keys());
265
- const allWuMap = new Map(wus.map((wu) => [wu.id, wu]));
266
- const allWuIds = new Set(allWuMap.keys());
267
- // Build dependency graph for validation (check cycles)
268
- const graph = buildDependencyGraph();
269
- const { cycles } = validateGraph(graph);
270
- // Filter cycles to only those involving our WUs
271
- const relevantCycles = cycles.filter((cycle) => cycle.some((id) => wuIds.has(id)));
272
- if (relevantCycles.length > 0) {
273
- const cycleStr = relevantCycles.map((c) => c.join(' → ')).join('; ');
274
- throw createError(ErrorCodes.VALIDATION_ERROR, `Circular dependencies detected: ${cycleStr}`, {
275
- cycles: relevantCycles,
276
- });
277
- }
278
- // WU-2430: Check for external blockers without stamps
279
- // A WU with blocked_by dependencies that are NOT in the initiative
280
- // and do NOT have stamps should be deferred
281
- const deferredIds = new Set();
282
- const deferredReasons = new Map();
283
- const deferredBlockers = new Map();
284
- const addDeferredEntry = (wuId, blockers, reason) => {
285
- deferredIds.add(wuId);
286
- if (!deferredReasons.has(wuId)) {
287
- deferredReasons.set(wuId, new Set());
288
- }
289
- if (!deferredBlockers.has(wuId)) {
290
- deferredBlockers.set(wuId, new Set());
291
- }
292
- const reasonSet = deferredReasons.get(wuId);
293
- const blockerSet = deferredBlockers.get(wuId);
294
- for (const blockerId of blockers) {
295
- blockerSet.add(blockerId);
296
- }
297
- reasonSet.add(reason);
298
- };
299
- for (const wu of readyWUs) {
300
- // WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
301
- const blockers = getAllDependencies(wu.doc);
302
- const externalBlockers = blockers.filter((blockerId) => !allWuIds.has(blockerId));
303
- const internalBlockers = blockers.filter((blockerId) => allWuIds.has(blockerId));
304
- if (externalBlockers.length > 0) {
305
- // Check if any external blockers lack stamps
306
- const unstampedBlockers = externalBlockers.filter((blockerId) => !hasStamp(blockerId));
307
- if (unstampedBlockers.length > 0) {
308
- addDeferredEntry(wu.id, unstampedBlockers, `waiting for external: ${unstampedBlockers.join(', ')}`);
309
- }
310
- }
311
- if (internalBlockers.length > 0) {
312
- const nonReadyInternal = internalBlockers.filter((blockerId) => {
313
- const blocker = allWuMap.get(blockerId);
314
- const status = blocker?.doc?.status ?? 'unknown';
315
- if (status === WU_STATUS.READY) {
316
- return false;
317
- }
318
- return !doneStatuses.has(status);
319
- });
320
- if (nonReadyInternal.length > 0) {
321
- const details = nonReadyInternal.map((blockerId) => {
322
- const status = allWuMap.get(blockerId)?.doc?.status ?? 'unknown';
323
- return `${blockerId} (status: ${status})`;
324
- });
325
- addDeferredEntry(wu.id, nonReadyInternal, `waiting for internal: ${details.join(', ')}`);
326
- }
327
- }
328
- }
329
- let hasNewDeferral = true;
330
- while (hasNewDeferral) {
331
- hasNewDeferral = false;
332
- for (const wu of readyWUs) {
333
- if (deferredIds.has(wu.id)) {
334
- continue;
335
- }
336
- // WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
337
- const blockers = getAllDependencies(wu.doc);
338
- const deferredInternal = blockers.filter((blockerId) => allWuIds.has(blockerId) && deferredIds.has(blockerId));
339
- if (deferredInternal.length > 0) {
340
- const details = deferredInternal.map((blockerId) => {
341
- const status = allWuMap.get(blockerId)?.doc?.status ?? 'unknown';
342
- return `${blockerId} (status: ${status})`;
343
- });
344
- addDeferredEntry(wu.id, deferredInternal, `waiting for internal: ${details.join(', ')}`);
345
- hasNewDeferral = true;
346
- }
347
- }
348
- }
349
- for (const wu of readyWUs) {
350
- if (deferredIds.has(wu.id)) {
351
- const blockerSet = deferredBlockers.get(wu.id) || new Set();
352
- const reasonSet = deferredReasons.get(wu.id) || new Set();
353
- deferred.push({
354
- id: wu.id,
355
- blockedBy: Array.from(blockerSet),
356
- reason: reasonSet.size > 0 ? Array.from(reasonSet).join('; ') : DEFAULT_DEFERRED_REASON,
357
- });
358
- }
359
- }
360
- // Remove deferred WUs from candidates
361
- const schedulableWUs = readyWUs.filter((wu) => !deferredIds.has(wu.id));
362
- const schedulableMap = new Map(schedulableWUs.map((wu) => [wu.id, wu]));
363
- const schedulableIds = new Set(schedulableMap.keys());
364
- if (schedulableIds.size === 0) {
365
- return { waves: [], skipped, skippedWithReasons, deferred };
366
- }
367
- // Build waves using Kahn's algorithm (topological sort by levels)
368
- // WU-1618: Also enforce lane WIP=1 constraint (no two WUs with same lane in same wave)
369
- const waves = [];
370
- const remaining = new Set(schedulableIds);
371
- const completed = new Set(skipped); // Treat done WUs as completed for dependency resolution
372
- // Also treat stamped external deps as completed
373
- for (const wu of wus) {
374
- // WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
375
- const blockers = getAllDependencies(wu.doc);
376
- for (const blockerId of blockers) {
377
- if (!allWuIds.has(blockerId) && hasStamp(blockerId)) {
378
- completed.add(blockerId);
379
- }
380
- }
381
- }
382
- while (remaining.size > 0) {
383
- const wave = [];
384
- const lanesInWave = new Set(); // WU-1618: Track lanes used in this wave
385
- const deferredToNextWave = []; // WUs that could run but lane is occupied
386
- for (const id of remaining) {
387
- const wu = schedulableMap.get(id);
388
- // WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
389
- const blockers = getAllDependencies(wu.doc);
390
- // Check if all blockers are either done or completed in previous waves
391
- const allBlockersDone = blockers.every((blockerId) => completed.has(blockerId));
392
- if (allBlockersDone) {
393
- // WU-1618: Check if lane is already occupied in this wave
394
- const lane = wu.doc.lane;
395
- if (lanesInWave.has(lane)) {
396
- // Defer to next wave (lane conflict)
397
- deferredToNextWave.push(wu);
398
- }
399
- else {
400
- wave.push(wu);
401
- lanesInWave.add(lane);
402
- }
403
- }
404
- }
405
- // Deadlock detection: if no WUs can be scheduled but remaining exist
406
- // WU-1618: Account for deferred WUs (they can run in next wave, not stuck)
407
- if (wave.length === 0 && remaining.size > 0 && deferredToNextWave.length === 0) {
408
- const stuckIds = Array.from(remaining);
409
- throw createError(ErrorCodes.VALIDATION_ERROR, `Circular or unresolvable dependencies detected. Stuck WUs: ${stuckIds.join(', ')}`, { stuckIds });
410
- }
411
- // Add wave and mark WUs as completed
412
- waves.push(wave);
413
- for (const wu of wave) {
414
- remaining.delete(wu.id);
415
- completed.add(wu.id);
416
- }
417
- }
418
- return { waves, skipped, skippedWithReasons, deferred };
419
- }
420
- /**
421
- * Build execution plan from WUs asynchronously.
422
- *
423
- * @param {Array<{id: string, doc: object}>} wus - WUs to plan
424
- * @returns {Promise<ExecutionPlan>}
425
- */
426
- export async function buildExecutionPlanAsync(wus) {
427
- // WU-2430: Enhanced categorisation of WUs
428
- const skipped = []; // IDs of done WUs (backwards compat)
429
- const skippedWithReasons = []; // WU-2430: Non-ready WUs with reasons
430
- const deferred = []; // WU-2430: Ready WUs waiting on external blockers
431
- const doneStatuses = new Set([WU_STATUS.DONE, WU_STATUS.COMPLETED]);
432
- // Categorise WUs by status
433
- for (const wu of wus) {
434
- const status = wu.doc.status ?? 'unknown';
435
- if (doneStatuses.has(status)) {
436
- skipped.push(wu.id);
437
- }
438
- else if (status !== WU_STATUS.READY) {
439
- skippedWithReasons.push({ id: wu.id, reason: `status: ${status}` });
440
- }
441
- }
442
- // WU-2430: Only ready WUs are candidates for execution
443
- const readyWUs = wus.filter((wu) => wu.doc.status === WU_STATUS.READY);
444
- if (readyWUs.length === 0) {
445
- return { waves: [], skipped, skippedWithReasons, deferred };
446
- }
447
- // Build a map for quick lookup
448
- const wuMap = new Map(readyWUs.map((wu) => [wu.id, wu]));
449
- const wuIds = new Set(wuMap.keys());
450
- const allWuMap = new Map(wus.map((wu) => [wu.id, wu]));
451
- const allWuIds = new Set(allWuMap.keys());
452
- // Build dependency graph for validation (check cycles)
453
- const graph = await buildDependencyGraphAsync();
454
- const { cycles } = validateGraph(graph);
455
- // Filter cycles to only those involving our WUs
456
- const relevantCycles = cycles.filter((cycle) => cycle.some((id) => wuIds.has(id)));
457
- if (relevantCycles.length > 0) {
458
- const cycleStr = relevantCycles.map((c) => c.join(' → ')).join('; ');
459
- throw createError(ErrorCodes.VALIDATION_ERROR, `Circular dependencies detected: ${cycleStr}`, {
460
- cycles: relevantCycles,
461
- });
462
- }
463
- // WU-2430: Check for external blockers without stamps
464
- // A WU with blocked_by dependencies that are NOT in the initiative
465
- // and do NOT have stamps should be deferred
466
- const deferredIds = new Set();
467
- const deferredReasons = new Map();
468
- const deferredBlockers = new Map();
469
- const addDeferredEntry = (wuId, blockers, reason) => {
470
- deferredIds.add(wuId);
471
- if (!deferredReasons.has(wuId)) {
472
- deferredReasons.set(wuId, new Set());
473
- }
474
- if (!deferredBlockers.has(wuId)) {
475
- deferredBlockers.set(wuId, new Set());
476
- }
477
- const reasonSet = deferredReasons.get(wuId);
478
- const blockerSet = deferredBlockers.get(wuId);
479
- for (const blockerId of blockers) {
480
- blockerSet.add(blockerId);
481
- }
482
- reasonSet.add(reason);
483
- };
484
- for (const wu of readyWUs) {
485
- // WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
486
- const blockers = getAllDependencies(wu.doc);
487
- const externalBlockers = blockers.filter((blockerId) => !allWuIds.has(blockerId));
488
- const internalBlockers = blockers.filter((blockerId) => allWuIds.has(blockerId));
489
- if (externalBlockers.length > 0) {
490
- // Check if any external blockers lack stamps
491
- const unstampedBlockers = externalBlockers.filter((blockerId) => !hasStamp(blockerId));
492
- if (unstampedBlockers.length > 0) {
493
- addDeferredEntry(wu.id, unstampedBlockers, `waiting for external: ${unstampedBlockers.join(', ')}`);
494
- }
495
- }
496
- if (internalBlockers.length > 0) {
497
- const nonReadyInternal = internalBlockers.filter((blockerId) => {
498
- const blocker = allWuMap.get(blockerId);
499
- const status = blocker?.doc?.status ?? 'unknown';
500
- if (status === WU_STATUS.READY) {
501
- return false;
502
- }
503
- return !doneStatuses.has(status);
504
- });
505
- if (nonReadyInternal.length > 0) {
506
- const details = nonReadyInternal.map((blockerId) => {
507
- const status = allWuMap.get(blockerId)?.doc?.status ?? 'unknown';
508
- return `${blockerId} (status: ${status})`;
509
- });
510
- addDeferredEntry(wu.id, nonReadyInternal, `waiting for internal: ${details.join(', ')}`);
511
- }
512
- }
513
- }
514
- let hasNewDeferral = true;
515
- while (hasNewDeferral) {
516
- hasNewDeferral = false;
517
- for (const wu of readyWUs) {
518
- if (deferredIds.has(wu.id)) {
519
- continue;
520
- }
521
- // WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
522
- const blockers = getAllDependencies(wu.doc);
523
- const deferredInternal = blockers.filter((blockerId) => allWuIds.has(blockerId) && deferredIds.has(blockerId));
524
- if (deferredInternal.length > 0) {
525
- const details = deferredInternal.map((blockerId) => {
526
- const status = allWuMap.get(blockerId)?.doc?.status ?? 'unknown';
527
- return `${blockerId} (status: ${status})`;
528
- });
529
- addDeferredEntry(wu.id, deferredInternal, `waiting for internal: ${details.join(', ')}`);
530
- hasNewDeferral = true;
531
- }
532
- }
533
- }
534
- for (const wu of readyWUs) {
535
- if (deferredIds.has(wu.id)) {
536
- const blockerSet = deferredBlockers.get(wu.id) || new Set();
537
- const reasonSet = deferredReasons.get(wu.id) || new Set();
538
- deferred.push({
539
- id: wu.id,
540
- blockedBy: Array.from(blockerSet),
541
- reason: reasonSet.size > 0 ? Array.from(reasonSet).join('; ') : DEFAULT_DEFERRED_REASON,
542
- });
543
- }
544
- }
545
- // Remove deferred WUs from candidates
546
- const schedulableWUs = readyWUs.filter((wu) => !deferredIds.has(wu.id));
547
- const schedulableMap = new Map(schedulableWUs.map((wu) => [wu.id, wu]));
548
- const schedulableIds = new Set(schedulableMap.keys());
549
- if (schedulableIds.size === 0) {
550
- return { waves: [], skipped, skippedWithReasons, deferred };
551
- }
552
- // Build waves using Kahn's algorithm (topological sort by levels)
553
- // WU-1618: Also enforce lane WIP=1 constraint (no two WUs with same lane in same wave)
554
- const waves = [];
555
- const remaining = new Set(schedulableIds);
556
- const completed = new Set(skipped); // Treat done WUs as completed for dependency resolution
557
- // Also treat stamped external deps as completed
558
- for (const wu of wus) {
559
- // WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
560
- const blockers = getAllDependencies(wu.doc);
561
- for (const blockerId of blockers) {
562
- if (!allWuIds.has(blockerId) && hasStamp(blockerId)) {
563
- completed.add(blockerId);
564
- }
565
- }
566
- }
567
- while (remaining.size > 0) {
568
- const wave = [];
569
- const lanesInWave = new Set(); // WU-1618: Track lanes used in this wave
570
- const deferredToNextWave = []; // WUs that could run but lane is occupied
571
- for (const id of remaining) {
572
- const wu = schedulableMap.get(id);
573
- // WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
574
- const blockers = getAllDependencies(wu.doc);
575
- // Check if all blockers are either done or completed in previous waves
576
- const allBlockersDone = blockers.every((blockerId) => completed.has(blockerId));
577
- if (allBlockersDone) {
578
- // WU-1618: Check if lane is already occupied in this wave
579
- const lane = wu.doc.lane;
580
- if (lanesInWave.has(lane)) {
581
- // Defer to next wave (lane conflict)
582
- deferredToNextWave.push(wu);
583
- }
584
- else {
585
- wave.push(wu);
586
- lanesInWave.add(lane);
587
- }
588
- }
589
- }
590
- // Deadlock detection: if no WUs can be scheduled but remaining exist
591
- // WU-1618: Account for deferred WUs (they can run in next wave, not stuck)
592
- if (wave.length === 0 && remaining.size > 0 && deferredToNextWave.length === 0) {
593
- const stuckIds = Array.from(remaining);
594
- throw createError(ErrorCodes.VALIDATION_ERROR, `Circular or unresolvable dependencies detected. Stuck WUs: ${stuckIds.join(', ')}`, { stuckIds });
595
- }
596
- // Add wave and mark WUs as completed
597
- waves.push(wave);
598
- for (const wu of wave) {
599
- remaining.delete(wu.id);
600
- completed.add(wu.id);
601
- }
602
- }
603
- return { waves, skipped, skippedWithReasons, deferred };
604
- }
605
- /**
606
- * WU-1828: Determine if checkpoint mode should be auto-enabled based on initiative size.
607
- *
608
- * Auto-detection triggers checkpoint mode when:
609
- * - Pending WU count exceeds WU_COUNT threshold (>3)
610
- * - OR wave count exceeds WAVE_COUNT threshold (>2)
611
- *
612
- * This prevents "prompt too long" errors for large initiatives by using
613
- * checkpoint-per-wave execution instead of polling mode.
614
- *
615
- * @param {Array<{id: string, doc: object}>} wus - WUs to analyse
616
- * @returns {{autoEnabled: boolean, reason: string, pendingCount: number, waveCount: number}}
617
- */
618
- export function shouldAutoEnableCheckpoint(wus) {
619
- // Count only pending WUs (not done)
620
- const pendingWUs = wus.filter((wu) => wu.doc.status !== WU_STATUS.DONE);
621
- const pendingCount = pendingWUs.length;
622
- // Check WU count threshold first (faster check)
623
- if (pendingCount > CHECKPOINT_AUTO_THRESHOLDS.WU_COUNT) {
624
- return {
625
- autoEnabled: true,
626
- reason: `${pendingCount} pending WUs exceeds threshold (>${CHECKPOINT_AUTO_THRESHOLDS.WU_COUNT})`,
627
- pendingCount,
628
- waveCount: -1, // Not computed (early return)
629
- };
630
- }
631
- // Only compute waves if WU count didn't trigger
632
- if (pendingCount === 0) {
633
- return {
634
- autoEnabled: false,
635
- reason: 'No pending WUs',
636
- pendingCount: 0,
637
- waveCount: 0,
638
- };
639
- }
640
- // Build execution plan to count waves
641
- const plan = buildExecutionPlan(wus);
642
- const waveCount = plan.waves.length;
643
- // Check wave count threshold
644
- if (waveCount > CHECKPOINT_AUTO_THRESHOLDS.WAVE_COUNT) {
645
- return {
646
- autoEnabled: true,
647
- reason: `${waveCount} waves exceeds threshold (>${CHECKPOINT_AUTO_THRESHOLDS.WAVE_COUNT})`,
648
- pendingCount,
649
- waveCount,
650
- };
651
- }
652
- return {
653
- autoEnabled: false,
654
- reason: `${pendingCount} pending WUs and ${waveCount} waves within thresholds`,
655
- pendingCount,
656
- waveCount,
657
- };
658
- }
659
- /**
660
- * WU-1828: Determine if checkpoint mode should be auto-enabled based on initiative size asynchronously.
661
- *
662
- * @param {Array<{id: string, doc: object}>} wus - WUs to analyse
663
- * @returns {Promise<{autoEnabled: boolean, reason: string, pendingCount: number, waveCount: number}>}
664
- */
665
- export async function shouldAutoEnableCheckpointAsync(wus) {
666
- // Count only pending WUs (not done)
667
- const pendingWUs = wus.filter((wu) => wu.doc.status !== WU_STATUS.DONE);
668
- const pendingCount = pendingWUs.length;
669
- // Check WU count threshold first (faster check)
670
- if (pendingCount > CHECKPOINT_AUTO_THRESHOLDS.WU_COUNT) {
671
- return {
672
- autoEnabled: true,
673
- reason: `${pendingCount} pending WUs exceeds threshold (>${CHECKPOINT_AUTO_THRESHOLDS.WU_COUNT})`,
674
- pendingCount,
675
- waveCount: -1, // Not computed (early return)
676
- };
677
- }
678
- // Only compute waves if WU count didn't trigger
679
- if (pendingCount === 0) {
680
- return {
681
- autoEnabled: false,
682
- reason: 'No pending WUs',
683
- pendingCount: 0,
684
- waveCount: 0,
685
- };
686
- }
687
- // Build execution plan to count waves
688
- const plan = await buildExecutionPlanAsync(wus);
689
- const waveCount = plan.waves.length;
690
- // Check wave count threshold
691
- if (waveCount > CHECKPOINT_AUTO_THRESHOLDS.WAVE_COUNT) {
692
- return {
693
- autoEnabled: true,
694
- reason: `${waveCount} waves exceeds threshold (>${CHECKPOINT_AUTO_THRESHOLDS.WAVE_COUNT})`,
695
- pendingCount,
696
- waveCount,
697
- };
698
- }
699
- return {
700
- autoEnabled: false,
701
- reason: `${pendingCount} pending WUs and ${waveCount} waves within thresholds`,
702
- pendingCount,
703
- waveCount,
704
- };
705
- }
706
- /**
707
- * WU-1828: Resolve checkpoint mode from CLI flags and auto-detection.
708
- * WU-2430: Updated to suppress auto-detection in dry-run mode.
709
- *
710
- * Flag precedence:
711
- * 1. --checkpoint-per-wave (-c): Explicitly enables checkpoint mode
712
- * 2. --no-checkpoint: Explicitly disables checkpoint mode (overrides auto-detection)
713
- * 3. --dry-run: Suppresses auto-detection (dry-run uses polling mode for preview)
714
- * 4. Auto-detection: Enabled based on initiative size if no explicit flags
715
- *
716
- * @param {{checkpointPerWave?: boolean, noCheckpoint?: boolean, dryRun?: boolean}} options - CLI options
717
- * @param {Array<{id: string, doc: object}>} wus - WUs for auto-detection
718
- * @returns {{enabled: boolean, source: 'explicit'|'override'|'auto'|'dryrun', reason?: string}}
719
- */
720
- export function resolveCheckpointMode(options, wus) {
721
- const { checkpointPerWave = false, noCheckpoint = false, dryRun = false } = options;
722
- // Explicit enable via -c flag
723
- if (checkpointPerWave) {
724
- return {
725
- enabled: true,
726
- source: 'explicit',
727
- reason: 'Enabled via -c/--checkpoint-per-wave flag',
728
- };
729
- }
730
- // Explicit disable via --no-checkpoint flag
731
- if (noCheckpoint) {
732
- return {
733
- enabled: false,
734
- source: 'override',
735
- reason: 'Disabled via --no-checkpoint flag',
736
- };
737
- }
738
- // WU-2430: Dry-run suppresses auto-detection (preview should use polling mode)
739
- if (dryRun) {
740
- return {
741
- enabled: false,
742
- source: 'dryrun',
743
- reason: 'Disabled in dry-run mode (preview uses polling mode)',
744
- };
745
- }
746
- // Auto-detection
747
- const autoResult = shouldAutoEnableCheckpoint(wus);
748
- return {
749
- enabled: autoResult.autoEnabled,
750
- source: 'auto',
751
- reason: autoResult.reason,
752
- };
753
- }
754
- /**
755
- * WU-1828: Resolve checkpoint mode from CLI flags and auto-detection asynchronously.
756
- *
757
- * @param {{checkpointPerWave?: boolean, noCheckpoint?: boolean, dryRun?: boolean}} options - CLI options
758
- * @param {Array<{id: string, doc: object}>} wus - WUs for auto-detection
759
- * @returns {Promise<{enabled: boolean, source: 'explicit'|'override'|'auto'|'dryrun', reason?: string}>}
760
- */
761
- export async function resolveCheckpointModeAsync(options, wus) {
762
- const { checkpointPerWave = false, noCheckpoint = false, dryRun = false } = options;
763
- // Explicit enable via -c flag
764
- if (checkpointPerWave) {
765
- return {
766
- enabled: true,
767
- source: 'explicit',
768
- reason: 'Enabled via -c/--checkpoint-per-wave flag',
769
- };
770
- }
771
- // Explicit disable via --no-checkpoint flag
772
- if (noCheckpoint) {
773
- return {
774
- enabled: false,
775
- source: 'override',
776
- reason: 'Disabled via --no-checkpoint flag',
777
- };
778
- }
779
- // WU-2430: Dry-run suppresses auto-detection (preview should use polling mode)
780
- if (dryRun) {
781
- return {
782
- enabled: false,
783
- source: 'dryrun',
784
- reason: 'Disabled in dry-run mode (preview uses polling mode)',
785
- };
786
- }
787
- // Auto-detection
788
- const autoResult = await shouldAutoEnableCheckpointAsync(wus);
789
- return {
790
- enabled: autoResult.autoEnabled,
791
- source: 'auto',
792
- reason: autoResult.reason,
793
- };
794
- }
795
- /**
796
- * Get bottleneck WUs from a set of WUs based on how many downstream WUs they block.
797
- * A bottleneck is a WU that blocks multiple other WUs.
798
- *
799
- * @param {Array<{id: string, doc: object}>} wus - WUs to analyse
800
- * @param {number} [limit=5] - Maximum number of bottlenecks to return
801
- * @returns {Array<{id: string, title: string, blocksCount: number}>} Bottleneck WUs sorted by impact
802
- */
803
- export function getBottleneckWUs(wus, limit = 5) {
804
- // Build a map of WU ID -> count of WUs that depend on it
805
- const blocksCounts = new Map();
806
- // Initialise all WUs with 0
807
- for (const wu of wus) {
808
- blocksCounts.set(wu.id, 0);
809
- }
810
- // Count how many WUs each WU blocks
811
- for (const wu of wus) {
812
- // WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
813
- const blockers = getAllDependencies(wu.doc);
814
- for (const blockerId of blockers) {
815
- if (blocksCounts.has(blockerId)) {
816
- blocksCounts.set(blockerId, blocksCounts.get(blockerId) + 1);
817
- }
818
- }
819
- }
820
- // Convert to array and filter out WUs that don't block anything
821
- const bottlenecks = [];
822
- for (const wu of wus) {
823
- const blocksCount = blocksCounts.get(wu.id);
824
- if (blocksCount !== undefined && blocksCount > 0) {
825
- bottlenecks.push({
826
- id: wu.id,
827
- title: wu.doc.title ?? wu.id,
828
- blocksCount,
829
- });
830
- }
831
- }
832
- // Sort by blocks count descending
833
- bottlenecks.sort((a, b) => b.blocksCount - a.blocksCount);
834
- return bottlenecks.slice(0, limit);
835
- }
836
- /**
837
- * Format execution plan for display.
838
- *
839
- * WU-2430: Enhanced to show skippedWithReasons and deferred WUs.
840
- *
841
- * @param {object} initiative - Initiative document
842
- * @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
843
- * @returns {string} Formatted plan output
844
- */
845
- // eslint-disable-next-line sonarjs/cognitive-complexity -- display formatting inherently complex
846
- export function formatExecutionPlan(initiative, plan) {
847
- const lines = [];
848
- lines.push(`Initiative: ${initiative.id} — ${initiative.title}`);
849
- lines.push('');
850
- if (plan.skipped.length > 0) {
851
- lines.push(`Skipped (already done): ${plan.skipped.join(', ')}`);
852
- lines.push('');
853
- }
854
- // WU-2430: Show WUs skipped due to non-ready status
855
- if (plan.skippedWithReasons && plan.skippedWithReasons.length > 0) {
856
- lines.push('Skipped (not ready):');
857
- for (const entry of plan.skippedWithReasons) {
858
- lines.push(` - ${entry.id}: ${entry.reason}`);
859
- }
860
- lines.push('');
861
- }
862
- // WU-2430: Show WUs deferred due to unmet dependencies
863
- if (plan.deferred && plan.deferred.length > 0) {
864
- lines.push('Deferred (waiting for dependencies):');
865
- for (const entry of plan.deferred) {
866
- lines.push(` - ${entry.id}: ${entry.reason}`);
867
- if (entry.blockedBy && entry.blockedBy.length > 0) {
868
- lines.push(` blocked by: ${entry.blockedBy.join(', ')}`);
869
- }
870
- }
871
- lines.push('');
872
- }
873
- if (plan.waves.length === 0) {
874
- lines.push('No pending WUs to execute.');
875
- return lines.join(STRING_LITERALS.NEWLINE);
876
- }
877
- lines.push(`Execution Plan: ${plan.waves.length} wave(s)`);
878
- lines.push('');
879
- // Identify bottleneck WUs (WU-1596)
880
- const allWUs = plan.waves.flat();
881
- const bottleneckWUs = getBottleneckWUs(allWUs);
882
- if (bottleneckWUs.length > 0) {
883
- lines.push('Bottleneck WUs (prioritise these for fastest unblocking):');
884
- for (const bottleneck of bottleneckWUs) {
885
- lines.push(` - ${bottleneck.id}: ${bottleneck.title} [blocks ${bottleneck.blocksCount} WU${bottleneck.blocksCount !== 1 ? 's' : ''}]`);
886
- }
887
- lines.push('');
888
- }
889
- for (let i = 0; i < plan.waves.length; i++) {
890
- const wave = plan.waves[i];
891
- lines.push(`Wave ${i} (${wave.length} WU${wave.length !== 1 ? 's' : ''} in parallel):`);
892
- for (const wu of wave) {
893
- // WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
894
- const blockers = getAllDependencies(wu.doc);
895
- const blockerStr = blockers.length > 0 ? ` [blocked by: ${blockers.join(', ')}]` : '';
896
- // Mark bottleneck WUs (WU-1596)
897
- const isBottleneck = bottleneckWUs.some((b) => b.id === wu.id);
898
- const bottleneckMarker = isBottleneck ? ' *BOTTLENECK*' : '';
899
- lines.push(` - ${wu.id}: ${wu.doc.title}${blockerStr}${bottleneckMarker}`);
900
- }
901
- lines.push('');
902
- }
903
- // Add coordination guidance for multi-wave plans (WU-1592)
904
- if (plan.waves.length > 1) {
905
- lines.push('Coordination Guidance:');
906
- lines.push(' - Poll mem:inbox between waves: pnpm mem:inbox --since 10m');
907
- lines.push(' - Check for bug discoveries from sub-agents');
908
- lines.push(' - Review signals before proceeding to next wave');
909
- lines.push('');
910
- }
911
- return lines.join(STRING_LITERALS.NEWLINE);
912
- }
913
- /**
914
- * Generate spawn commands for a wave of WUs.
915
- *
916
- * @param {Array<{id: string, doc: object}>} wave - WUs in the wave
917
- * @returns {string[]} Array of spawn command strings
918
- */
919
- export function generateSpawnCommands(wave) {
920
- return wave.map((wu) => `pnpm wu:delegate --id ${wu.id} --parent-wu <PARENT-WU-ID> --client claude-code`);
921
- }
922
- /**
923
- * Calculate progress statistics for WUs.
924
- *
925
- * @param {Array<{id: string, doc: object}>} wus - WUs to calculate progress for
926
- * @returns {{total: number, done: number, active: number, pending: number, blocked: number, percentage: number}}
927
- */
928
- export function calculateProgress(wus) {
929
- const stats = {
930
- total: wus.length,
931
- done: 0,
932
- active: 0,
933
- pending: 0,
934
- blocked: 0,
935
- percentage: 0,
936
- };
937
- for (const { doc } of wus) {
938
- switch (doc.status) {
939
- case WU_STATUS.DONE:
940
- stats.done++;
941
- break;
942
- case WU_STATUS.IN_PROGRESS:
943
- stats.active++;
944
- break;
945
- case WU_STATUS.BLOCKED:
946
- stats.blocked++;
947
- break;
948
- case WU_STATUS.READY:
949
- stats.pending++;
950
- break;
951
- default:
952
- // Skip other statuses (e.g., cancelled) - counted in total only
953
- break;
954
- }
955
- }
956
- stats.percentage = stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0;
957
- return stats;
958
- }
959
- /**
960
- * Format progress for display.
961
- *
962
- * @param {{total: number, done: number, active: number, pending: number, blocked: number, percentage: number}} progress
963
- * @returns {string} Formatted progress string
964
- */
965
- export function formatProgress(progress) {
966
- const bar = createProgressBar(progress.percentage);
967
- return [
968
- `Progress: ${bar} ${progress.percentage}%`,
969
- ` Done: ${progress.done}/${progress.total}`,
970
- ` Active: ${progress.active}`,
971
- ` Pending: ${progress.pending}`,
972
- ` Blocked: ${progress.blocked}`,
973
- ].join(STRING_LITERALS.NEWLINE);
974
- }
975
- /**
976
- * Create a visual progress bar.
977
- *
978
- * @param {number} percentage - Completion percentage (0-100)
979
- * @param {number} [width=20] - Bar width in characters
980
- * @returns {string} Visual progress bar
981
- */
982
- function createProgressBar(percentage, width = 20) {
983
- const filled = Math.round((percentage / 100) * width);
984
- const empty = width - filled;
985
- return `[${'█'.repeat(filled)}${'░'.repeat(empty)}]`;
986
- }
987
- /**
988
- * Check if a stamp file exists for a WU.
989
- *
990
- * @param {string} wuId - WU ID (e.g., 'WU-001')
991
- * @returns {boolean} True if stamp exists
992
- */
993
- function hasStamp(wuId) {
994
- const stampPath = join(STAMPS_DIR, `${wuId}.done`);
995
- return existsSync(stampPath);
996
- }
997
- /**
998
- * WU-2040: Filter WUs by dependency stamp status.
999
- * WU-1251: Now checks both blocked_by AND dependencies arrays.
1000
- *
1001
- * A WU is only spawnable if ALL its dependencies have stamps.
1002
- * This implements the wait-for-completion pattern per Anthropic multi-agent research.
1003
- *
1004
- * @param {Array<{id: string, doc: {blocked_by?: string[], dependencies?: string[], lane: string, status: string}}>} candidates - WU candidates
1005
- * @returns {{spawnable: Array<object>, blocked: Array<object>, blockingDeps: string[], waitingMessage: string}}
1006
- */
1007
- export function filterByDependencyStamps(candidates) {
1008
- const spawnable = [];
1009
- const blocked = [];
1010
- const blockingDeps = new Set();
1011
- for (const wu of candidates) {
1012
- // WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
1013
- const deps = getAllDependencies(wu.doc);
1014
- // Check if ALL dependencies have stamps
1015
- const unmetDeps = deps.filter((depId) => !hasStamp(depId));
1016
- if (unmetDeps.length === 0) {
1017
- // All deps satisfied (or no deps)
1018
- spawnable.push(wu);
1019
- }
1020
- else {
1021
- // Has unmet dependencies
1022
- blocked.push(wu);
1023
- for (const depId of unmetDeps) {
1024
- blockingDeps.add(depId);
1025
- }
1026
- }
1027
- }
1028
- // Build waiting message if needed
1029
- let waitingMessage = '';
1030
- if (spawnable.length === 0 && blockingDeps.size > 0) {
1031
- const depsArray = Array.from(blockingDeps);
1032
- waitingMessage = `Waiting for ${depsArray.join(', ')} to complete. No WUs can spawn until ${depsArray.length === 1 ? 'this dependency has' : 'these dependencies have'} a stamp.`;
1033
- }
1034
- return {
1035
- spawnable,
1036
- blocked,
1037
- blockingDeps: Array.from(blockingDeps),
1038
- waitingMessage,
1039
- };
1040
- }
1041
- /**
1042
- * Get existing wave manifests for an initiative.
1043
- *
1044
- * @param {string} initId - Initiative ID
1045
- * @returns {Array<{wave: number, wus: Array<{id: string}>}>} Parsed manifests
1046
- */
1047
- function getExistingWaveManifests(initId) {
1048
- if (!existsSync(WAVE_MANIFEST_DIR)) {
1049
- return [];
1050
- }
1051
- const files = readdirSync(WAVE_MANIFEST_DIR);
1052
- // eslint-disable-next-line security/detect-non-literal-regexp -- initId from internal state, not user input
1053
- const pattern = new RegExp(`^${initId}-wave-(\\d+)\\.json$`);
1054
- const manifests = [];
1055
- for (const file of files) {
1056
- const match = file.match(pattern);
1057
- if (match) {
1058
- try {
1059
- const content = readFileSync(join(WAVE_MANIFEST_DIR, file), 'utf8');
1060
- const manifest = JSON.parse(content);
1061
- manifests.push(manifest);
1062
- }
1063
- catch {
1064
- // Skip invalid manifests
1065
- }
1066
- }
1067
- }
1068
- return manifests.sort((a, b) => a.wave - b.wave);
1069
- }
1070
- /**
1071
- * Get WU IDs that have already been spawned in previous manifests.
1072
- *
1073
- * @param {string} initId - Initiative ID
1074
- * @returns {Set<string>} Set of WU IDs already in manifests
1075
- */
1076
- function _getSpawnedWUIds(initId) {
1077
- const manifests = getExistingWaveManifests(initId);
1078
- const spawnedIds = new Set();
1079
- for (const manifest of manifests) {
1080
- if (manifest.wus) {
1081
- for (const wu of manifest.wus) {
1082
- spawnedIds.add(wu.id);
1083
- }
1084
- }
1085
- }
1086
- return spawnedIds;
1087
- }
1088
- /**
1089
- * Determine the next wave number for an initiative.
1090
- *
1091
- * @param {string} initId - Initiative ID
1092
- * @returns {number} Next wave number (0-indexed)
1093
- */
1094
- function getNextWaveNumber(initId) {
1095
- const manifests = getExistingWaveManifests(initId);
1096
- if (manifests.length === 0) {
1097
- return 0;
1098
- }
1099
- const maxWave = Math.max(...manifests.map((m) => m.wave));
1100
- return maxWave + 1;
1101
- }
1102
- /**
1103
- * Validate checkpoint-per-wave flag combinations.
1104
- *
1105
- * WU-1828: Extended to validate --no-checkpoint flag combinations.
1106
- *
1107
- * @param {{checkpointPerWave?: boolean, dryRun?: boolean, noCheckpoint?: boolean}} options - CLI options
1108
- * @throws {Error} If invalid flag combination
1109
- */
1110
- export function validateCheckpointFlags(options) {
1111
- if (options.checkpointPerWave && options.dryRun) {
1112
- throw createError(ErrorCodes.VALIDATION_ERROR, 'Cannot combine --checkpoint-per-wave (-c) with --dry-run (-d). ' +
1113
- 'Checkpoint mode writes manifests and spawns agents.', { flags: { checkpointPerWave: true, dryRun: true } });
1114
- }
1115
- // WU-1828: Validate -c and --no-checkpoint are mutually exclusive
1116
- if (options.checkpointPerWave && options.noCheckpoint) {
1117
- throw createError(ErrorCodes.VALIDATION_ERROR, 'Cannot combine --checkpoint-per-wave (-c) with --no-checkpoint. ' +
1118
- 'These flags are mutually exclusive.', { flags: { checkpointPerWave: true, noCheckpoint: true } });
1119
- }
1120
- }
1121
- /**
1122
- * Build a checkpoint wave for an initiative.
1123
- *
1124
- * WU-1821: Creates a wave manifest file and returns spawn candidates.
1125
- * Implements idempotency: skips WUs with stamps or already in previous manifests.
1126
- *
1127
- * Idempotency precedence (single source of truth):
1128
- * 1. Stamp (highest): .lumenflow/stamps/WU-XXXX.done exists → WU is done
1129
- * 2. Manifest: WU already in previous wave manifest → skip
1130
- * 3. Status: Only spawn status: ready WUs
1131
- *
1132
- * @param {string} initRef - Initiative ID or slug
1133
- * @returns {{wave: number, wus: Array<{id: string, lane: string, status: string}>, manifestPath: string, initiative: string}|null}
1134
- * Wave data or null if all WUs complete
1135
- */
1136
- export function buildCheckpointWave(initRef, options = {}) {
1137
- const { dryRun = false } = options;
1138
- // Load initiative and WUs
1139
- const initData = findInitiative(initRef);
1140
- if (!initData) {
1141
- throw createError(ErrorCodes.INIT_NOT_FOUND, `Initiative '${initRef}' not found.`, { initRef });
1142
- }
1143
- const initId = initData.id;
1144
- const wus = getInitiativeWUs(initRef);
1145
- // WU-1200: Check YAML status, not just wave manifests
1146
- // Wave manifests can be stale (prompt output but agent never launched)
1147
- // YAML status is the authoritative source of truth
1148
- // Filter to spawn candidates:
1149
- // 1. status: ready only (from YAML - authoritative)
1150
- // 2. No stamp exists (idempotency)
1151
- // 3. WU is not actually spawned (YAML status not in_progress/done)
1152
- //
1153
- // Note: We no longer rely on wave manifests for exclusion because:
1154
- // - Manifests can be stale (AC3: Stale wave manifests don't block new runs)
1155
- // - YAML status is updated when an agent actually claims the WU
1156
- const readyCandidates = wus.filter((wu) => {
1157
- // Only ready WUs (YAML status - authoritative)
1158
- if (wu.doc.status !== WU_STATUS.READY) {
1159
- return false;
1160
- }
1161
- // Skip if stamp exists (highest precedence - WU is complete)
1162
- if (hasStamp(wu.id)) {
1163
- return false;
1164
- }
1165
- // WU-1200: Check YAML status, not manifest status
1166
- // If YAML says 'ready', the WU hasn't been claimed yet, so it's spawnable
1167
- // (even if a stale manifest says it was 'spawned')
1168
- return true;
1169
- });
1170
- // If no ready candidates, all work is done
1171
- if (readyCandidates.length === 0) {
1172
- return null;
1173
- }
1174
- // WU-2040: Filter by dependency stamps (wait-for-completion pattern)
1175
- // A WU is only spawnable if ALL its blocked_by dependencies have stamps
1176
- const depResult = filterByDependencyStamps(readyCandidates);
1177
- // If no spawnable WUs due to unmet dependencies, return blocking info
1178
- if (depResult.spawnable.length === 0) {
1179
- return {
1180
- initiative: initId,
1181
- wave: -1,
1182
- wus: [],
1183
- manifestPath: null,
1184
- blockedBy: depResult.blockingDeps,
1185
- waitingMessage: depResult.waitingMessage,
1186
- };
1187
- }
1188
- // Apply lane WIP=1 constraint: max one WU per lane per wave
1189
- const selectedWUs = [];
1190
- const usedLanes = new Set();
1191
- for (const wu of depResult.spawnable) {
1192
- const lane = wu.doc.lane;
1193
- if (!usedLanes.has(lane)) {
1194
- selectedWUs.push(wu);
1195
- usedLanes.add(lane);
1196
- }
1197
- }
1198
- // Determine wave number
1199
- const waveNum = getNextWaveNumber(initId);
1200
- // Build manifest
1201
- // WU-1200: Use 'queued' status instead of 'spawned' to prevent confusion
1202
- // 'queued' indicates the WU is ready to spawn, not that an agent was launched
1203
- const manifest = {
1204
- initiative: initId,
1205
- wave: waveNum,
1206
- created_at: new Date().toISOString(),
1207
- wus: selectedWUs.map((wu) => ({
1208
- id: wu.id,
1209
- lane: wu.doc.lane,
1210
- status: MANIFEST_WU_STATUS,
1211
- })),
1212
- lane_validation: 'pass',
1213
- done_criteria: 'All stamps exist in .lumenflow/stamps/',
1214
- };
1215
- // WU-2277: Skip file creation in dry-run mode
1216
- const manifestPath = join(WAVE_MANIFEST_DIR, `${initId}-wave-${waveNum}.json`);
1217
- if (!dryRun) {
1218
- // Ensure directory exists
1219
- if (!existsSync(WAVE_MANIFEST_DIR)) {
1220
- mkdirSync(WAVE_MANIFEST_DIR, { recursive: true });
1221
- }
1222
- // Write manifest
1223
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
1224
- }
1225
- return {
1226
- initiative: initId,
1227
- wave: waveNum,
1228
- wus: manifest.wus,
1229
- manifestPath,
1230
- };
1231
- }
1232
- /**
1233
- * Format checkpoint wave output with Task invocations.
1234
- *
1235
- * WU-1821: Token discipline - keep output minimal for context management.
1236
- * WU-2040: Output full Task invocation blocks instead of pnpm wu:spawn meta-prompts.
1237
- * WU-2280: Prevent false wave spawned confusion - use markdown code blocks and ACTION REQUIRED banner.
1238
- * WU-2430: Handle dry-run mode - indicate preview mode clearly.
1239
- *
1240
- * @param {{initiative: string, wave: number, wus: Array<{id: string, lane: string}>, manifestPath: string, blockedBy?: string[], waitingMessage?: string, dryRun?: boolean}} waveData
1241
- * @returns {string} Formatted output with embedded Task invocations
1242
- */
1243
- export function formatCheckpointOutput(waveData) {
1244
- const lines = [];
1245
- const isDryRun = waveData.dryRun === true;
1246
- // WU-2040: Handle blocked case with waiting message
1247
- if (waveData.blockedBy && waveData.blockedBy.length > 0) {
1248
- lines.push(`Waiting for dependencies to complete:`);
1249
- for (const depId of waveData.blockedBy) {
1250
- lines.push(` - ${depId}`);
1251
- }
1252
- lines.push('');
1253
- lines.push(waveData.waitingMessage || 'No WUs can spawn until dependencies have stamps.');
1254
- lines.push('');
1255
- lines.push('Check dependency progress with:');
1256
- lines.push(` pnpm mem:inbox --since 10m`);
1257
- lines.push(` pnpm orchestrate:initiative -i ${waveData.initiative} -c`);
1258
- return lines.join(STRING_LITERALS.NEWLINE);
1259
- }
1260
- // WU-2430: Dry-run header
1261
- if (isDryRun) {
1262
- lines.push('[DRY-RUN PREVIEW] Checkpoint mode output (no manifest written)');
1263
- lines.push('');
1264
- }
1265
- lines.push(`Wave ${waveData.wave} manifest: ${waveData.manifestPath}`);
1266
- lines.push(`WUs in this wave: ${waveData.wus.length}`);
1267
- for (const wu of waveData.wus) {
1268
- lines.push(` - ${wu.id} (${wu.lane})`);
1269
- }
1270
- lines.push('');
1271
- // WU-2280: ACTION REQUIRED banner - per Anthropic skill best practices
1272
- // Make it unambiguous that agents have NOT been spawned yet
1273
- lines.push(BANNER_SEPARATOR);
1274
- lines.push('ACTION REQUIRED: Agents have NOT been spawned yet.');
1275
- lines.push('');
1276
- lines.push('To spawn agents, copy the XML below and invoke the Task tool.');
1277
- lines.push('The output below is documentation only - it will NOT execute automatically.');
1278
- lines.push(BANNER_SEPARATOR);
1279
- lines.push('');
1280
- // WU-2280: Wrap XML in markdown code block to prevent confusion with actual tool calls
1281
- // Raw XML output could be mistaken for a tool invocation by agents
1282
- lines.push('```xml');
1283
- // Build the Task invocation content
1284
- const xmlLines = [];
1285
- xmlLines.push(XML_PATTERNS.FUNCTION_CALLS_OPEN);
1286
- for (const wu of waveData.wus) {
1287
- try {
1288
- // Generate full Task invocation with embedded spawn prompt
1289
- const fullInvocation = generateEmbeddedSpawnPrompt(wu.id);
1290
- // Extract just the inner invoke block (remove outer function_calls wrapper)
1291
- const startIdx = fullInvocation.indexOf(XML_PATTERNS.INVOKE_OPEN);
1292
- const endIdx = fullInvocation.indexOf(XML_PATTERNS.INVOKE_CLOSE);
1293
- if (startIdx !== -1 && endIdx !== -1) {
1294
- const invokeBlock = fullInvocation.substring(startIdx, endIdx + XML_PATTERNS.INVOKE_CLOSE.length);
1295
- xmlLines.push(invokeBlock);
1296
- }
1297
- }
1298
- catch {
1299
- // Fallback to simple reference if WU file not found
1300
- xmlLines.push(`<!-- Could not generate Task invocation for ${wu.id} -->`);
1301
- }
1302
- }
1303
- xmlLines.push(XML_PATTERNS.FUNCTION_CALLS_CLOSE);
1304
- lines.push(xmlLines.join(STRING_LITERALS.NEWLINE));
1305
- lines.push('```');
1306
- lines.push('');
1307
- lines.push('Resume with:');
1308
- lines.push(` pnpm mem:ready --wu WU-ORCHESTRATOR`);
1309
- lines.push(` pnpm orchestrate:initiative -i ${waveData.initiative} -c`);
1310
- return lines.join(STRING_LITERALS.NEWLINE);
1311
- }
1312
- /**
1313
- * WU-2027: Generate embedded spawn prompt for a WU.
1314
- *
1315
- * Instead of outputting a meta-prompt like "Run: pnpm wu:spawn --id WU-XXX",
1316
- * this function runs the spawn logic internally and returns the full ~3KB
1317
- * prompt content ready for embedding in a Task invocation.
1318
- *
1319
- * This follows Anthropic guidance that sub-agent prompts must be fully
1320
- * self-contained to prevent delegation failures.
1321
- *
1322
- * @param {string} wuId - WU ID (e.g., 'WU-001')
1323
- * @returns {string} Escaped spawn prompt content ready for XML embedding
1324
- * @throws {Error} If WU file not found or cannot be parsed
1325
- */
1326
- export function generateEmbeddedSpawnPrompt(wuId) {
1327
- const wuPath = WU_PATHS.WU(wuId);
1328
- if (!existsSync(wuPath)) {
1329
- throw createError(ErrorCodes.WU_NOT_FOUND, `WU file not found: ${wuPath}`, {
1330
- wuId,
1331
- path: wuPath,
1332
- });
1333
- }
1334
- // Read and parse WU YAML
1335
- const text = readFileSync(wuPath, 'utf8');
1336
- const doc = parseYAML(text);
1337
- // Generate the full Task invocation (includes XML wrapper)
1338
- // The prompt is already XML-escaped in generateTaskInvocation
1339
- return generateTaskInvocation(doc, wuId, SpawnStrategyFactory.create('claude-code'));
1340
- }
1341
- /**
1342
- * WU-2027: Format a Task invocation with embedded spawn content for a WU.
1343
- *
1344
- * Creates a complete Task tool invocation block with the full spawn prompt
1345
- * embedded directly, rather than a meta-prompt referencing wu:spawn.
1346
- *
1347
- * @param {{id: string, doc: object}} wu - WU with id and YAML doc
1348
- * @returns {string} Complete Task invocation with embedded spawn content
1349
- */
1350
- export function formatTaskInvocationWithEmbeddedSpawn(wu) {
1351
- // Generate the full Task invocation for this WU
1352
- return generateTaskInvocation(wu.doc, wu.id, SpawnStrategyFactory.create('claude-code'));
1353
- }
1354
- /**
1355
- * WU-2027: Format execution plan with embedded spawns (no meta-prompts).
1356
- * WU-2280: Updated to use markdown code blocks and ACTION REQUIRED banner.
1357
- *
1358
- * Generates Task invocation blocks for all WUs in the execution plan,
1359
- * with full spawn content embedded directly. This replaces the meta-prompt
1360
- * pattern that was causing delegation failures.
1361
- *
1362
- * @param {{waves: Array<Array<{id: string, doc: object}>>, skipped: string[]}} plan - Execution plan
1363
- * @returns {string} Formatted output with embedded Task invocations
1364
- */
1365
- export function formatExecutionPlanWithEmbeddedSpawns(plan) {
1366
- const lines = [];
1367
- if (plan.waves.length === 0) {
1368
- return 'No pending WUs to execute.';
1369
- }
1370
- for (let waveIndex = 0; waveIndex < plan.waves.length; waveIndex++) {
1371
- const wave = plan.waves[waveIndex];
1372
- lines.push(`## Wave ${waveIndex} (${wave.length} WU${wave.length !== 1 ? 's' : ''} in parallel)`);
1373
- lines.push('');
1374
- // WU-2280: ACTION REQUIRED banner - per Anthropic skill best practices
1375
- lines.push(BANNER_SEPARATOR);
1376
- lines.push('ACTION REQUIRED: Agents have NOT been spawned yet.');
1377
- lines.push('');
1378
- lines.push('To spawn agents, copy the XML below and invoke the Task tool.');
1379
- lines.push('The output below is documentation only - it will NOT execute automatically.');
1380
- lines.push(BANNER_SEPARATOR);
1381
- lines.push('');
1382
- // WU-2280: Wrap XML in markdown code block to prevent confusion with actual tool calls
1383
- lines.push('```xml');
1384
- // Build parallel spawn block for this wave
1385
- const xmlLines = [];
1386
- const openTag = '<' + 'antml:function_calls>';
1387
- const closeTag = '</' + 'antml:function_calls>';
1388
- xmlLines.push(openTag);
1389
- for (const wu of wave) {
1390
- const fullInvocation = generateTaskInvocation(wu.doc, wu.id, SpawnStrategyFactory.create('claude-code'));
1391
- // Extract just the inner invoke block (remove outer function_calls wrapper)
1392
- // Use indexOf for reliable extraction (regex can have escaping issues)
1393
- const startPattern = '<' + 'antml:invoke';
1394
- const endPattern = '</' + 'antml:invoke>';
1395
- const startIdx = fullInvocation.indexOf(startPattern);
1396
- const endIdx = fullInvocation.indexOf(endPattern);
1397
- if (startIdx !== -1 && endIdx !== -1) {
1398
- let invokeBlock = fullInvocation.substring(startIdx, endIdx + endPattern.length);
1399
- // Add run_in_background parameter for parallel execution
1400
- if (!invokeBlock.includes('run_in_background')) {
1401
- const paramOpen = '<' + 'antml:parameter name="';
1402
- const paramClose = '</' + 'antml:parameter>';
1403
- const invokeTag = '<' + 'antml:invoke name="Task">';
1404
- invokeBlock = invokeBlock.replace(invokeTag, `${invokeTag}\n${paramOpen}run_in_background">true${paramClose}`);
1405
- }
1406
- xmlLines.push(invokeBlock);
1407
- }
1408
- }
1409
- xmlLines.push(closeTag);
1410
- lines.push(xmlLines.join(STRING_LITERALS.NEWLINE));
1411
- lines.push('```');
1412
- lines.push('');
1413
- if (waveIndex < plan.waves.length - 1) {
1414
- lines.push(`After all Wave ${waveIndex} agents complete, proceed to Wave ${waveIndex + 1}.`);
1415
- lines.push('Before next wave: pnpm mem:inbox --since 10m (check for bug discoveries)');
1416
- lines.push('');
1417
- }
1418
- }
1419
- return lines.join(STRING_LITERALS.NEWLINE);
1420
- }
1421
- /**
1422
- * WU-1326: Get lock_policy for a lane from configuration.
1423
- *
1424
- * Returns the lock_policy from config if specified, otherwise defaults to 'all'
1425
- * for backward compatibility.
1426
- *
1427
- * @param {string} lane - Lane name (e.g., 'Framework: Core')
1428
- * @param {Record<string, LaneConfig> | undefined} laneConfigs - Lane configurations
1429
- * @returns {LockPolicy} The lock_policy for the lane ('all' | 'active' | 'none')
1430
- */
1431
- export function getLockPolicyForLane(lane, laneConfigs) {
1432
- if (!laneConfigs) {
1433
- return 'all'; // Default for backward compatibility
1434
- }
1435
- const config = laneConfigs[lane];
1436
- if (!config || !config.lock_policy) {
1437
- return 'all'; // Default for unspecified lanes
1438
- }
1439
- return config.lock_policy;
1440
- }
1441
- /**
1442
- * WU-1326: Check if a WU status holds the lane lock based on lock_policy.
1443
- *
1444
- * - policy=all: both 'in_progress' and 'blocked' hold lane lock
1445
- * - policy=active: only 'in_progress' holds lane lock
1446
- * - policy=none: nothing holds lane lock (no WIP checking)
1447
- *
1448
- * @param {string} status - WU status
1449
- * @param {LockPolicy} policy - Lane lock policy
1450
- * @returns {boolean} True if status holds lane lock
1451
- */
1452
- function _statusHoldsLaneLock(status, policy) {
1453
- if (policy === 'none') {
1454
- return false; // No WIP checking
1455
- }
1456
- if (policy === 'active') {
1457
- // Only in_progress holds lane lock
1458
- return status === WU_STATUS.IN_PROGRESS;
1459
- }
1460
- // policy === 'all' (default) - both in_progress and blocked hold lane
1461
- return status === WU_STATUS.IN_PROGRESS || status === WU_STATUS.BLOCKED;
1462
- }
1463
- /**
1464
- * WU-1326: Build execution plan respecting lock_policy per lane.
1465
- *
1466
- * This is an enhanced version of buildExecutionPlan that respects lock_policy
1467
- * when determining lane occupancy for wave building.
1468
- *
1469
- * When policy=active, blocked WUs do NOT prevent ready WUs in the same lane
1470
- * from being scheduled in the same wave.
1471
- *
1472
- * @param {Array<{id: string, doc: object}>} wus - WUs to plan
1473
- * @param {LockPolicyOptions} options - Lock policy options including laneConfigs
1474
- * @returns {ExecutionPlan} Execution plan with waves, skipped, and deferred WUs
1475
- */
1476
- // eslint-disable-next-line sonarjs/cognitive-complexity -- wave-building logic inherently complex
1477
- export function buildExecutionPlanWithLockPolicy(wus, options = {}) {
1478
- const { laneConfigs = {} } = options;
1479
- // WU-2430: Enhanced categorisation of WUs
1480
- const skipped = []; // IDs of done WUs (backwards compat)
1481
- const skippedWithReasons = []; // WU-2430: Non-ready WUs with reasons
1482
- const deferred = []; // WU-2430: Ready WUs waiting on external blockers
1483
- const doneStatuses = new Set([WU_STATUS.DONE, WU_STATUS.COMPLETED]);
1484
- // Categorise WUs by status
1485
- for (const wu of wus) {
1486
- const status = wu.doc.status ?? 'unknown';
1487
- if (doneStatuses.has(status)) {
1488
- skipped.push(wu.id);
1489
- }
1490
- else if (status !== WU_STATUS.READY) {
1491
- skippedWithReasons.push({ id: wu.id, reason: `status: ${status}` });
1492
- }
1493
- }
1494
- // WU-2430: Only ready WUs are candidates for execution
1495
- const readyWUs = wus.filter((wu) => wu.doc.status === WU_STATUS.READY);
1496
- if (readyWUs.length === 0) {
1497
- return { waves: [], skipped, skippedWithReasons, deferred };
1498
- }
1499
- // Build a map for quick lookup
1500
- const wuMap = new Map(readyWUs.map((wu) => [wu.id, wu]));
1501
- const wuIds = new Set(wuMap.keys());
1502
- const allWuMap = new Map(wus.map((wu) => [wu.id, wu]));
1503
- const allWuIds = new Set(allWuMap.keys());
1504
- // Build dependency graph for validation (check cycles)
1505
- const graph = buildDependencyGraph();
1506
- const { cycles } = validateGraph(graph);
1507
- // Filter cycles to only those involving our WUs
1508
- const relevantCycles = cycles.filter((cycle) => cycle.some((id) => wuIds.has(id)));
1509
- if (relevantCycles.length > 0) {
1510
- const cycleStr = relevantCycles.map((c) => c.join(' → ')).join('; ');
1511
- throw createError(ErrorCodes.VALIDATION_ERROR, `Circular dependencies detected: ${cycleStr}`, {
1512
- cycles: relevantCycles,
1513
- });
1514
- }
1515
- // WU-2430: Check for external blockers without stamps
1516
- const deferredIds = new Set();
1517
- const deferredReasons = new Map();
1518
- const deferredBlockers = new Map();
1519
- const addDeferredEntry = (wuId, blockers, reason) => {
1520
- deferredIds.add(wuId);
1521
- let reasonSet = deferredReasons.get(wuId);
1522
- let blockerSet = deferredBlockers.get(wuId);
1523
- if (!reasonSet) {
1524
- reasonSet = new Set();
1525
- deferredReasons.set(wuId, reasonSet);
1526
- }
1527
- if (!blockerSet) {
1528
- blockerSet = new Set();
1529
- deferredBlockers.set(wuId, blockerSet);
1530
- }
1531
- for (const blockerId of blockers) {
1532
- blockerSet.add(blockerId);
1533
- }
1534
- reasonSet.add(reason);
1535
- };
1536
- for (const wu of readyWUs) {
1537
- const blockers = getAllDependencies(wu.doc);
1538
- const externalBlockers = blockers.filter((blockerId) => !allWuIds.has(blockerId));
1539
- const internalBlockers = blockers.filter((blockerId) => allWuIds.has(blockerId));
1540
- if (externalBlockers.length > 0) {
1541
- const unstampedBlockers = externalBlockers.filter((blockerId) => !hasStamp(blockerId));
1542
- if (unstampedBlockers.length > 0) {
1543
- addDeferredEntry(wu.id, unstampedBlockers, `waiting for external: ${unstampedBlockers.join(', ')}`);
1544
- }
1545
- }
1546
- if (internalBlockers.length > 0) {
1547
- const nonReadyInternal = internalBlockers.filter((blockerId) => {
1548
- const blocker = allWuMap.get(blockerId);
1549
- const status = blocker?.doc?.status ?? 'unknown';
1550
- if (status === WU_STATUS.READY) {
1551
- return false;
1552
- }
1553
- return !doneStatuses.has(status);
1554
- });
1555
- if (nonReadyInternal.length > 0) {
1556
- const details = nonReadyInternal.map((blockerId) => {
1557
- const status = allWuMap.get(blockerId)?.doc?.status ?? 'unknown';
1558
- return `${blockerId} (status: ${status})`;
1559
- });
1560
- addDeferredEntry(wu.id, nonReadyInternal, `waiting for internal: ${details.join(', ')}`);
1561
- }
1562
- }
1563
- }
1564
- let hasNewDeferral = true;
1565
- while (hasNewDeferral) {
1566
- hasNewDeferral = false;
1567
- for (const wu of readyWUs) {
1568
- if (deferredIds.has(wu.id)) {
1569
- continue;
1570
- }
1571
- const blockers = getAllDependencies(wu.doc);
1572
- const deferredInternal = blockers.filter((blockerId) => allWuIds.has(blockerId) && deferredIds.has(blockerId));
1573
- if (deferredInternal.length > 0) {
1574
- const details = deferredInternal.map((blockerId) => {
1575
- const status = allWuMap.get(blockerId)?.doc?.status ?? 'unknown';
1576
- return `${blockerId} (status: ${status})`;
1577
- });
1578
- addDeferredEntry(wu.id, deferredInternal, `waiting for internal: ${details.join(', ')}`);
1579
- hasNewDeferral = true;
1580
- }
1581
- }
1582
- }
1583
- for (const wu of readyWUs) {
1584
- if (deferredIds.has(wu.id)) {
1585
- const blockerSet = deferredBlockers.get(wu.id) || new Set();
1586
- const reasonSet = deferredReasons.get(wu.id) || new Set();
1587
- deferred.push({
1588
- id: wu.id,
1589
- blockedBy: Array.from(blockerSet),
1590
- reason: reasonSet.size > 0 ? Array.from(reasonSet).join('; ') : DEFAULT_DEFERRED_REASON,
1591
- });
1592
- }
1593
- }
1594
- // Remove deferred WUs from candidates
1595
- const schedulableWUs = readyWUs.filter((wu) => !deferredIds.has(wu.id));
1596
- const schedulableMap = new Map(schedulableWUs.map((wu) => [wu.id, wu]));
1597
- const schedulableIds = new Set(schedulableMap.keys());
1598
- if (schedulableIds.size === 0) {
1599
- return { waves: [], skipped, skippedWithReasons, deferred };
1600
- }
1601
- // WU-1326: Build set of lanes currently occupied based on policy
1602
- // Track which lanes are occupied by in_progress or blocked WUs
1603
- const lanesOccupiedByInProgress = new Set();
1604
- const lanesOccupiedByBlocked = new Set();
1605
- for (const wu of wus) {
1606
- const status = wu.doc.status ?? 'unknown';
1607
- const lane = wu.doc.lane;
1608
- if (lane) {
1609
- if (status === WU_STATUS.IN_PROGRESS) {
1610
- lanesOccupiedByInProgress.add(lane);
1611
- }
1612
- else if (status === WU_STATUS.BLOCKED) {
1613
- lanesOccupiedByBlocked.add(lane);
1614
- }
1615
- }
1616
- }
1617
- // Build waves using Kahn's algorithm (topological sort by levels)
1618
- // WU-1326: Enforce lane WIP based on lock_policy
1619
- const waves = [];
1620
- const remaining = new Set(schedulableIds);
1621
- const completed = new Set(skipped);
1622
- // Also treat stamped external deps as completed
1623
- for (const wu of wus) {
1624
- const blockers = getAllDependencies(wu.doc);
1625
- for (const blockerId of blockers) {
1626
- if (!allWuIds.has(blockerId) && hasStamp(blockerId)) {
1627
- completed.add(blockerId);
1628
- }
1629
- }
1630
- }
1631
- while (remaining.size > 0) {
1632
- const wave = [];
1633
- const lanesInWave = new Set(); // Track lanes used in this wave
1634
- const deferredToNextWave = []; // WUs that could run but lane is occupied
1635
- for (const id of remaining) {
1636
- const wu = schedulableMap.get(id);
1637
- if (!wu)
1638
- continue; // Should not happen - remaining only contains valid IDs
1639
- const blockers = getAllDependencies(wu.doc);
1640
- // Check if all blockers are either done or completed in previous waves
1641
- const allBlockersDone = blockers.every((blockerId) => completed.has(blockerId));
1642
- if (allBlockersDone) {
1643
- const lane = wu.doc.lane ?? '';
1644
- // WU-1326: Get lock_policy for this lane
1645
- const policy = getLockPolicyForLane(lane, laneConfigs);
1646
- // WU-1326: Check if lane is already occupied in this wave
1647
- // Skip this check when policy=none (allows unlimited parallel WUs in same lane)
1648
- if (policy !== 'none' && lanesInWave.has(lane)) {
1649
- // Defer to next wave (lane conflict within this wave)
1650
- deferredToNextWave.push(wu);
1651
- continue;
1652
- }
1653
- // WU-1326: Check if lane is occupied by existing WUs based on policy
1654
- // policy=none: laneBlocked stays false (no WIP checking)
1655
- // policy=active: only in_progress blocks
1656
- // policy=all: both in_progress and blocked block
1657
- let laneBlocked = false;
1658
- if (policy === 'active') {
1659
- // Only in_progress WUs block the lane
1660
- laneBlocked = lanesOccupiedByInProgress.has(lane);
1661
- }
1662
- else if (policy === 'all') {
1663
- // policy === 'all' (default): both in_progress and blocked block lane
1664
- laneBlocked = lanesOccupiedByInProgress.has(lane) || lanesOccupiedByBlocked.has(lane);
1665
- }
1666
- // policy === 'none': laneBlocked remains false
1667
- if (laneBlocked) {
1668
- // Lane is occupied by existing WU based on policy
1669
- deferredToNextWave.push(wu);
1670
- }
1671
- else {
1672
- wave.push(wu);
1673
- lanesInWave.add(lane);
1674
- }
1675
- }
1676
- }
1677
- // Deadlock detection: if no WUs can be scheduled but remaining exist
1678
- if (wave.length === 0 && remaining.size > 0 && deferredToNextWave.length === 0) {
1679
- const stuckIds = Array.from(remaining);
1680
- throw createError(ErrorCodes.VALIDATION_ERROR, `Circular or unresolvable dependencies detected. Stuck WUs: ${stuckIds.join(', ')}`, { stuckIds });
1681
- }
1682
- // Add wave and mark WUs as completed
1683
- if (wave.length > 0) {
1684
- waves.push(wave);
1685
- for (const wu of wave) {
1686
- remaining.delete(wu.id);
1687
- completed.add(wu.id);
1688
- }
1689
- }
1690
- // Add deferred WUs back to remaining for next wave (if wave had items)
1691
- // If wave was empty but we have deferred items, we need to make progress
1692
- if (wave.length === 0 && deferredToNextWave.length > 0) {
1693
- // Schedule one deferred WU per lane to make progress
1694
- const processedLanes = new Set();
1695
- for (const wu of deferredToNextWave) {
1696
- const lane = wu.doc.lane ?? '';
1697
- if (!processedLanes.has(lane)) {
1698
- wave.push(wu);
1699
- processedLanes.add(lane);
1700
- }
1701
- }
1702
- if (wave.length > 0) {
1703
- waves.push(wave);
1704
- for (const wu of wave) {
1705
- remaining.delete(wu.id);
1706
- completed.add(wu.id);
1707
- }
1708
- }
1709
- }
1710
- }
1711
- return { waves, skipped, skippedWithReasons, deferred };
1712
- }
1713
- /**
1714
- * WU-1326: Get lane availability respecting lock_policy.
1715
- *
1716
- * Returns availability status for each lane based on current WU states
1717
- * and configured lock_policy.
1718
- *
1719
- * @param {Array<{id: string, doc: object}>} wus - WUs to check
1720
- * @param {LockPolicyOptions} options - Lock policy options
1721
- * @returns {Record<string, LaneAvailabilityResult>} Lane availability map
1722
- */
1723
- // eslint-disable-next-line sonarjs/cognitive-complexity -- lane availability logic with multiple policy branches
1724
- export function getLaneAvailability(wus, options = {}) {
1725
- const { laneConfigs = {} } = options;
1726
- const result = {};
1727
- // Group WUs by lane
1728
- const wusByLane = new Map();
1729
- for (const wu of wus) {
1730
- const lane = wu.doc.lane;
1731
- if (lane) {
1732
- const laneWUs = wusByLane.get(lane);
1733
- if (laneWUs) {
1734
- laneWUs.push(wu);
1735
- }
1736
- else {
1737
- wusByLane.set(lane, [wu]);
1738
- }
1739
- }
1740
- }
1741
- // Calculate availability for each lane
1742
- for (const [lane, laneWUs] of wusByLane) {
1743
- const policy = getLockPolicyForLane(lane, laneConfigs);
1744
- let inProgressCount = 0;
1745
- let blockedCount = 0;
1746
- let occupiedBy;
1747
- for (const wu of laneWUs) {
1748
- const status = wu.doc.status ?? 'unknown';
1749
- if (status === WU_STATUS.IN_PROGRESS) {
1750
- inProgressCount++;
1751
- if (!occupiedBy) {
1752
- occupiedBy = wu.id;
1753
- }
1754
- }
1755
- else if (status === WU_STATUS.BLOCKED) {
1756
- blockedCount++;
1757
- // Only set occupiedBy for blocked if policy=all
1758
- if (policy === 'all' && !occupiedBy) {
1759
- occupiedBy = wu.id;
1760
- }
1761
- }
1762
- }
1763
- // Determine availability based on policy
1764
- let available = false;
1765
- if (policy === 'none') {
1766
- // No WIP checking - always available
1767
- available = true;
1768
- occupiedBy = undefined;
1769
- }
1770
- else if (policy === 'active') {
1771
- // Only in_progress blocks
1772
- available = inProgressCount === 0;
1773
- if (available) {
1774
- occupiedBy = undefined;
1775
- }
1776
- }
1777
- else {
1778
- // policy === 'all': both in_progress and blocked block
1779
- available = inProgressCount === 0 && blockedCount === 0;
1780
- }
1781
- result[lane] = {
1782
- available,
1783
- policy,
1784
- occupiedBy,
1785
- blockedCount,
1786
- inProgressCount,
1787
- };
1788
- }
1789
- return result;
1790
- }
1791
- export { LOG_PREFIX };
20
+ // ── Shared utilities ──────────────────────────────────────────────────────────
21
+ export { LOG_PREFIX } from './orchestrator/shared.js';
22
+ // ── Execution planning ────────────────────────────────────────────────────────
23
+ export { buildExecutionPlan, buildExecutionPlanAsync, buildExecutionPlanWithLockPolicy, } from './orchestrator/execution-planning.js';
24
+ // ── Checkpoint ────────────────────────────────────────────────────────────────
25
+ export { CHECKPOINT_AUTO_THRESHOLDS, filterByDependencyStamps, shouldAutoEnableCheckpoint, shouldAutoEnableCheckpointAsync, resolveCheckpointMode, resolveCheckpointModeAsync, validateCheckpointFlags, buildCheckpointWave, } from './orchestrator/checkpoint.js';
26
+ // ── Formatting ────────────────────────────────────────────────────────────────
27
+ export { formatExecutionPlan, generateSpawnCommands, calculateProgress, formatProgress, getBottleneckWUs, formatCheckpointOutput, generateEmbeddedSpawnPrompt, formatTaskInvocationWithEmbeddedSpawn, formatExecutionPlanWithEmbeddedSpawns, } from './orchestrator/formatting.js';
28
+ // ── Spawn status ──────────────────────────────────────────────────────────────
29
+ export { getManifestWUStatus, isWUActuallySpawned, getSpawnCandidatesWithYAMLCheck, } from './orchestrator/spawn-status.js';
30
+ // ── Lane policy ───────────────────────────────────────────────────────────────
31
+ export { getLockPolicyForLane, getLaneAvailability } from './orchestrator/lane-policy.js';
32
+ // ── Initiative loading ────────────────────────────────────────────────────────
33
+ export { loadInitiativeWUs, loadMultipleInitiatives } from './orchestrator/initiative-loading.js';
1792
34
  //# sourceMappingURL=initiative-orchestrator.js.map