@caseyharalson/orrery 0.7.1

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 (40) hide show
  1. package/.devcontainer.example/Dockerfile +149 -0
  2. package/.devcontainer.example/devcontainer.json +61 -0
  3. package/.devcontainer.example/init-firewall.sh +175 -0
  4. package/LICENSE +21 -0
  5. package/README.md +139 -0
  6. package/agent/skills/discovery/SKILL.md +428 -0
  7. package/agent/skills/discovery/schemas/plan-schema.yaml +138 -0
  8. package/agent/skills/orrery-execute/SKILL.md +107 -0
  9. package/agent/skills/orrery-report/SKILL.md +119 -0
  10. package/agent/skills/orrery-review/SKILL.md +105 -0
  11. package/agent/skills/orrery-verify/SKILL.md +105 -0
  12. package/agent/skills/refine-plan/SKILL.md +291 -0
  13. package/agent/skills/simulate-plan/SKILL.md +244 -0
  14. package/bin/orrery.js +5 -0
  15. package/lib/cli/commands/help.js +21 -0
  16. package/lib/cli/commands/ingest-plan.js +56 -0
  17. package/lib/cli/commands/init.js +21 -0
  18. package/lib/cli/commands/install-devcontainer.js +97 -0
  19. package/lib/cli/commands/install-skills.js +182 -0
  20. package/lib/cli/commands/orchestrate.js +27 -0
  21. package/lib/cli/commands/resume.js +146 -0
  22. package/lib/cli/commands/status.js +137 -0
  23. package/lib/cli/commands/validate-plan.js +288 -0
  24. package/lib/cli/index.js +57 -0
  25. package/lib/orchestration/agent-invoker.js +595 -0
  26. package/lib/orchestration/condensed-plan.js +128 -0
  27. package/lib/orchestration/config.js +213 -0
  28. package/lib/orchestration/dependency-resolver.js +149 -0
  29. package/lib/orchestration/edit-invoker.js +115 -0
  30. package/lib/orchestration/index.js +1065 -0
  31. package/lib/orchestration/plan-loader.js +212 -0
  32. package/lib/orchestration/progress-tracker.js +208 -0
  33. package/lib/orchestration/report-format.js +80 -0
  34. package/lib/orchestration/review-invoker.js +305 -0
  35. package/lib/utils/agent-detector.js +47 -0
  36. package/lib/utils/git.js +297 -0
  37. package/lib/utils/paths.js +43 -0
  38. package/lib/utils/plan-detect.js +24 -0
  39. package/lib/utils/skill-copier.js +79 -0
  40. package/package.json +58 -0
