@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,1065 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Plan Orchestrator
5
+ *
6
+ * Scans .agent-work/plans/ for YAML plan files, dispatches agents to execute steps,
7
+ * tracks completion, and archives finished plans to .agent-work/completed/.
8
+ *
9
+ * Branch Management:
10
+ * - Plans are discovered on the source branch (e.g., main)
11
+ * - Each plan gets a dedicated work branch (e.g., plan/add-feature)
12
+ * - All agent work happens on the work branch
13
+ * - When complete, a PR is created and orchestrator returns to source branch
14
+ */
15
+
16
+ const fs = require("fs");
17
+ const path = require("path");
18
+ const YAML = require("yaml");
19
+
20
+ const {
21
+ loadPlan,
22
+ savePlan,
23
+ updateStepsStatus,
24
+ getPlanFiles,
25
+ movePlanToCompleted,
26
+ getCompletedPlanNames
27
+ } = require("./plan-loader");
28
+
29
+ const {
30
+ getReadySteps,
31
+ partitionSteps,
32
+ getBlockedDependents
33
+ } = require("./dependency-resolver");
34
+
35
+ const {
36
+ invokeAgentWithFailover,
37
+ parseAgentResults,
38
+ createDefaultResult,
39
+ waitForAny
40
+ } = require("./agent-invoker");
41
+ const { invokeReviewAgent } = require("./review-invoker");
42
+ const { invokeEditAgent } = require("./edit-invoker");
43
+
44
+ const {
45
+ getCurrentBranch,
46
+ branchExists,
47
+ createBranch,
48
+ checkoutBranch,
49
+ commit,
50
+ createPullRequest,
51
+ deriveBranchName,
52
+ hasUncommittedChanges,
53
+ getUncommittedDiff
54
+ } = require("../utils/git");
55
+
56
+ const config = require("./config");
57
+ const {
58
+ getPlansDir,
59
+ getCompletedDir,
60
+ getReportsDir
61
+ } = require("../utils/paths");
62
+
63
+ const {
64
+ generateCondensedPlan,
65
+ writeCondensedPlan,
66
+ deleteCondensedPlan
67
+ } = require("./condensed-plan");
68
+
69
+ const { ProgressTracker } = require("./progress-tracker");
70
+
71
+ const REPO_ROOT = process.cwd();
72
+
73
+ function parseArgs(argv) {
74
+ const options = {
75
+ plan: null,
76
+ dryRun: false,
77
+ verbose: false,
78
+ resume: false,
79
+ review: undefined
80
+ };
81
+
82
+ for (let i = 0; i < argv.length; i++) {
83
+ const arg = argv[i];
84
+ if (arg === "--plan") {
85
+ options.plan = argv[i + 1];
86
+ i += 1;
87
+ continue;
88
+ }
89
+ if (arg.startsWith("--plan=")) {
90
+ options.plan = arg.split("=").slice(1).join("=");
91
+ continue;
92
+ }
93
+ if (arg === "--dry-run") {
94
+ options.dryRun = true;
95
+ continue;
96
+ }
97
+ if (arg === "--verbose") {
98
+ options.verbose = true;
99
+ continue;
100
+ }
101
+ if (arg === "--resume") {
102
+ options.resume = true;
103
+ continue;
104
+ }
105
+ if (arg === "--review") {
106
+ options.review = true;
107
+ continue;
108
+ }
109
+ if (arg.startsWith("--review=")) {
110
+ const value = arg.split("=").slice(1).join("=");
111
+ options.review = parseEnvBoolean(value);
112
+ continue;
113
+ }
114
+ }
115
+
116
+ return options;
117
+ }
118
+
119
+ function parseEnvBoolean(value) {
120
+ if (value === undefined || value === null) {
121
+ return undefined;
122
+ }
123
+
124
+ const normalized = String(value).trim().toLowerCase();
125
+ if (["true", "1", "yes", "y", "on"].includes(normalized)) {
126
+ return true;
127
+ }
128
+ if (["false", "0", "no", "n", "off"].includes(normalized)) {
129
+ return false;
130
+ }
131
+
132
+ return undefined;
133
+ }
134
+
135
+ function parseEnvInteger(value) {
136
+ if (value === undefined || value === null) {
137
+ return undefined;
138
+ }
139
+ const parsed = parseInt(String(value).trim(), 10);
140
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
141
+ }
142
+
143
+ function resolveReviewEnabled(cliValue) {
144
+ if (typeof cliValue === "boolean") {
145
+ return cliValue;
146
+ }
147
+
148
+ const envValue = parseEnvBoolean(process.env.ORRERY_REVIEW_ENABLED);
149
+ if (typeof envValue === "boolean") {
150
+ return envValue;
151
+ }
152
+
153
+ return config.review.enabled;
154
+ }
155
+
156
+ function resolveReviewMaxIterations(cliValue) {
157
+ if (Number.isFinite(cliValue) && cliValue > 0) {
158
+ return cliValue;
159
+ }
160
+
161
+ const envValue = parseEnvInteger(process.env.ORRERY_REVIEW_MAX_ITERATIONS);
162
+ if (envValue !== undefined) {
163
+ return envValue;
164
+ }
165
+
166
+ return config.review.maxIterations;
167
+ }
168
+
169
+ function resolvePlanFile(planArg, plansDir) {
170
+ if (!planArg) return null;
171
+
172
+ const candidates = [];
173
+ if (path.isAbsolute(planArg)) {
174
+ candidates.push(planArg);
175
+ } else {
176
+ candidates.push(path.resolve(process.cwd(), planArg));
177
+ candidates.push(path.join(plansDir, planArg));
178
+ }
179
+
180
+ for (const candidate of candidates) {
181
+ if (fs.existsSync(candidate)) {
182
+ return candidate;
183
+ }
184
+ }
185
+
186
+ return null;
187
+ }
188
+
189
+ function logDryRunSummary(planFiles) {
190
+ console.log("Dry run: no changes will be made.");
191
+ if (planFiles.length === 0) {
192
+ console.log("No plans to process.");
193
+ return;
194
+ }
195
+
196
+ console.log(`Plans to process (${planFiles.length}):`);
197
+ for (const planFile of planFiles) {
198
+ const plan = loadPlan(planFile);
199
+ const totalSteps = plan.steps.length;
200
+ const completed = plan.steps.filter((s) => s.status === "complete").length;
201
+ const blocked = plan.steps.filter((s) => s.status === "blocked").length;
202
+ const pending = totalSteps - completed - blocked;
203
+ console.log(
204
+ ` - ${path.basename(planFile)} (${pending} pending, ${completed} complete, ${blocked} blocked)`
205
+ );
206
+ }
207
+ console.log();
208
+ }
209
+
210
+ /**
211
+ * Main orchestration function
212
+ */
213
+ async function orchestrate(options = {}) {
214
+ const normalizedOptions = {
215
+ plan: options.plan || null,
216
+ dryRun: Boolean(options.dryRun),
217
+ verbose: Boolean(options.verbose),
218
+ resume: Boolean(options.resume),
219
+ review: options.review
220
+ };
221
+
222
+ config.logging.streamOutput = normalizedOptions.verbose;
223
+ config.review.enabled = resolveReviewEnabled(normalizedOptions.review);
224
+
225
+ console.log("=== Plan Orchestrator Starting ===\n");
226
+
227
+ const plansDir = getPlansDir();
228
+ const completedDir = getCompletedDir();
229
+ const reportsDir = getReportsDir();
230
+
231
+ // Record the source branch we're starting from
232
+ const sourceBranch = getCurrentBranch(REPO_ROOT);
233
+ console.log(`Source branch: ${sourceBranch}\n`);
234
+
235
+ // Check for uncommitted changes
236
+ if (hasUncommittedChanges(REPO_ROOT)) {
237
+ console.error(
238
+ "Error: Uncommitted changes detected. Please commit or stash before running orchestrator."
239
+ );
240
+ process.exit(1);
241
+ }
242
+
243
+ // Resume mode: find and continue the plan for the current branch
244
+ if (normalizedOptions.resume) {
245
+ await handleResumeMode(plansDir, completedDir, reportsDir, sourceBranch);
246
+ return;
247
+ }
248
+
249
+ // Get list of completed plan filenames (to exclude)
250
+ const completedNames = getCompletedPlanNames(completedDir);
251
+
252
+ let planFiles = [];
253
+ let allPlanFiles = [];
254
+
255
+ if (normalizedOptions.plan) {
256
+ const resolvedPlanFile = resolvePlanFile(normalizedOptions.plan, plansDir);
257
+ if (!resolvedPlanFile) {
258
+ console.error(`Plan file not found: ${normalizedOptions.plan}`);
259
+ process.exit(1);
260
+ }
261
+ if (completedNames.has(path.basename(resolvedPlanFile))) {
262
+ console.log(`Plan already completed: ${path.basename(resolvedPlanFile)}`);
263
+ return;
264
+ }
265
+ allPlanFiles = [resolvedPlanFile];
266
+ } else {
267
+ // Scan for active plans
268
+ allPlanFiles = getPlanFiles(plansDir).filter(
269
+ (f) => !completedNames.has(path.basename(f))
270
+ );
271
+ }
272
+
273
+ // Filter out plans that are already dispatched (have work_branch set)
274
+ const dispatchedPlans = [];
275
+
276
+ for (const planFile of allPlanFiles) {
277
+ const plan = loadPlan(planFile);
278
+ if (plan.metadata.work_branch) {
279
+ dispatchedPlans.push({
280
+ file: path.basename(planFile),
281
+ workBranch: plan.metadata.work_branch
282
+ });
283
+ } else {
284
+ planFiles.push(planFile);
285
+ }
286
+ }
287
+
288
+ if (dispatchedPlans.length > 0) {
289
+ console.log(
290
+ `Skipping ${dispatchedPlans.length} already-dispatched plan(s):`
291
+ );
292
+ for (const dp of dispatchedPlans) {
293
+ console.log(` - ${dp.file} (work branch: ${dp.workBranch})`);
294
+ }
295
+ console.log();
296
+ }
297
+
298
+ if (planFiles.length === 0) {
299
+ console.log(
300
+ `No new plans to process in ${path.relative(process.cwd(), plansDir)}/`
301
+ );
302
+ console.log(
303
+ "Create a plan file without work_branch metadata to get started."
304
+ );
305
+ return;
306
+ }
307
+
308
+ if (normalizedOptions.dryRun) {
309
+ logDryRunSummary(planFiles);
310
+ return;
311
+ }
312
+
313
+ console.log(`Found ${planFiles.length} plan(s) to process:\n`);
314
+ for (const pf of planFiles) {
315
+ console.log(` - ${path.basename(pf)}`);
316
+ }
317
+ console.log();
318
+
319
+ // Process each plan (one at a time, with branch switching)
320
+ for (const planFile of planFiles) {
321
+ await processPlanWithBranching(
322
+ planFile,
323
+ sourceBranch,
324
+ completedDir,
325
+ reportsDir
326
+ );
327
+
328
+ // Reload plan to check final state
329
+ const plan = loadPlan(planFile);
330
+ const isComplete = plan.isComplete();
331
+ const isSuccessful = plan.isSuccessful();
332
+
333
+ if (isComplete && isSuccessful) {
334
+ // Plan completed successfully - return to source branch for next plan
335
+ const currentBranch = getCurrentBranch(REPO_ROOT);
336
+ if (currentBranch !== sourceBranch) {
337
+ console.log(`\nReturning to source branch: ${sourceBranch}`);
338
+ checkoutBranch(sourceBranch, REPO_ROOT);
339
+ }
340
+ } else {
341
+ // Plan is blocked - stay on work branch and stop processing
342
+ console.log(`\nPlan "${path.basename(planFile)}" is blocked.`);
343
+ console.log(`Staying on work branch: ${plan.metadata.work_branch}`);
344
+ console.log("\nTo continue:");
345
+ console.log(" 1. Fix the blocked steps (orrery status)");
346
+ console.log(" 2. Run 'orrery resume' to unblock and continue");
347
+
348
+ // List remaining unprocessed plans
349
+ const remaining = planFiles.slice(planFiles.indexOf(planFile) + 1);
350
+ if (remaining.length > 0) {
351
+ console.log(`\nSkipped ${remaining.length} remaining plan(s).`);
352
+ }
353
+ break; // Stop processing
354
+ }
355
+ }
356
+
357
+ console.log("\n=== Orchestrator Complete ===");
358
+ }
359
+
360
+ /**
361
+ * Handle resume mode: find and continue plan for current branch
362
+ */
363
+ async function handleResumeMode(
364
+ plansDir,
365
+ completedDir,
366
+ reportsDir,
367
+ currentBranch
368
+ ) {
369
+ console.log("=== Resume Mode ===\n");
370
+ console.log(`Looking for plan with work_branch: ${currentBranch}\n`);
371
+
372
+ // Get all plan files (including dispatched ones)
373
+ const completedNames = getCompletedPlanNames(completedDir);
374
+ const allPlanFiles = getPlanFiles(plansDir).filter(
375
+ (f) => !completedNames.has(path.basename(f))
376
+ );
377
+
378
+ // Find plan matching current branch
379
+ let matchingPlanFile = null;
380
+ let matchingPlan = null;
381
+
382
+ for (const planFile of allPlanFiles) {
383
+ const plan = loadPlan(planFile);
384
+ if (plan.metadata.work_branch === currentBranch) {
385
+ matchingPlanFile = planFile;
386
+ matchingPlan = plan;
387
+ break;
388
+ }
389
+ }
390
+
391
+ if (!matchingPlanFile) {
392
+ console.error(`No plan found with work_branch matching "${currentBranch}"`);
393
+ console.log("\nTo resume a plan:");
394
+ console.log(" 1. git checkout <work-branch>");
395
+ console.log(" 2. orrery exec --resume");
396
+ process.exit(1);
397
+ }
398
+
399
+ const planFileName = path.basename(matchingPlanFile);
400
+ console.log(`Found plan: ${planFileName}`);
401
+
402
+ // Check if plan has pending steps
403
+ if (matchingPlan.isComplete()) {
404
+ console.log("\nPlan is already complete (no pending steps).");
405
+ console.log("Use normal mode to create a PR or archive the plan.");
406
+ return;
407
+ }
408
+
409
+ const pendingSteps = matchingPlan.steps.filter((s) => s.status === "pending");
410
+ const inProgressSteps = matchingPlan.steps.filter(
411
+ (s) => s.status === "in_progress"
412
+ );
413
+ console.log(`Pending steps: ${pendingSteps.length}`);
414
+ if (inProgressSteps.length > 0) {
415
+ console.log(
416
+ `In-progress steps (will be retried): ${inProgressSteps.length}`
417
+ );
418
+ // Reset in_progress steps to pending so they get retried
419
+ for (const step of inProgressSteps) {
420
+ step.status = "pending";
421
+ }
422
+ savePlan(matchingPlan);
423
+ }
424
+
425
+ console.log("\nResuming plan execution...\n");
426
+
427
+ // Process the plan (reuse existing processPlan logic)
428
+ await processPlan(matchingPlanFile, completedDir, reportsDir);
429
+
430
+ // Reload and check final state
431
+ matchingPlan = loadPlan(matchingPlanFile);
432
+ const isComplete = matchingPlan.isComplete();
433
+
434
+ if (isComplete) {
435
+ // Archive and create PR
436
+ archivePlan(matchingPlanFile, matchingPlan, completedDir);
437
+
438
+ const workCommit = commit(
439
+ `chore: complete plan ${planFileName}`,
440
+ [],
441
+ REPO_ROOT
442
+ );
443
+ if (workCommit) {
444
+ console.log(`Committed plan completion (${workCommit.slice(0, 7)})`);
445
+ }
446
+
447
+ const sourceBranch = matchingPlan.metadata.source_branch || "main";
448
+ const prTitle = `Plan: ${planFileName.replace(/\.ya?ml$/, "").replace(/^\d{4}-\d{2}-\d{2}-/, "")}`;
449
+ const prBody = generatePRBody(matchingPlan);
450
+ const prInfo = createPullRequest(prTitle, prBody, sourceBranch, REPO_ROOT);
451
+ logPullRequestInfo(prInfo);
452
+ } else {
453
+ const progressCommit = commit(
454
+ `wip: progress on plan ${planFileName}`,
455
+ [],
456
+ REPO_ROOT
457
+ );
458
+ if (progressCommit) {
459
+ console.log(`Committed work-in-progress (${progressCommit.slice(0, 7)})`);
460
+ }
461
+ console.log(
462
+ "\nPlan still has pending steps. Run --resume again to continue."
463
+ );
464
+ }
465
+
466
+ console.log("\n=== Resume Complete ===");
467
+ }
468
+
469
+ /**
470
+ * Process a single plan with branch management
471
+ */
472
+ async function processPlanWithBranching(
473
+ planFile,
474
+ sourceBranch,
475
+ completedDir,
476
+ reportsDir
477
+ ) {
478
+ const planFileName = path.basename(planFile);
479
+ console.log(`\n--- Processing: ${planFileName} ---\n`);
480
+
481
+ // Step 1: Determine work branch name
482
+ const workBranch = deriveBranchName(planFileName);
483
+ console.log(`Work branch: ${workBranch}`);
484
+
485
+ // Step 2: Update plan metadata on source branch to mark as dispatched
486
+ let plan = loadPlan(planFile);
487
+ plan.metadata.source_branch = sourceBranch;
488
+ plan.metadata.work_branch = workBranch;
489
+ savePlan(plan);
490
+
491
+ // Commit the metadata update on source branch
492
+ const metadataCommit = commit(
493
+ `chore: dispatch plan ${planFileName} to ${workBranch}`,
494
+ [planFile],
495
+ REPO_ROOT
496
+ );
497
+ if (metadataCommit) {
498
+ console.log(
499
+ `Marked plan as dispatched on ${sourceBranch} (${metadataCommit.slice(0, 7)})`
500
+ );
501
+ }
502
+
503
+ // Step 3: Create and switch to work branch
504
+ if (branchExists(workBranch, REPO_ROOT)) {
505
+ console.log(`Work branch ${workBranch} already exists, checking out...`);
506
+ checkoutBranch(workBranch, REPO_ROOT);
507
+ } else {
508
+ console.log(`Creating work branch: ${workBranch}`);
509
+ createBranch(workBranch, REPO_ROOT);
510
+ }
511
+
512
+ // Step 4: Process the plan (main execution logic)
513
+ await processPlan(planFile, completedDir, reportsDir);
514
+
515
+ // Step 5: Reload plan to check final state
516
+ plan = loadPlan(planFile);
517
+ const isComplete = plan.isComplete();
518
+
519
+ if (isComplete) {
520
+ // Step 6: Archive the plan (on work branch)
521
+ archivePlan(planFile, plan, completedDir);
522
+
523
+ // Step 7: Commit all work branch changes
524
+ const workCommit = commit(
525
+ `chore: complete plan ${planFileName}`,
526
+ [], // Stage all changes
527
+ REPO_ROOT
528
+ );
529
+ if (workCommit) {
530
+ console.log(`Committed plan completion (${workCommit.slice(0, 7)})`);
531
+ }
532
+
533
+ // Step 8: Generate PR info
534
+ const prTitle = `Plan: ${planFileName.replace(/\.ya?ml$/, "").replace(/^\d{4}-\d{2}-\d{2}-/, "")}`;
535
+ const prBody = generatePRBody(plan);
536
+ const prInfo = createPullRequest(prTitle, prBody, sourceBranch, REPO_ROOT);
537
+ logPullRequestInfo(prInfo);
538
+ } else {
539
+ // Plan not complete (still has pending steps or was interrupted)
540
+ // Commit any progress made
541
+ const progressCommit = commit(
542
+ `wip: progress on plan ${planFileName}`,
543
+ [],
544
+ REPO_ROOT
545
+ );
546
+ if (progressCommit) {
547
+ console.log(`Committed work-in-progress (${progressCommit.slice(0, 7)})`);
548
+ }
549
+ console.log(
550
+ "\nPlan not complete. Work branch preserved for later continuation."
551
+ );
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Log pull request information for user to create PR manually
557
+ * @param {{url: string, title: string, body: string, headBranch: string, baseBranch: string, pushed: boolean}} prInfo
558
+ */
559
+ function logPullRequestInfo(prInfo) {
560
+ console.log("\n=== Pull Request Ready ===\n");
561
+
562
+ if (prInfo.pushed) {
563
+ console.log(`Branch pushed: ${prInfo.headBranch} -> origin`);
564
+ } else {
565
+ console.log(
566
+ `Note: Could not push branch. Run: git push -u origin ${prInfo.headBranch}`
567
+ );
568
+ }
569
+
570
+ console.log(`\nBase branch: ${prInfo.baseBranch}`);
571
+ console.log(`Head branch: ${prInfo.headBranch}`);
572
+
573
+ if (prInfo.url) {
574
+ console.log(`\nCreate PR: ${prInfo.url}`);
575
+ } else {
576
+ console.log("\nCould not generate PR URL (no remote configured).");
577
+ console.log("Create the PR manually on your Git hosting platform.");
578
+ }
579
+
580
+ console.log("\n--- PR Title ---");
581
+ console.log(prInfo.title);
582
+ console.log("\n--- PR Body ---");
583
+ console.log(prInfo.body);
584
+ console.log("----------------\n");
585
+ }
586
+
587
+ /**
588
+ * Generate PR body from completed plan
589
+ */
590
+ function generatePRBody(plan) {
591
+ const steps = plan.steps || [];
592
+ const completed = steps.filter((s) => s.status === "complete").length;
593
+ const blocked = steps.filter((s) => s.status === "blocked").length;
594
+ const total = steps.length;
595
+
596
+ let body = `## Plan Summary\n\n`;
597
+ body += `- **Status:** ${plan.metadata.outcome === "success" ? "All steps complete" : "Partial (some steps blocked)"}\n`;
598
+ body += `- **Steps:** ${completed}/${total} complete`;
599
+ if (blocked > 0) {
600
+ body += `, ${blocked} blocked`;
601
+ }
602
+ body += `\n\n`;
603
+
604
+ body += `## Steps\n\n`;
605
+ for (const step of steps) {
606
+ const icon =
607
+ step.status === "complete" ? "x" : step.status === "blocked" ? "-" : " ";
608
+ body += `- [${icon}] **${step.id}**: ${step.description}\n`;
609
+ if (step.status === "blocked" && step.blocked_reason) {
610
+ body += ` - Blocked: ${step.blocked_reason}\n`;
611
+ }
612
+ }
613
+
614
+ body += `\n---\n*Generated by Orrery*`;
615
+ return body;
616
+ }
617
+
618
+ /**
619
+ * Process a single plan file (core execution logic)
620
+ */
621
+ async function processPlan(planFile, completedDir, reportsDir) {
622
+ let plan = loadPlan(planFile);
623
+ const activeAgents = []; // Array of {handle, stepIds}
624
+
625
+ // Initialize progress tracker
626
+ const tracker = new ProgressTracker(
627
+ plan.steps.length,
628
+ path.basename(planFile)
629
+ );
630
+ tracker.initializeFromPlan(plan);
631
+ tracker.logStart();
632
+
633
+ // Check initial state
634
+ if (plan.isComplete()) {
635
+ console.log("Plan is already complete.");
636
+ tracker.logSummary();
637
+ return;
638
+ }
639
+
640
+ // Main execution loop
641
+ while (!plan.isComplete()) {
642
+ // Get steps ready to execute
643
+ const readySteps = getReadySteps(plan);
644
+
645
+ if (readySteps.length === 0) {
646
+ // Check if we have running agents
647
+ if (activeAgents.length > 0) {
648
+ // Wait for at least one to complete
649
+ const { stepIds, parsedResults } = await waitForAgentCompletion(
650
+ planFile,
651
+ activeAgents,
652
+ reportsDir,
653
+ tracker
654
+ );
655
+ plan = loadPlan(planFile); // Reload to get status updates
656
+
657
+ // Commit agent work using their commit message
658
+ if (hasUncommittedChanges(REPO_ROOT)) {
659
+ const commitMsg =
660
+ parsedResults[0]?.commitMessage ||
661
+ `feat: complete step(s) ${stepIds.join(", ")}`;
662
+ const commitSha = commit(commitMsg, [], REPO_ROOT);
663
+ if (commitSha) {
664
+ console.log(
665
+ `Committed: ${commitMsg.split("\n")[0]} (${commitSha.slice(0, 7)})`
666
+ );
667
+ }
668
+ }
669
+ continue;
670
+ } else {
671
+ // No ready steps and no running agents = fully blocked
672
+ console.log("Plan is blocked - no executable steps remaining.");
673
+ break;
674
+ }
675
+ }
676
+
677
+ // Determine how many we can start
678
+ const currentlyRunning = activeAgents.reduce(
679
+ (sum, a) => sum + a.stepIds.length,
680
+ 0
681
+ );
682
+ const { parallel, serial } = partitionSteps(
683
+ readySteps,
684
+ config.concurrency.maxParallel,
685
+ currentlyRunning
686
+ );
687
+
688
+ // Start parallel steps together (batch invocation)
689
+ if (parallel.length > 0) {
690
+ const stepIds = parallel.map((s) => s.id);
691
+ await startSteps(planFile, stepIds, activeAgents, tracker);
692
+ }
693
+
694
+ // Start serial steps individually
695
+ for (const step of serial) {
696
+ if (
697
+ activeAgents.reduce((sum, a) => sum + a.stepIds.length, 0) >=
698
+ config.concurrency.maxParallel
699
+ ) {
700
+ break;
701
+ }
702
+ await startSteps(planFile, [step.id], activeAgents, tracker);
703
+ }
704
+
705
+ // If we started any agents, wait for at least one to complete
706
+ if (activeAgents.length > 0) {
707
+ const { stepIds, parsedResults } = await waitForAgentCompletion(
708
+ planFile,
709
+ activeAgents,
710
+ reportsDir,
711
+ tracker
712
+ );
713
+ plan = loadPlan(planFile); // Reload to get status updates
714
+
715
+ // Commit agent work using their commit message
716
+ if (hasUncommittedChanges(REPO_ROOT)) {
717
+ const commitMsg =
718
+ parsedResults[0]?.commitMessage ||
719
+ `feat: complete step(s) ${stepIds.join(", ")}`;
720
+ const commitSha = commit(commitMsg, [], REPO_ROOT);
721
+ if (commitSha) {
722
+ console.log(
723
+ `Committed: ${commitMsg.split("\n")[0]} (${commitSha.slice(0, 7)})`
724
+ );
725
+ }
726
+ }
727
+ }
728
+ }
729
+
730
+ // Log final summary
731
+ tracker.logSummary();
732
+ }
733
+
734
+ /**
735
+ * Start steps by marking them in_progress and invoking an agent
736
+ * @param {string} planFile - Path to the plan file
737
+ * @param {string[]} stepIds - Array of step IDs to start
738
+ * @param {Object[]} activeAgents - Array of active agent handles
739
+ * @param {ProgressTracker} tracker - Progress tracker instance
740
+ */
741
+ async function startSteps(planFile, stepIds, activeAgents, tracker) {
742
+ // Log step start with progress info
743
+ tracker.logStepStart(stepIds);
744
+
745
+ // Mark steps as in_progress
746
+ const updates = stepIds.map((stepId) => ({
747
+ stepId,
748
+ status: "in_progress"
749
+ }));
750
+ updateStepsStatus(planFile, updates);
751
+
752
+ // Generate condensed plan with only assigned steps and their completed dependencies
753
+ const plan = loadPlan(planFile);
754
+ const condensedPlan = generateCondensedPlan(plan, stepIds);
755
+ const tempPlanFile = writeCondensedPlan(condensedPlan, planFile, stepIds);
756
+
757
+ // Invoke the agent with the condensed plan
758
+ const handle = invokeAgentWithFailover(
759
+ config,
760
+ tempPlanFile,
761
+ stepIds,
762
+ REPO_ROOT,
763
+ {
764
+ onStdout: (text, ids) => {
765
+ if (config.logging.streamOutput) {
766
+ const prefix = `[${ids.join(",")}]`;
767
+ const lines = text.trim().split("\n");
768
+ for (const line of lines) {
769
+ if (line.trim()) {
770
+ console.log(`${prefix} ${line}`);
771
+ }
772
+ }
773
+ }
774
+ },
775
+ onStderr: (text, ids) => {
776
+ if (config.logging.streamOutput) {
777
+ const prefix = `[${ids.join(",")}]`;
778
+ const lines = text.trim().split("\n");
779
+ for (const line of lines) {
780
+ if (line.trim()) {
781
+ // Note: stderrIsProgress is handled per-agent inside failover
782
+ console.log(`${prefix} ${line}`);
783
+ }
784
+ }
785
+ }
786
+ }
787
+ }
788
+ );
789
+
790
+ activeAgents.push({ handle, stepIds, tempPlanFile });
791
+ }
792
+
793
+ /**
794
+ * Wait for any agent to complete and process its results
795
+ * @param {string} planFile - Path to the plan file
796
+ * @param {Object[]} activeAgents - Array of active agent handles
797
+ * @param {string} reportsDir - Directory for reports
798
+ * @param {ProgressTracker} tracker - Progress tracker instance
799
+ * @returns {{stepIds: string[], parsedResults: Object[]}} Completed step IDs and parsed results
800
+ */
801
+ async function waitForAgentCompletion(
802
+ planFile,
803
+ activeAgents,
804
+ reportsDir,
805
+ tracker
806
+ ) {
807
+ if (activeAgents.length === 0) return { stepIds: [], parsedResults: [] };
808
+
809
+ // Wait for any agent to complete
810
+ const { result, index } = await waitForAny(activeAgents.map((a) => a.handle));
811
+ const { stepIds, tempPlanFile } = activeAgents[index];
812
+
813
+ // Remove completed agent from active list
814
+ activeAgents.splice(index, 1);
815
+
816
+ const agentName = result.agentName || "unknown";
817
+ console.log(
818
+ `Agent ${agentName} for step(s) ${stepIds.join(", ")} exited with code ${result.exitCode}`
819
+ );
820
+
821
+ // Log failure details for debugging
822
+ if (result.exitCode !== 0 && result.exitCode !== null) {
823
+ console.log(`[${agentName}] FAILED (exit ${result.exitCode})`);
824
+ if (result.stderr) {
825
+ console.log(`[${agentName}] stderr:\n${result.stderr}`);
826
+ }
827
+ if (result.stdout) {
828
+ console.log(`[${agentName}] stdout:\n${result.stdout}`);
829
+ }
830
+ }
831
+
832
+ // Parse results from stdout
833
+ let parsedResults = parseAgentResults(result.stdout);
834
+
835
+ const planForReview = loadPlan(planFile);
836
+
837
+ // Build updates for each step
838
+ const updates = [];
839
+ const reports = [];
840
+
841
+ for (const stepId of stepIds) {
842
+ // Find parsed result for this step, or create default
843
+ let stepResult = parsedResults.find((r) => r.stepId === stepId);
844
+ if (!stepResult) {
845
+ console.log(`[DEBUG] No report found for step ${stepId}`);
846
+ if (!result.stdout || result.stdout.trim().length === 0) {
847
+ console.log(`[DEBUG] Agent stdout was empty`);
848
+ } else {
849
+ console.log(`[DEBUG] Agent stdout:\n${result.stdout}`);
850
+ console.log(`[DEBUG] Parsed results:`, parsedResults);
851
+ }
852
+ stepResult = createDefaultResult(stepId, result.exitCode, result.stderr);
853
+ }
854
+
855
+ if (config.review.enabled && stepResult.status === "complete") {
856
+ const maxIterations = resolveReviewMaxIterations();
857
+ if (maxIterations > 0) {
858
+ const stepData =
859
+ (planForReview.steps || []).find((step) => step.id === stepId) ||
860
+ null;
861
+ const stepContext = stepData
862
+ ? {
863
+ id: stepData.id,
864
+ description: stepData.description,
865
+ context: stepData.context,
866
+ requirements: stepData.requirements,
867
+ criteria: stepData.criteria,
868
+ files: stepData.files,
869
+ risk_notes: stepData.risk_notes
870
+ }
871
+ : `Step ${stepId} context not found.`;
872
+
873
+ let approved = false;
874
+ let currentResult = stepResult;
875
+
876
+ for (let iteration = 1; iteration <= maxIterations; iteration++) {
877
+ const files =
878
+ Array.isArray(currentResult.artifacts) &&
879
+ currentResult.artifacts.length > 0
880
+ ? currentResult.artifacts
881
+ : stepData && Array.isArray(stepData.files)
882
+ ? stepData.files
883
+ : [];
884
+ const diff = getUncommittedDiff(REPO_ROOT, files);
885
+
886
+ console.log(
887
+ `Review iteration ${iteration}/${maxIterations} for step ${stepId}`
888
+ );
889
+ const reviewResult = await invokeReviewAgent(
890
+ config,
891
+ stepContext,
892
+ files,
893
+ diff,
894
+ REPO_ROOT,
895
+ {
896
+ planFile,
897
+ stepId,
898
+ stepIds: [stepId]
899
+ }
900
+ );
901
+
902
+ if (reviewResult.error) {
903
+ console.log(
904
+ `[WARN] Review output parse issue for step ${stepId}: ${reviewResult.error}`
905
+ );
906
+ }
907
+
908
+ if (reviewResult.approved) {
909
+ console.log(`Review approved for step ${stepId}`);
910
+ approved = true;
911
+ break;
912
+ }
913
+
914
+ const issueCount = reviewResult.feedback.length;
915
+ console.log(
916
+ `Review needs changes for step ${stepId}: ${issueCount} issue(s)`
917
+ );
918
+
919
+ if (iteration >= maxIterations) {
920
+ break;
921
+ }
922
+
923
+ const editResults = await invokeEditAgent(
924
+ config,
925
+ planFile,
926
+ [stepId],
927
+ reviewResult.feedback,
928
+ REPO_ROOT,
929
+ {
930
+ stepId,
931
+ stepIds: [stepId]
932
+ }
933
+ );
934
+
935
+ const editedResult =
936
+ editResults.find((r) => r.stepId === stepId) ||
937
+ createDefaultResult(stepId, null, "Edit agent returned no report");
938
+ currentResult = editedResult;
939
+
940
+ if (currentResult.status !== "complete") {
941
+ console.log(
942
+ `Edit agent reported ${currentResult.status} for step ${stepId}`
943
+ );
944
+ break;
945
+ }
946
+ }
947
+
948
+ if (!approved && currentResult.status === "complete") {
949
+ console.log(
950
+ `[WARN] Review max iterations reached for step ${stepId}. Proceeding without approval.`
951
+ );
952
+ }
953
+
954
+ stepResult = currentResult;
955
+ }
956
+ }
957
+
958
+ parsedResults = parsedResults.filter((r) => r.stepId !== stepId);
959
+ parsedResults.push(stepResult);
960
+
961
+ // Prepare plan update (include agent name)
962
+ const update = {
963
+ stepId,
964
+ status: stepResult.status,
965
+ extras: { agent: agentName }
966
+ };
967
+ if (stepResult.status === "blocked" && stepResult.blockedReason) {
968
+ update.extras.blocked_reason = stepResult.blockedReason;
969
+ }
970
+ updates.push(update);
971
+
972
+ // Prepare report
973
+ reports.push({
974
+ step_id: stepId,
975
+ agent: agentName,
976
+ outcome: stepResult.status === "complete" ? "success" : "failure",
977
+ details: stepResult.summary || "",
978
+ timestamp: new Date().toISOString(),
979
+ artifacts: stepResult.artifacts || [],
980
+ blocked_reason: stepResult.blockedReason || null,
981
+ test_results: stepResult.testResults || null
982
+ });
983
+ }
984
+
985
+ // Update plan file
986
+ updateStepsStatus(planFile, updates);
987
+
988
+ // Write reports
989
+ for (const report of reports) {
990
+ writeReport(reportsDir, planFile, report);
991
+ }
992
+
993
+ // Update progress tracker
994
+ for (const update of updates) {
995
+ if (update.status === "complete") {
996
+ tracker.recordComplete(update.stepId);
997
+ } else if (update.status === "blocked") {
998
+ tracker.recordBlocked(update.stepId);
999
+ }
1000
+ }
1001
+ tracker.logProgress();
1002
+
1003
+ // Handle blocked step cascades
1004
+ const plan = loadPlan(planFile);
1005
+ for (const update of updates) {
1006
+ if (update.status === "blocked") {
1007
+ const dependents = getBlockedDependents(plan, update.stepId);
1008
+ if (dependents.length > 0) {
1009
+ console.log(
1010
+ `Step ${update.stepId} blocked. Dependent steps affected: ${dependents.join(", ")}`
1011
+ );
1012
+ }
1013
+ }
1014
+ }
1015
+
1016
+ // Clean up temp plan file
1017
+ if (tempPlanFile) {
1018
+ deleteCondensedPlan(tempPlanFile);
1019
+ }
1020
+
1021
+ return { stepIds, parsedResults };
1022
+ }
1023
+
1024
+ /**
1025
+ * Write a step report to the reports directory
1026
+ */
1027
+ function writeReport(reportsDir, planFile, report) {
1028
+ const planName = path.basename(planFile, ".yaml");
1029
+ const fileName = `${planName}-${report.step_id}-report.yaml`;
1030
+ const filePath = path.join(reportsDir, fileName);
1031
+
1032
+ const content = YAML.stringify(report);
1033
+
1034
+ fs.writeFileSync(filePath, content, "utf8");
1035
+ console.log(`Report written: ${fileName}`);
1036
+ }
1037
+
1038
+ /**
1039
+ * Archive a completed plan
1040
+ */
1041
+ function archivePlan(planFile, plan, completedDir) {
1042
+ const success = plan.isSuccessful();
1043
+ const status = success ? "SUCCESS" : "PARTIAL (some steps blocked)";
1044
+
1045
+ // Add completion metadata
1046
+ plan.metadata.completed_at = new Date().toISOString();
1047
+ plan.metadata.outcome = success ? "success" : "partial";
1048
+ savePlan(plan);
1049
+
1050
+ // Move to completed
1051
+ const destPath = movePlanToCompleted(planFile, completedDir);
1052
+ console.log(`\nPlan archived: ${status}`);
1053
+ console.log(` -> ${path.relative(REPO_ROOT, destPath)}`);
1054
+ }
1055
+
1056
+ // Run if called directly
1057
+ if (require.main === module) {
1058
+ const cliOptions = parseArgs(process.argv.slice(2));
1059
+ orchestrate(cliOptions).catch((err) => {
1060
+ console.error("Orchestrator error:", err);
1061
+ process.exit(1);
1062
+ });
1063
+ }
1064
+
1065
+ module.exports = { orchestrate };