@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.
- package/LICENSE +190 -0
- package/README.md +198 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/initiative-constants.d.ts +101 -0
- package/dist/initiative-constants.js +104 -0
- package/dist/initiative-orchestrator.d.ts +341 -0
- package/dist/initiative-orchestrator.js +1026 -0
- package/dist/initiative-paths.d.ts +28 -0
- package/dist/initiative-paths.js +29 -0
- package/dist/initiative-schema.d.ts +107 -0
- package/dist/initiative-schema.js +103 -0
- package/dist/initiative-validator.d.ts +143 -0
- package/dist/initiative-validator.js +360 -0
- package/dist/initiative-yaml.d.ts +136 -0
- package/dist/initiative-yaml.js +238 -0
- package/package.json +64 -0
|
@@ -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 };
|