@@ -0,0 +1,595 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn } = require("child_process");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const { validateAgentOutput } = require("./report-format");
7
+ const { getReportsDir } = require("../utils/paths");
8
+
9
+ /**
10
+ * Invoke an agent subprocess to execute plan steps
11
+ * @param {Object} agentConfig - Agent configuration (command, args)
12
+ * @param {string} planFile - Absolute path to the plan YAML file
13
+ * @param {string[]} stepIds - Array of step IDs to execute
14
+ * @param {string} repoRoot - Path to repository root
15
+ * @param {Object} [options] - Additional options
16
+ * @param {Function} [options.onStdout] - Callback for stdout data
17
+ * @param {Function} [options.onStderr] - Callback for stderr data
18
+ * @returns {Object} - Process handle with promise for completion
19
+ */
20
+ function invokeAgent(agentConfig, planFile, stepIds, repoRoot, options = {}) {
21
+ const stepIdsStr = stepIds.join(",");
22
+
23
+ // Replace placeholders in command args
24
+ const args = agentConfig.args.map((arg) =>
25
+ arg.replace("{planFile}", planFile).replace("{stepIds}", stepIdsStr)
26
+ );
27
+
28
+ const proc = spawn(agentConfig.command, args, {
29
+ cwd: repoRoot,
30
+ stdio: ["ignore", "pipe", "pipe"],
31
+ detached: false,
32
+ shell: false
33
+ });
34
+
35
+ // Collect stdout for result parsing
36
+ let stdoutBuffer = "";
37
+ let stderrBuffer = "";
38
+
39
+ proc.stdout.on("data", (data) => {
40
+ const text = data.toString();
41
+ stdoutBuffer += text;
42
+ if (options.onStdout) {
43
+ options.onStdout(text, stepIds);
44
+ }
45
+ });
46
+
47
+ proc.stderr.on("data", (data) => {
48
+ const text = data.toString();
49
+ stderrBuffer += text;
50
+ if (options.onStderr) {
51
+ options.onStderr(text, stepIds);
52
+ }
53
+ });
54
+
55
+ // Create a promise that resolves when process exits
56
+ const completion = new Promise((resolve, reject) => {
57
+ proc.on("close", (code) => {
58
+ resolve({
59
+ exitCode: code,
60
+ stdout: stdoutBuffer,
61
+ stderr: stderrBuffer,
62
+ stepIds
63
+ });
64
+ });
65
+
66
+ proc.on("error", (err) => {
67
+ reject({
68
+ error: err,
69
+ stdout: stdoutBuffer,
70
+ stderr: stderrBuffer,
71
+ stepIds
72
+ });
73
+ });
74
+ });
75
+
76
+ return {
77
+ process: proc,
78
+ completion,
79
+ stepIds,
80
+ kill: () => proc.kill()
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Parse agent result from stdout
86
+ * Expected format is JSON with:
87
+ * {
88
+ * stepId: string,
89
+ * status: "complete" | "blocked",
90
+ * summary: string,
91
+ * blockedReason?: string,
92
+ * artifacts?: string[],
93
+ * testResults?: { passed: number, failed: number }
94
+ * }
95
+ *
96
+ * Agents may output multiple JSON objects (one per step) or a single array.
97
+ * JSON may be wrapped in markdown code blocks (```json ... ```).
98
+ * @param {string} stdout - Raw stdout from agent
99
+ * @returns {Array<Object>} - Array of parsed results
100
+ */
101
+ function parseAgentResults(stdout) {
102
+ const results = [];
103
+
104
+ // Helper to validate and add results
105
+ const addResult = (obj) => {
106
+ try {
107
+ const valid = validateAgentOutput(obj);
108
+ results.push(valid);
109
+ } catch (e) {
110
+ // Invalid result structure, ignore
111
+ console.debug("Invalid result:", e.message);
112
+ }
113
+ };
114
+
115
+ // First, try to extract JSON from markdown code blocks
116
+ const codeBlockPattern = /```(?:json)?\s*([\s\S]*?)```/g;
117
+ let codeBlockMatch;
118
+ while ((codeBlockMatch = codeBlockPattern.exec(stdout)) !== null) {
119
+ const content = codeBlockMatch[1].trim();
120
+ try {
121
+ const parsed = JSON.parse(content);
122
+ if (Array.isArray(parsed)) {
123
+ parsed.forEach(addResult);
124
+ } else {
125
+ addResult(parsed);
126
+ }
127
+ } catch {
128
+ // Not valid JSON in this code block, continue
129
+ }
130
+ }
131
+
132
+ // If we found results in code blocks, return them
133
+ if (results.length > 0) {
134
+ return results;
135
+ }
136
+
137
+ // Otherwise, try to find raw JSON objects with balanced braces
138
+ let i = 0;
139
+ while (i < stdout.length) {
140
+ if (stdout[i] === "{") {
141
+ const jsonObj = extractBalancedJson(stdout, i, "{", "}");
142
+ if (jsonObj) {
143
+ try {
144
+ const parsed = JSON.parse(jsonObj);
145
+ addResult(parsed);
146
+ } catch {
147
+ // Not valid JSON
148
+ }
149
+ i += jsonObj.length;
150
+ } else {
151
+ i++;
152
+ }
153
+ } else if (stdout[i] === "[") {
154
+ const jsonArr = extractBalancedJson(stdout, i, "[", "]");
155
+ if (jsonArr) {
156
+ try {
157
+ const parsed = JSON.parse(jsonArr);
158
+ if (Array.isArray(parsed)) {
159
+ parsed.forEach(addResult);
160
+ }
161
+ } catch {
162
+ // Not valid JSON
163
+ }
164
+ i += jsonArr.length;
165
+ } else {
166
+ i++;
167
+ }
168
+ } else {
169
+ i++;
170
+ }
171
+ }
172
+
173
+ return results;
174
+ }
175
+
176
+ /**
177
+ * Extract a balanced JSON structure from a string starting at a given position.
178
+ * Handles nested braces and respects string boundaries.
179
+ * @param {string} str - The string to extract from
180
+ * @param {number} start - Starting index (must be openChar)
181
+ * @param {string} openChar - Opening character ('{' or '[')
182
+ * @param {string} closeChar - Closing character ('}' or ']')
183
+ * @returns {string|null} - The extracted JSON string or null if unbalanced
184
+ */
185
+ function extractBalancedJson(str, start, openChar = "{", closeChar = "}") {
186
+ if (str[start] !== openChar) return null;
187
+
188
+ let depth = 0;
189
+ let inString = false;
190
+ let escapeNext = false;
191
+
192
+ for (let i = start; i < str.length; i++) {
193
+ const char = str[i];
194
+
195
+ if (escapeNext) {
196
+ escapeNext = false;
197
+ continue;
198
+ }
199
+
200
+ if (char === "\\" && inString) {
201
+ escapeNext = true;
202
+ continue;
203
+ }
204
+
205
+ if (char === '"') {
206
+ inString = !inString;
207
+ continue;
208
+ }
209
+
210
+ if (!inString) {
211
+ if (char === openChar) {
212
+ depth++;
213
+ } else if (char === closeChar) {
214
+ depth--;
215
+ if (depth === 0) {
216
+ return str.slice(start, i + 1);
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ return null; // Unbalanced
223
+ }
224
+
225
+ /**
226
+ * Create a default result for a step when agent doesn't report properly
227
+ * @param {string} stepId - Step ID
228
+ * @param {number} exitCode - Process exit code
229
+ * @param {string} stderr - Error output
230
+ * @returns {Object} - Default result object
231
+ */
232
+ function createDefaultResult(stepId, exitCode, stderr) {
233
+ if (exitCode === 0) {
234
+ return {
235
+ stepId,
236
+ status: "complete",
237
+ summary: "Step completed (no detailed report from agent)",
238
+ artifacts: [],
239
+ commitMessage: `feat: complete step ${stepId}`
240
+ };
241
+ } else {
242
+ return {
243
+ stepId,
244
+ status: "blocked",
245
+ summary: "Step failed",
246
+ blockedReason: stderr || `Agent exited with code ${exitCode}`,
247
+ artifacts: [],
248
+ commitMessage: `wip: attempt step ${stepId}`
249
+ };
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Check if an error condition should trigger failover to another agent
255
+ * @param {Object} result - Process result with exitCode, stdout, stderr
256
+ * @param {Error} spawnError - Error from spawn (if any)
257
+ * @param {boolean} timedOut - Whether the process timed out
258
+ * @param {Object} errorPatterns - Regex patterns for error detection
259
+ * @returns {{shouldFailover: boolean, reason: string}}
260
+ */
261
+ function shouldTriggerFailover(result, spawnError, timedOut, errorPatterns) {
262
+ // 1. Spawn failures (command not found, ENOENT)
263
+ if (spawnError) {
264
+ if (spawnError.code === "ENOENT") {
265
+ return { shouldFailover: true, reason: "command_not_found" };
266
+ }
267
+ return { shouldFailover: true, reason: "spawn_error" };
268
+ }
269
+
270
+ // 2. Timeout
271
+ if (timedOut) {
272
+ return { shouldFailover: true, reason: "timeout" };
273
+ }
274
+
275
+ // 3. Non-zero exit with error patterns in stderr (but NOT legitimate blocked)
276
+ if (result && result.exitCode !== 0) {
277
+ const stderr = result.stderr || "";
278
+
279
+ // Check API error patterns
280
+ for (const pattern of errorPatterns.apiError || []) {
281
+ if (pattern.test(stderr)) {
282
+ return { shouldFailover: true, reason: "api_error" };
283
+ }
284
+ }
285
+
286
+ // Check token limit patterns
287
+ for (const pattern of errorPatterns.tokenLimit || []) {
288
+ if (pattern.test(stderr)) {
289
+ return { shouldFailover: true, reason: "token_limit" };
290
+ }
291
+ }
292
+ }
293
+
294
+ return { shouldFailover: false, reason: null };
295
+ }
296
+
297
+ /**
298
+ * Log a timeout event to the configured log file
299
+ * @param {Object} config - Orchestrator config
300
+ * @param {string} planFile - Path to plan file
301
+ * @param {string[]} stepIds - Step IDs that timed out
302
+ * @param {string} agentName - Name of the agent that timed out
303
+ */
304
+ function logTimeout(config, planFile, stepIds, agentName) {
305
+ const logPath = path.join(getReportsDir(), "timeouts.log");
306
+
307
+ const entry = {
308
+ timestamp: new Date().toISOString(),
309
+ planFile: path.basename(planFile),
310
+ stepIds,
311
+ agent: agentName,
312
+ timeoutMs: config.failover?.timeoutMs
313
+ };
314
+
315
+ const line = JSON.stringify(entry) + "\n";
316
+ fs.appendFileSync(logPath, line, "utf8");
317
+ }
318
+
319
+ /**
320
+ * Log a failure event to the configured log file
321
+ * Captures both stdout and stderr since agents may write errors to either stream
322
+ * @param {Object} config - Orchestrator config
323
+ * @param {string} planFile - Path to plan file
324
+ * @param {string[]} stepIds - Step IDs that failed
325
+ * @param {string} agentName - Name of the agent that failed
326
+ * @param {number} exitCode - Process exit code
327
+ * @param {string} stdout - Agent stdout output
328
+ * @param {string} stderr - Agent stderr output
329
+ */
330
+ function logFailure(
331
+ config,
332
+ planFile,
333
+ stepIds,
334
+ agentName,
335
+ exitCode,
336
+ stdout,
337
+ stderr
338
+ ) {
339
+ const logPath = path.join(getReportsDir(), "failures.log");
340
+
341
+ const entry = {
342
+ timestamp: new Date().toISOString(),
343
+ planFile: path.basename(planFile),
344
+ stepIds,
345
+ agent: agentName,
346
+ exitCode,
347
+ stdout: stdout || "",
348
+ stderr: stderr || ""
349
+ };
350
+
351
+ const line = JSON.stringify(entry) + "\n";
352
+ fs.appendFileSync(logPath, line, "utf8");
353
+ }
354
+
355
+ /**
356
+ * Invoke an agent with a timeout
357
+ * @param {Object} agentConfig - Agent configuration
358
+ * @param {string} planFile - Path to plan file
359
+ * @param {string[]} stepIds - Step IDs to execute
360
+ * @param {string} repoRoot - Repository root path
361
+ * @param {number} timeoutMs - Timeout in milliseconds
362
+ * @param {Object} options - Options including callbacks
363
+ * @returns {Promise<Object>} - Result with timedOut flag
364
+ */
365
+ async function invokeAgentWithTimeout(
366
+ agentConfig,
367
+ planFile,
368
+ stepIds,
369
+ repoRoot,
370
+ timeoutMs,
371
+ options
372
+ ) {
373
+ const handle = invokeAgent(agentConfig, planFile, stepIds, repoRoot, options);
374
+
375
+ if (!timeoutMs || timeoutMs <= 0) {
376
+ const result = await handle.completion;
377
+ return { ...result, timedOut: false };
378
+ }
379
+
380
+ // Race between completion and timeout
381
+ let timer;
382
+ const timeoutPromise = new Promise((resolve) => {
383
+ timer = setTimeout(() => {
384
+ handle.kill();
385
+ resolve({
386
+ timedOut: true,
387
+ stepIds,
388
+ exitCode: null,
389
+ stdout: "",
390
+ stderr: "Process timed out"
391
+ });
392
+ }, timeoutMs);
393
+ });
394
+
395
+ try {
396
+ const result = await Promise.race([
397
+ handle.completion.then((r) => ({ ...r, timedOut: false })),
398
+ timeoutPromise
399
+ ]);
400
+ return result;
401
+ } finally {
402
+ clearTimeout(timer);
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Invoke an agent with automatic failover to next agent on infrastructure failures
408
+ * @param {Object} config - Full orchestrator config
409
+ * @param {string} planFile - Path to plan file
410
+ * @param {string[]} stepIds - Step IDs to execute
411
+ * @param {string} repoRoot - Repository root path
412
+ * @param {Object} options - Options including callbacks
413
+ * @returns {Object} - Handle with completion promise
414
+ */
415
+ function invokeAgentWithFailover(
416
+ config,
417
+ planFile,
418
+ stepIds,
419
+ repoRoot,
420
+ options = {}
421
+ ) {
422
+ const failoverConfig = config.failover || { enabled: false };
423
+
424
+ // If failover is disabled, use default agent directly
425
+ if (!failoverConfig.enabled) {
426
+ const agentName = config.defaultAgent || Object.keys(config.agents)[0];
427
+ const agentConfig = config.agents[agentName];
428
+ const handle = invokeAgent(
429
+ agentConfig,
430
+ planFile,
431
+ stepIds,
432
+ repoRoot,
433
+ options
434
+ );
435
+ // Wrap completion to include agentName and log failures
436
+ return {
437
+ ...handle,
438
+ completion: handle.completion.then((result) => {
439
+ if (result.exitCode !== 0 && result.exitCode !== null) {
440
+ logFailure(
441
+ config,
442
+ planFile,
443
+ stepIds,
444
+ agentName,
445
+ result.exitCode,
446
+ result.stdout,
447
+ result.stderr
448
+ );
449
+ }
450
+ return { ...result, agentName };
451
+ })
452
+ };
453
+ }
454
+
455
+ const agentPriority = config.agentPriority || [config.defaultAgent];
456
+
457
+ // Filter to only configured agents
458
+ const availableAgents = agentPriority.filter((name) => config.agents[name]);
459
+
460
+ if (availableAgents.length === 0) {
461
+ throw new Error("No agents configured");
462
+ }
463
+
464
+ let cancelled = false;
465
+ let currentHandle = null;
466
+
467
+ const completion = (async () => {
468
+ let lastResult = null;
469
+ let lastError = null;
470
+
471
+ for (let i = 0; i < availableAgents.length; i++) {
472
+ if (cancelled) break;
473
+
474
+ const agentName = availableAgents[i];
475
+ const agentConfig = config.agents[agentName];
476
+
477
+ console.log(
478
+ `[failover] Trying agent: ${agentName} (${i + 1}/${
479
+ availableAgents.length
480
+ })`
481
+ );
482
+
483
+ try {
484
+ // Invoke with timeout wrapper
485
+ const result = await invokeAgentWithTimeout(
486
+ agentConfig,
487
+ planFile,
488
+ stepIds,
489
+ repoRoot,
490
+ failoverConfig.timeoutMs,
491
+ options
492
+ );
493
+
494
+ // Log failures (non-zero exit) for debugging
495
+ if (result.exitCode !== 0 && result.exitCode !== null) {
496
+ logFailure(
497
+ config,
498
+ planFile,
499
+ stepIds,
500
+ agentName,
501
+ result.exitCode,
502
+ result.stdout,
503
+ result.stderr
504
+ );
505
+ }
506
+
507
+ // Check if we should failover
508
+ const { shouldFailover, reason } = shouldTriggerFailover(
509
+ result,
510
+ null,
511
+ result.timedOut,
512
+ failoverConfig.errorPatterns || {}
513
+ );
514
+
515
+ if (shouldFailover && i < availableAgents.length - 1) {
516
+ console.log(
517
+ `[failover] Agent ${agentName} failed (${reason}), trying next agent`
518
+ );
519
+ lastResult = result;
520
+
521
+ if (reason === "timeout") {
522
+ logTimeout(config, planFile, stepIds, agentName);
523
+ }
524
+ continue;
525
+ }
526
+
527
+ // Either succeeded or no more agents to try
528
+ return { ...result, agentName };
529
+ } catch (spawnError) {
530
+ const { shouldFailover, reason } = shouldTriggerFailover(
531
+ null,
532
+ spawnError,
533
+ false,
534
+ failoverConfig.errorPatterns || {}
535
+ );
536
+
537
+ if (shouldFailover && i < availableAgents.length - 1) {
538
+ console.log(
539
+ `[failover] Agent ${agentName} spawn failed (${reason}), trying next agent`
540
+ );
541
+ lastError = spawnError;
542
+ continue;
543
+ }
544
+
545
+ // No more agents, rethrow
546
+ throw spawnError;
547
+ }
548
+ }
549
+
550
+ // All agents exhausted
551
+ if (lastResult) return lastResult;
552
+ if (lastError) throw lastError;
553
+ })();
554
+
555
+ return {
556
+ process: null, // Not directly accessible with failover
557
+ completion,
558
+ stepIds,
559
+ kill: () => {
560
+ cancelled = true;
561
+ if (currentHandle) currentHandle.kill();
562
+ }
563
+ };
564
+ }
565
+
566
+ /**
567
+ * Wait for multiple agent processes to complete
568
+ * @param {Array<Object>} agentHandles - Array of handles from invokeAgent
569
+ * @returns {Promise<Array>} - Array of completion results
570
+ */
571
+ async function waitForAll(agentHandles) {
572
+ return Promise.all(agentHandles.map((h) => h.completion));
573
+ }
574
+
575
+ /**
576
+ * Wait for any one agent process to complete
577
+ * @param {Array<Object>} agentHandles - Array of handles from invokeAgent
578
+ * @returns {Promise<{result: Object, index: number}>} - First completed result with index
579
+ */
580
+ async function waitForAny(agentHandles) {
581
+ return Promise.race(
582
+ agentHandles.map((handle, index) =>
583
+ handle.completion.then((result) => ({ result, index }))
584
+ )
585
+ );
586
+ }
587
+
588
+ module.exports = {
589
+ invokeAgent,
590
+ invokeAgentWithFailover,
591
+ parseAgentResults,
592
+ createDefaultResult,
593
+ waitForAll,
594
+ waitForAny
595
+ };
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Condensed Plan Generator
3
+ *
4
+ * Creates temporary condensed plans containing only the steps an agent needs,
5
+ * reducing context usage for large plans.
6
+ */
7
+
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+ const YAML = require("yaml");
11
+
12
+ const { getTempDir } = require("../utils/paths");
13
+
14
+ /**
15
+ * Get all completed dependencies for a set of step IDs (recursive)
16
+ * @param {Object} plan - The loaded plan object
17
+ * @param {string[]} stepIds - Step IDs to find dependencies for
18
+ * @returns {Object[]} - Array of completed dependency steps
19
+ */
20
+ function getCompletedDependencies(plan, stepIds) {
21
+ const stepMap = new Map(plan.steps.map((s) => [s.id, s]));
22
+ const collected = new Set();
23
+
24
+ function collectDeps(stepId) {
25
+ const step = stepMap.get(stepId);
26
+ if (!step || !step.deps) return;
27
+
28
+ for (const depId of step.deps) {
29
+ if (collected.has(depId)) continue;
30
+
31
+ const depStep = stepMap.get(depId);
32
+ if (depStep && depStep.status === "complete") {
33
+ collected.add(depId);
34
+ collectDeps(depId);
35
+ }
36
+ }
37
+ }
38
+
39
+ for (const stepId of stepIds) {
40
+ collectDeps(stepId);
41
+ }
42
+
43
+ return Array.from(collected)
44
+ .map((id) => stepMap.get(id))
45
+ .filter(Boolean);
46
+ }
47
+
48
+ /**
49
+ * Generate a condensed plan containing only assigned steps and their completed dependencies
50
+ * @param {Object} plan - The loaded plan object
51
+ * @param {string[]} stepIds - Step IDs assigned to the agent
52
+ * @returns {Object} - Condensed plan data (plain object, not a plan object)
53
+ */
54
+ function generateCondensedPlan(plan, stepIds) {
55
+ const stepSet = new Set(stepIds);
56
+ const assignedSteps = plan.steps.filter((s) => stepSet.has(s.id));
57
+ const completedDeps = getCompletedDependencies(plan, stepIds);
58
+
59
+ // Build ordered steps: completed deps + assigned steps, sorted by ascending ID
60
+ const depIds = new Set(completedDeps.map((s) => s.id));
61
+ const orderedSteps = [
62
+ ...completedDeps,
63
+ ...assignedSteps.filter((s) => !depIds.has(s.id))
64
+ ].sort((a, b) =>
65
+ String(a.id).localeCompare(String(b.id), undefined, { numeric: true })
66
+ );
67
+
68
+ // Copy metadata and add condensed indicators
69
+ const condensedMetadata = {
70
+ ...plan.metadata,
71
+ condensed: true,
72
+ source_plan: plan.filePath,
73
+ condensed_at: new Date().toISOString(),
74
+ assigned_steps: stepIds
75
+ };
76
+
77
+ return {
78
+ metadata: condensedMetadata,
79
+ steps: orderedSteps
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Write a condensed plan to a temp file
85
+ * @param {Object} condensedPlan - The condensed plan data
86
+ * @param {string} originalPlanPath - Path to the original plan file
87
+ * @param {string[]} stepIds - Step IDs for filename generation
88
+ * @returns {string} - Path to the written temp file
89
+ */
90
+ function writeCondensedPlan(condensedPlan, originalPlanPath, stepIds) {
91
+ const tempDir = getTempDir();
92
+ const baseName = path.basename(
93
+ originalPlanPath,
94
+ path.extname(originalPlanPath)
95
+ );
96
+ const stepSuffix = stepIds.join("-").replace(/[^a-zA-Z0-9.-]/g, "_");
97
+ const timestamp = Date.now();
98
+ const fileName = `${baseName}-${stepSuffix}-${timestamp}.yaml`;
99
+ const filePath = path.join(tempDir, fileName);
100
+
101
+ const content = YAML.stringify(condensedPlan);
102
+ fs.writeFileSync(filePath, content, "utf8");
103
+
104
+ return filePath;
105
+ }
106
+
107
+ /**
108
+ * Delete a condensed plan temp file
109
+ * @param {string} tempPlanPath - Path to the temp plan file
110
+ */
111
+ function deleteCondensedPlan(tempPlanPath) {
112
+ try {
113
+ if (fs.existsSync(tempPlanPath)) {
114
+ fs.unlinkSync(tempPlanPath);
115
+ }
116
+ } catch (err) {
117
+ console.warn(
118
+ `Warning: Failed to delete temp plan ${tempPlanPath}: ${err.message}`
119
+ );
120
+ }
121
+ }
122
+
123
+ module.exports = {
124
+ generateCondensedPlan,
125
+ getCompletedDependencies,
126
+ writeCondensedPlan,
127
+ deleteCondensedPlan
128
+ };