@caseyharalson/orrery 0.11.0 → 0.13.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.
@@ -13,6 +13,7 @@
13
13
  * - When complete, a PR is created and orchestrator returns to source branch
14
14
  */
15
15
 
16
+ const { execSync } = require("child_process");
16
17
  const fs = require("fs");
17
18
  const path = require("path");
18
19
  const YAML = require("yaml");
@@ -49,8 +50,10 @@ const {
49
50
  commit,
50
51
  createPullRequest,
51
52
  deriveBranchName,
53
+ derivePlanId,
52
54
  hasUncommittedChanges,
53
55
  addWorktree,
56
+ addWorktreeExistingBranch,
54
57
  removeWorktree,
55
58
  getCommitRange,
56
59
  cherryPick,
@@ -62,7 +65,8 @@ const config = require("./config");
62
65
  const {
63
66
  getPlansDir,
64
67
  getCompletedDir,
65
- getReportsDir
68
+ getReportsDir,
69
+ isWorkDirExternal
66
70
  } = require("../utils/paths");
67
71
 
68
72
  const {
@@ -72,6 +76,7 @@ const {
72
76
  } = require("./condensed-plan");
73
77
 
74
78
  const { ProgressTracker } = require("./progress-tracker");
79
+ const { acquireLock, releaseLock } = require("../utils/lock");
75
80
 
76
81
  const REPO_ROOT = process.cwd();
77
82
 
@@ -262,7 +267,8 @@ async function orchestrate(options = {}) {
262
267
  verbose: Boolean(options.verbose),
263
268
  resume: Boolean(options.resume),
264
269
  review: options.review,
265
- parallel: options.parallel
270
+ parallel: options.parallel,
271
+ onComplete: options.onComplete || null
266
272
  };
267
273
 
268
274
  config.logging.streamOutput = normalizedOptions.verbose;
@@ -277,140 +283,377 @@ async function orchestrate(options = {}) {
277
283
  );
278
284
  }
279
285
 
280
- console.log("=== Plan Orchestrator Starting ===\n");
286
+ // Per-plan worktree mode: when --plan is specified without --resume,
287
+ // use per-plan lock and worktree isolation for concurrent execution
288
+ if (
289
+ normalizedOptions.plan &&
290
+ !normalizedOptions.resume &&
291
+ !normalizedOptions.dryRun
292
+ ) {
293
+ await processPlanInWorktree(normalizedOptions);
294
+ return;
295
+ }
281
296
 
282
- const plansDir = getPlansDir();
283
- const completedDir = getCompletedDir();
284
- const reportsDir = getReportsDir();
297
+ // Per-plan worktree resume: when --plan + --resume, check for existing worktree
298
+ if (
299
+ normalizedOptions.plan &&
300
+ normalizedOptions.resume &&
301
+ !normalizedOptions.dryRun
302
+ ) {
303
+ const plansDir = getPlansDir();
304
+ const resolved = resolvePlanFile(normalizedOptions.plan, plansDir);
305
+ if (resolved) {
306
+ const planId = derivePlanId(path.basename(resolved));
307
+ const worktreePath = path.join(REPO_ROOT, ".worktrees", `plan-${planId}`);
308
+ if (fs.existsSync(worktreePath)) {
309
+ const plan = loadPlan(resolved);
310
+ const completedDir = getCompletedDir();
311
+ const reportsDir = getReportsDir();
312
+ await resumeInWorktree(
313
+ resolved,
314
+ plan,
315
+ planId,
316
+ worktreePath,
317
+ completedDir,
318
+ reportsDir,
319
+ normalizedOptions.onComplete
320
+ );
321
+ return;
322
+ }
323
+ }
324
+ // No worktree found — fall through to existing global-lock resume path
325
+ }
285
326
 
286
- // Record the source branch we're starting from
287
- const sourceBranch = getCurrentBranch(REPO_ROOT);
288
- console.log(`Source branch: ${sourceBranch}\n`);
327
+ // Acquire global execution lock (skip for dry-run)
328
+ if (!normalizedOptions.dryRun) {
329
+ const lockResult = acquireLock();
330
+ if (!lockResult.acquired) {
331
+ console.error(`Cannot start: ${lockResult.reason}`);
332
+ process.exitCode = 1;
333
+ return;
334
+ }
289
335
 
290
- // Check for uncommitted changes
291
- if (hasUncommittedChanges(REPO_ROOT)) {
292
- console.error(
293
- "Error: Uncommitted changes detected. Please commit or stash before running orchestrator."
294
- );
295
- process.exit(1);
336
+ // Clean up lock on signals
337
+ const cleanupLock = () => {
338
+ releaseLock();
339
+ process.exit();
340
+ };
341
+ process.on("SIGINT", cleanupLock);
342
+ process.on("SIGTERM", cleanupLock);
296
343
  }
297
344
 
298
- // Resume mode: find and continue the plan for the current branch
299
- if (normalizedOptions.resume) {
300
- await handleResumeMode(plansDir, completedDir, reportsDir, sourceBranch);
301
- return;
302
- }
345
+ try {
346
+ console.log("=== Plan Orchestrator Starting ===\n");
303
347
 
304
- // Get list of completed plan filenames (to exclude)
305
- const completedNames = getCompletedPlanNames(completedDir);
348
+ const plansDir = getPlansDir();
349
+ const completedDir = getCompletedDir();
350
+ const reportsDir = getReportsDir();
306
351
 
307
- let planFiles = [];
308
- let allPlanFiles = [];
352
+ // Get list of completed plan filenames (to exclude)
353
+ const completedNames = getCompletedPlanNames(completedDir);
309
354
 
310
- if (normalizedOptions.plan) {
311
- const resolvedPlanFile = resolvePlanFile(normalizedOptions.plan, plansDir);
312
- if (!resolvedPlanFile) {
313
- console.error(`Plan file not found: ${normalizedOptions.plan}`);
314
- process.exit(1);
355
+ let planFiles = [];
356
+ let allPlanFiles = [];
357
+
358
+ if (normalizedOptions.plan) {
359
+ const resolvedPlanFile = resolvePlanFile(
360
+ normalizedOptions.plan,
361
+ plansDir
362
+ );
363
+ if (!resolvedPlanFile) {
364
+ console.error(`Plan file not found: ${normalizedOptions.plan}`);
365
+ process.exit(1);
366
+ }
367
+ if (completedNames.has(path.basename(resolvedPlanFile))) {
368
+ console.log(
369
+ `Plan already completed: ${path.basename(resolvedPlanFile)}`
370
+ );
371
+ return;
372
+ }
373
+ allPlanFiles = [resolvedPlanFile];
374
+ } else {
375
+ // Scan for active plans
376
+ allPlanFiles = getPlanFiles(plansDir).filter(
377
+ (f) => !completedNames.has(path.basename(f))
378
+ );
379
+ }
380
+
381
+ // Filter out plans that are already dispatched (have work_branch set)
382
+ const dispatchedPlans = [];
383
+
384
+ for (const planFile of allPlanFiles) {
385
+ const plan = loadPlan(planFile);
386
+ if (plan.metadata.work_branch) {
387
+ dispatchedPlans.push({
388
+ file: path.basename(planFile),
389
+ workBranch: plan.metadata.work_branch
390
+ });
391
+ } else {
392
+ planFiles.push(planFile);
393
+ }
394
+ }
395
+
396
+ if (dispatchedPlans.length > 0) {
397
+ console.log(
398
+ `Skipping ${dispatchedPlans.length} already-dispatched plan(s):`
399
+ );
400
+ for (const dp of dispatchedPlans) {
401
+ console.log(` - ${dp.file} (work branch: ${dp.workBranch})`);
402
+ }
403
+ console.log();
404
+ }
405
+
406
+ if (planFiles.length === 0) {
407
+ console.log(
408
+ `No new plans to process in ${path.relative(process.cwd(), plansDir)}/`
409
+ );
410
+ console.log(
411
+ "Create a plan file without work_branch metadata to get started."
412
+ );
413
+ return;
315
414
  }
316
- if (completedNames.has(path.basename(resolvedPlanFile))) {
317
- console.log(`Plan already completed: ${path.basename(resolvedPlanFile)}`);
415
+
416
+ if (normalizedOptions.dryRun) {
417
+ logDryRunSummary(planFiles);
318
418
  return;
319
419
  }
320
- allPlanFiles = [resolvedPlanFile];
321
- } else {
322
- // Scan for active plans
323
- allPlanFiles = getPlanFiles(plansDir).filter(
324
- (f) => !completedNames.has(path.basename(f))
325
- );
326
- }
327
420
 
328
- // Filter out plans that are already dispatched (have work_branch set)
329
- const dispatchedPlans = [];
421
+ // Record the source branch we're starting from
422
+ const sourceBranch = getCurrentBranch(REPO_ROOT);
423
+ console.log(`Source branch: ${sourceBranch}\n`);
330
424
 
331
- for (const planFile of allPlanFiles) {
332
- const plan = loadPlan(planFile);
333
- if (plan.metadata.work_branch) {
334
- dispatchedPlans.push({
335
- file: path.basename(planFile),
336
- workBranch: plan.metadata.work_branch
337
- });
338
- } else {
339
- planFiles.push(planFile);
425
+ // Check for uncommitted changes
426
+ if (hasUncommittedChanges(REPO_ROOT)) {
427
+ console.error(
428
+ "Error: Uncommitted changes detected. Please commit or stash before running orchestrator."
429
+ );
430
+ process.exit(1);
340
431
  }
341
- }
342
432
 
343
- if (dispatchedPlans.length > 0) {
344
- console.log(
345
- `Skipping ${dispatchedPlans.length} already-dispatched plan(s):`
346
- );
347
- for (const dp of dispatchedPlans) {
348
- console.log(` - ${dp.file} (work branch: ${dp.workBranch})`);
433
+ // Resume mode: find and continue the plan for the current branch
434
+ if (normalizedOptions.resume) {
435
+ await handleResumeMode(
436
+ plansDir,
437
+ completedDir,
438
+ reportsDir,
439
+ sourceBranch,
440
+ normalizedOptions.plan,
441
+ normalizedOptions.onComplete
442
+ );
443
+ return;
444
+ }
445
+
446
+ console.log(`Found ${planFiles.length} plan(s) to process:\n`);
447
+ for (const pf of planFiles) {
448
+ console.log(` - ${path.basename(pf)}`);
349
449
  }
350
450
  console.log();
351
- }
352
451
 
353
- if (planFiles.length === 0) {
354
- console.log(
355
- `No new plans to process in ${path.relative(process.cwd(), plansDir)}/`
356
- );
357
- console.log(
358
- "Create a plan file without work_branch metadata to get started."
359
- );
360
- return;
452
+ // Process each plan (one at a time, with branch switching)
453
+ for (const planFile of planFiles) {
454
+ const result = await processPlanWithBranching(
455
+ planFile,
456
+ sourceBranch,
457
+ completedDir,
458
+ reportsDir,
459
+ parallelEnabled,
460
+ normalizedOptions.onComplete
461
+ );
462
+
463
+ if (result.isComplete && result.isSuccessful) {
464
+ // Plan completed successfully - return to source branch for next plan
465
+ const currentBranch = getCurrentBranch(REPO_ROOT);
466
+ if (currentBranch !== sourceBranch) {
467
+ console.log(`\nReturning to source branch: ${sourceBranch}`);
468
+ checkoutBranch(sourceBranch, REPO_ROOT);
469
+ }
470
+ } else {
471
+ // Plan is blocked - stay on work branch and stop processing
472
+ console.log(`\nPlan "${path.basename(planFile)}" is blocked.`);
473
+ console.log(`Staying on work branch: ${result.workBranch}`);
474
+ console.log("\nTo continue:");
475
+ console.log(" 1. Fix the blocked steps (orrery status)");
476
+ console.log(" 2. Run 'orrery resume' to unblock and continue");
477
+
478
+ // List remaining unprocessed plans
479
+ const remaining = planFiles.slice(planFiles.indexOf(planFile) + 1);
480
+ if (remaining.length > 0) {
481
+ console.log(`\nSkipped ${remaining.length} remaining plan(s).`);
482
+ }
483
+ break; // Stop processing
484
+ }
485
+ }
486
+
487
+ console.log("\n=== Orchestrator Complete ===");
488
+ } finally {
489
+ if (!normalizedOptions.dryRun) releaseLock();
361
490
  }
491
+ }
362
492
 
363
- if (normalizedOptions.dryRun) {
364
- logDryRunSummary(planFiles);
493
+ /**
494
+ * Resume a plan inside its existing worktree.
495
+ * @param {string} planFile - Resolved plan file path
496
+ * @param {Object} plan - Loaded plan object
497
+ * @param {string} planId - Plan ID for locking
498
+ * @param {string} worktreePath - Path to existing worktree
499
+ * @param {string} completedDir - Directory for completed plans
500
+ * @param {string} reportsDir - Directory for reports
501
+ */
502
+ async function resumeInWorktree(
503
+ planFile,
504
+ plan,
505
+ planId,
506
+ worktreePath,
507
+ completedDir,
508
+ reportsDir,
509
+ onComplete
510
+ ) {
511
+ const planFileName = path.basename(planFile);
512
+
513
+ // Acquire per-plan lock
514
+ const lockResult = acquireLock(planId, { worktreePath });
515
+ if (!lockResult.acquired) {
516
+ console.error(`Cannot resume plan "${planId}": ${lockResult.reason}`);
517
+ process.exitCode = 1;
365
518
  return;
366
519
  }
367
520
 
368
- console.log(`Found ${planFiles.length} plan(s) to process:\n`);
369
- for (const pf of planFiles) {
370
- console.log(` - ${path.basename(pf)}`);
371
- }
372
- console.log();
521
+ const cleanupLock = () => {
522
+ releaseLock(planId);
523
+ process.exit();
524
+ };
525
+ process.on("SIGINT", cleanupLock);
526
+ process.on("SIGTERM", cleanupLock);
373
527
 
374
- // Process each plan (one at a time, with branch switching)
375
- for (const planFile of planFiles) {
376
- await processPlanWithBranching(
377
- planFile,
378
- sourceBranch,
379
- completedDir,
380
- reportsDir,
381
- parallelEnabled
528
+ try {
529
+ process.env.ORRERY_REPO_ROOT = REPO_ROOT;
530
+
531
+ console.log(`Found plan: ${planFileName}`);
532
+
533
+ if (plan.isComplete()) {
534
+ console.log("\nPlan is already complete (no pending steps).");
535
+ return;
536
+ }
537
+
538
+ const pendingSteps = plan.steps.filter((s) => s.status === "pending");
539
+ const inProgressSteps = plan.steps.filter(
540
+ (s) => s.status === "in_progress"
382
541
  );
542
+ console.log(`Pending steps: ${pendingSteps.length}`);
543
+ if (inProgressSteps.length > 0) {
544
+ console.log(
545
+ `In-progress steps (will be retried): ${inProgressSteps.length}`
546
+ );
547
+ for (const step of inProgressSteps) {
548
+ step.status = "pending";
549
+ }
550
+ savePlan(plan);
551
+ }
383
552
 
384
- // Reload plan to check final state
385
- const plan = loadPlan(planFile);
553
+ console.log("\nResuming plan execution in worktree...\n");
554
+
555
+ const parallelEnabled = resolveParallelEnabled(undefined);
556
+ if (parallelEnabled) {
557
+ config.concurrency.maxParallel = resolveParallelMax();
558
+ console.log(
559
+ `Parallel mode enabled (max ${config.concurrency.maxParallel} concurrent agents)`
560
+ );
561
+ }
562
+
563
+ const ctx = { workingDir: worktreePath, mainRepoRoot: REPO_ROOT };
564
+ await processPlan(planFile, completedDir, reportsDir, parallelEnabled, ctx);
565
+
566
+ // Reload and check final state
567
+ plan = loadPlan(planFile);
386
568
  const isComplete = plan.isComplete();
387
- const isSuccessful = plan.isSuccessful();
388
569
 
389
- if (isComplete && isSuccessful) {
390
- // Plan completed successfully - return to source branch for next plan
391
- const currentBranch = getCurrentBranch(REPO_ROOT);
392
- if (currentBranch !== sourceBranch) {
393
- console.log(`\nReturning to source branch: ${sourceBranch}`);
394
- checkoutBranch(sourceBranch, REPO_ROOT);
570
+ if (isComplete) {
571
+ archivePlan(planFile, plan, completedDir);
572
+
573
+ const workCommit = commit(
574
+ `chore: complete plan ${planFileName}`,
575
+ [],
576
+ worktreePath
577
+ );
578
+ if (workCommit) {
579
+ console.log(`Committed plan completion (${workCommit.slice(0, 7)})`);
395
580
  }
396
- } else {
397
- // Plan is blocked - stay on work branch and stop processing
398
- console.log(`\nPlan "${path.basename(planFile)}" is blocked.`);
399
- console.log(`Staying on work branch: ${plan.metadata.work_branch}`);
400
- console.log("\nTo continue:");
401
- console.log(" 1. Fix the blocked steps (orrery status)");
402
- console.log(" 2. Run 'orrery resume' to unblock and continue");
403
581
 
404
- // List remaining unprocessed plans
405
- const remaining = planFiles.slice(planFiles.indexOf(planFile) + 1);
406
- if (remaining.length > 0) {
407
- console.log(`\nSkipped ${remaining.length} remaining plan(s).`);
582
+ const sourceBranch = plan.metadata.source_branch || "main";
583
+ const prTitle = `Plan: ${planFileName.replace(/\.ya?ml$/, "").replace(/^\d{4}-\d{2}-\d{2}-/, "")}`;
584
+ const prBody = generatePRBody(plan);
585
+ const prInfo = createPullRequest(
586
+ prTitle,
587
+ prBody,
588
+ sourceBranch,
589
+ worktreePath
590
+ );
591
+ logPullRequestInfo(prInfo);
592
+
593
+ const isSuccessful = plan.isSuccessful();
594
+ runOnCompleteHook(
595
+ onComplete,
596
+ {
597
+ planName: planFileName,
598
+ planFile,
599
+ outcome: isSuccessful ? "success" : "partial",
600
+ workBranch: plan.metadata.work_branch || "",
601
+ sourceBranch,
602
+ prUrl: prInfo.url || "",
603
+ stepsTotal: plan.steps.length,
604
+ stepsCompleted: plan.steps.filter((s) => s.status === "complete")
605
+ .length,
606
+ stepsBlocked: plan.steps.filter((s) => s.status === "blocked").length
607
+ },
608
+ worktreePath
609
+ );
610
+
611
+ // Clean up worktree on success
612
+ try {
613
+ removeWorktree(worktreePath, REPO_ROOT);
614
+ console.log(`Cleaned up worktree: ${worktreePath}`);
615
+ } catch (err) {
616
+ console.error(`Failed to clean up worktree: ${err.message}`);
617
+ }
618
+ } else {
619
+ const progressCommit = commit(
620
+ `wip: progress on plan ${planFileName}`,
621
+ [],
622
+ worktreePath
623
+ );
624
+ if (progressCommit) {
625
+ console.log(
626
+ `Committed work-in-progress (${progressCommit.slice(0, 7)})`
627
+ );
408
628
  }
409
- break; // Stop processing
629
+
630
+ runOnCompleteHook(
631
+ onComplete,
632
+ {
633
+ planName: planFileName,
634
+ planFile,
635
+ outcome: "incomplete",
636
+ workBranch: plan.metadata.work_branch || "",
637
+ sourceBranch: plan.metadata.source_branch || "main",
638
+ prUrl: "",
639
+ stepsTotal: plan.steps.length,
640
+ stepsCompleted: plan.steps.filter((s) => s.status === "complete")
641
+ .length,
642
+ stepsBlocked: plan.steps.filter((s) => s.status === "blocked").length
643
+ },
644
+ worktreePath
645
+ );
646
+
647
+ console.log(
648
+ "\nPlan still has pending steps. Run --resume again to continue."
649
+ );
410
650
  }
411
- }
412
651
 
413
- console.log("\n=== Orchestrator Complete ===");
652
+ console.log("\n=== Resume Complete ===");
653
+ } finally {
654
+ delete process.env.ORRERY_REPO_ROOT;
655
+ releaseLock(planId);
656
+ }
414
657
  }
415
658
 
416
659
  /**
@@ -420,36 +663,93 @@ async function handleResumeMode(
420
663
  plansDir,
421
664
  completedDir,
422
665
  reportsDir,
423
- currentBranch
666
+ currentBranch,
667
+ planFileArg,
668
+ onComplete
424
669
  ) {
425
670
  console.log("=== Resume Mode ===\n");
426
- console.log(`Looking for plan with work_branch: ${currentBranch}\n`);
427
671
 
428
- // Get all plan files (including dispatched ones)
429
- const completedNames = getCompletedPlanNames(completedDir);
430
- const allPlanFiles = getPlanFiles(plansDir).filter(
431
- (f) => !completedNames.has(path.basename(f))
432
- );
433
-
434
- // Find plan matching current branch
435
672
  let matchingPlanFile = null;
436
673
  let matchingPlan = null;
437
674
 
438
- for (const planFile of allPlanFiles) {
439
- const plan = loadPlan(planFile);
440
- if (plan.metadata.work_branch === currentBranch) {
441
- matchingPlanFile = planFile;
442
- matchingPlan = plan;
443
- break;
675
+ if (planFileArg) {
676
+ // Resolve the plan file from argument
677
+ const resolved = resolvePlanFile(planFileArg, plansDir);
678
+ if (!resolved) {
679
+ console.error(`Plan file not found: ${planFileArg}`);
680
+ process.exit(1);
444
681
  }
445
- }
446
682
 
447
- if (!matchingPlanFile) {
448
- console.error(`No plan found with work_branch matching "${currentBranch}"`);
449
- console.log("\nTo resume a plan:");
450
- console.log(" 1. git checkout <work-branch>");
451
- console.log(" 2. orrery exec --resume");
452
- process.exit(1);
683
+ matchingPlan = loadPlan(resolved);
684
+ matchingPlanFile = resolved;
685
+
686
+ // Validate work_branch
687
+ if (!matchingPlan.metadata.work_branch) {
688
+ console.error("Plan has no work_branch — it hasn't been dispatched yet.");
689
+ console.log(
690
+ "\nUse 'orrery exec --plan <file>' to dispatch the plan first."
691
+ );
692
+ process.exit(1);
693
+ }
694
+
695
+ // Check for existing worktree for this plan
696
+ const planId = derivePlanId(path.basename(resolved));
697
+ const worktreePath = path.join(REPO_ROOT, ".worktrees", `plan-${planId}`);
698
+ if (fs.existsSync(worktreePath)) {
699
+ // Resume in worktree mode
700
+ console.log(`Found existing worktree: ${worktreePath}`);
701
+ console.log(`Using specified plan: ${path.basename(resolved)}\n`);
702
+ await resumeInWorktree(
703
+ resolved,
704
+ matchingPlan,
705
+ planId,
706
+ worktreePath,
707
+ completedDir,
708
+ reportsDir,
709
+ onComplete
710
+ );
711
+ return;
712
+ }
713
+
714
+ if (matchingPlan.metadata.work_branch !== currentBranch) {
715
+ console.error(
716
+ `Plan expects branch '${matchingPlan.metadata.work_branch}' but you are on '${currentBranch}'.`
717
+ );
718
+ console.log(`\nRun: git checkout ${matchingPlan.metadata.work_branch}`);
719
+ process.exit(1);
720
+ }
721
+
722
+ console.log(`Using specified plan: ${path.basename(resolved)}\n`);
723
+ } else {
724
+ console.log(`Looking for plan with work_branch: ${currentBranch}\n`);
725
+
726
+ // Get all plan files (including dispatched ones)
727
+ const completedNames = getCompletedPlanNames(completedDir);
728
+ const allPlanFiles = getPlanFiles(plansDir).filter(
729
+ (f) => !completedNames.has(path.basename(f))
730
+ );
731
+
732
+ // Find plan matching current branch
733
+ for (const planFile of allPlanFiles) {
734
+ const plan = loadPlan(planFile);
735
+ if (plan.metadata.work_branch === currentBranch) {
736
+ matchingPlanFile = planFile;
737
+ matchingPlan = plan;
738
+ break;
739
+ }
740
+ }
741
+
742
+ if (!matchingPlanFile) {
743
+ console.error(
744
+ `No plan found with work_branch matching "${currentBranch}"`
745
+ );
746
+ console.log("\nTo resume a plan:");
747
+ console.log(" 1. git checkout <work-branch>");
748
+ console.log(" 2. orrery exec --resume");
749
+ console.log("\nOr specify a plan directly:");
750
+ console.log(" orrery exec --resume --plan <file>");
751
+ process.exit(1);
752
+ }
453
753
  }
454
754
 
455
755
  const planFileName = path.basename(matchingPlanFile);
@@ -518,6 +818,26 @@ async function handleResumeMode(
518
818
  const prBody = generatePRBody(matchingPlan);
519
819
  const prInfo = createPullRequest(prTitle, prBody, sourceBranch, REPO_ROOT);
520
820
  logPullRequestInfo(prInfo);
821
+
822
+ const isSuccessful = matchingPlan.isSuccessful();
823
+ runOnCompleteHook(
824
+ onComplete,
825
+ {
826
+ planName: planFileName,
827
+ planFile: matchingPlanFile,
828
+ outcome: isSuccessful ? "success" : "partial",
829
+ workBranch: matchingPlan.metadata.work_branch || currentBranch,
830
+ sourceBranch,
831
+ prUrl: prInfo.url || "",
832
+ stepsTotal: matchingPlan.steps.length,
833
+ stepsCompleted: matchingPlan.steps.filter(
834
+ (s) => s.status === "complete"
835
+ ).length,
836
+ stepsBlocked: matchingPlan.steps.filter((s) => s.status === "blocked")
837
+ .length
838
+ },
839
+ REPO_ROOT
840
+ );
521
841
  } else {
522
842
  const progressCommit = commit(
523
843
  `wip: progress on plan ${planFileName}`,
@@ -527,6 +847,26 @@ async function handleResumeMode(
527
847
  if (progressCommit) {
528
848
  console.log(`Committed work-in-progress (${progressCommit.slice(0, 7)})`);
529
849
  }
850
+
851
+ runOnCompleteHook(
852
+ onComplete,
853
+ {
854
+ planName: planFileName,
855
+ planFile: matchingPlanFile,
856
+ outcome: "incomplete",
857
+ workBranch: matchingPlan.metadata.work_branch || currentBranch,
858
+ sourceBranch: matchingPlan.metadata.source_branch || "main",
859
+ prUrl: "",
860
+ stepsTotal: matchingPlan.steps.length,
861
+ stepsCompleted: matchingPlan.steps.filter(
862
+ (s) => s.status === "complete"
863
+ ).length,
864
+ stepsBlocked: matchingPlan.steps.filter((s) => s.status === "blocked")
865
+ .length
866
+ },
867
+ REPO_ROOT
868
+ );
869
+
530
870
  console.log(
531
871
  "\nPlan still has pending steps. Run --resume again to continue."
532
872
  );
@@ -535,6 +875,235 @@ async function handleResumeMode(
535
875
  console.log("\n=== Resume Complete ===");
536
876
  }
537
877
 
878
+ /**
879
+ * Process a single plan in an isolated git worktree.
880
+ * Uses per-plan locking to allow concurrent execution of multiple plans.
881
+ * @param {Object} normalizedOptions - Normalized orchestration options
882
+ */
883
+ async function processPlanInWorktree(normalizedOptions) {
884
+ const plansDir = getPlansDir();
885
+ const completedDir = getCompletedDir();
886
+ const reportsDir = getReportsDir();
887
+
888
+ // Handle parallel mode configuration
889
+ const parallelEnabled = resolveParallelEnabled(normalizedOptions.parallel);
890
+ if (parallelEnabled) {
891
+ config.concurrency.maxParallel = resolveParallelMax();
892
+ }
893
+
894
+ // Resolve the plan file
895
+ const resolvedPlanFile = resolvePlanFile(normalizedOptions.plan, plansDir);
896
+ if (!resolvedPlanFile) {
897
+ console.error(`Plan file not found: ${normalizedOptions.plan}`);
898
+ process.exitCode = 1;
899
+ return;
900
+ }
901
+
902
+ const completedNames = getCompletedPlanNames(completedDir);
903
+ const planFileName = path.basename(resolvedPlanFile);
904
+ if (completedNames.has(planFileName)) {
905
+ console.log(`Plan already completed: ${planFileName}`);
906
+ return;
907
+ }
908
+
909
+ // Derive plan ID and work branch
910
+ const planId = derivePlanId(planFileName);
911
+ const workBranch = deriveBranchName(planFileName);
912
+
913
+ // Acquire per-plan lock
914
+ const worktreesDir = path.join(REPO_ROOT, ".worktrees");
915
+ const worktreePath = path.join(worktreesDir, `plan-${planId}`);
916
+
917
+ const lockResult = acquireLock(planId, { worktreePath });
918
+ if (!lockResult.acquired) {
919
+ console.error(`Cannot start plan "${planId}": ${lockResult.reason}`);
920
+ process.exitCode = 1;
921
+ return;
922
+ }
923
+
924
+ // Clean up per-plan lock on signals
925
+ const cleanupLock = () => {
926
+ releaseLock(planId);
927
+ process.exit();
928
+ };
929
+ process.on("SIGINT", cleanupLock);
930
+ process.on("SIGTERM", cleanupLock);
931
+
932
+ try {
933
+ console.log("=== Plan Orchestrator Starting (worktree mode) ===\n");
934
+ console.log(`Plan: ${planFileName}`);
935
+ console.log(`Plan ID: ${planId}`);
936
+ console.log(`Work branch: ${workBranch}`);
937
+
938
+ // Set ORRERY_REPO_ROOT so path resolution works from inside the worktree
939
+ process.env.ORRERY_REPO_ROOT = REPO_ROOT;
940
+
941
+ const sourceBranch = getCurrentBranch(REPO_ROOT);
942
+ console.log(`Source branch: ${sourceBranch}\n`);
943
+
944
+ // Check for uncommitted changes in the main repo
945
+ if (hasUncommittedChanges(REPO_ROOT)) {
946
+ console.error(
947
+ "Error: Uncommitted changes detected. Please commit or stash before running orchestrator."
948
+ );
949
+ process.exitCode = 1;
950
+ return;
951
+ }
952
+
953
+ // Update plan metadata (in the plan file, not committed to source branch)
954
+ let plan = loadPlan(resolvedPlanFile);
955
+ plan.metadata.source_branch = sourceBranch;
956
+ plan.metadata.work_branch = workBranch;
957
+ savePlan(plan);
958
+
959
+ // Create work branch if needed
960
+ if (!branchExists(workBranch, REPO_ROOT)) {
961
+ console.log(`Creating work branch: ${workBranch}`);
962
+ createBranch(workBranch, REPO_ROOT);
963
+ // Switch back to source branch — the worktree will use the work branch
964
+ checkoutBranch(sourceBranch, REPO_ROOT);
965
+ }
966
+
967
+ // Create or reuse plan worktree
968
+ if (!fs.existsSync(worktreesDir)) {
969
+ fs.mkdirSync(worktreesDir, { recursive: true });
970
+ }
971
+
972
+ if (fs.existsSync(worktreePath)) {
973
+ console.log(`Reusing existing worktree: ${worktreePath}`);
974
+ } else {
975
+ console.log(`Creating worktree: ${worktreePath}`);
976
+ addWorktreeExistingBranch(worktreePath, workBranch, REPO_ROOT);
977
+ }
978
+
979
+ // Build execution context
980
+ const ctx = { workingDir: worktreePath, mainRepoRoot: REPO_ROOT };
981
+
982
+ if (parallelEnabled) {
983
+ console.log(
984
+ `Parallel mode enabled (max ${config.concurrency.maxParallel} concurrent agents)`
985
+ );
986
+ }
987
+
988
+ console.log(`\nWorking directory: ${worktreePath}\n`);
989
+
990
+ // Process the plan inside the worktree
991
+ await processPlan(
992
+ resolvedPlanFile,
993
+ completedDir,
994
+ reportsDir,
995
+ parallelEnabled,
996
+ ctx
997
+ );
998
+
999
+ // Reload and check final state
1000
+ plan = loadPlan(resolvedPlanFile);
1001
+ const isComplete = plan.isComplete();
1002
+
1003
+ if (isComplete) {
1004
+ const isSuccessful = plan.isSuccessful();
1005
+
1006
+ // Archive the plan
1007
+ archivePlan(resolvedPlanFile, plan, completedDir);
1008
+
1009
+ // Commit all work branch changes (inside worktree)
1010
+ const workCommit = commit(
1011
+ `chore: complete plan ${planFileName}`,
1012
+ [],
1013
+ worktreePath
1014
+ );
1015
+ if (workCommit) {
1016
+ console.log(`Committed plan completion (${workCommit.slice(0, 7)})`);
1017
+ }
1018
+
1019
+ // Generate PR info
1020
+ const prTitle = `Plan: ${planFileName.replace(/\.ya?ml$/, "").replace(/^\d{4}-\d{2}-\d{2}-/, "")}`;
1021
+ const prBody = generatePRBody(plan);
1022
+ const prInfo = createPullRequest(
1023
+ prTitle,
1024
+ prBody,
1025
+ sourceBranch,
1026
+ worktreePath
1027
+ );
1028
+ logPullRequestInfo(prInfo);
1029
+
1030
+ // Run on-complete hook before worktree cleanup
1031
+ runOnCompleteHook(
1032
+ normalizedOptions.onComplete,
1033
+ {
1034
+ planName: planFileName,
1035
+ planFile: resolvedPlanFile,
1036
+ outcome: isSuccessful ? "success" : "partial",
1037
+ workBranch,
1038
+ sourceBranch,
1039
+ prUrl: prInfo.url || "",
1040
+ stepsTotal: plan.steps.length,
1041
+ stepsCompleted: plan.steps.filter((s) => s.status === "complete")
1042
+ .length,
1043
+ stepsBlocked: plan.steps.filter((s) => s.status === "blocked").length
1044
+ },
1045
+ worktreePath
1046
+ );
1047
+
1048
+ // Clean up worktree on success
1049
+ try {
1050
+ removeWorktree(worktreePath, REPO_ROOT);
1051
+ console.log(`Cleaned up worktree: ${worktreePath}`);
1052
+ } catch (err) {
1053
+ console.error(`Failed to clean up worktree: ${err.message}`);
1054
+ }
1055
+
1056
+ if (isSuccessful) {
1057
+ console.log("\n=== Plan Complete (success) ===");
1058
+ } else {
1059
+ console.log("\n=== Plan Complete (partial — some steps blocked) ===");
1060
+ }
1061
+ } else {
1062
+ // Plan not complete — commit WIP and preserve worktree
1063
+ const progressCommit = commit(
1064
+ `wip: progress on plan ${planFileName}`,
1065
+ [],
1066
+ worktreePath
1067
+ );
1068
+ if (progressCommit) {
1069
+ console.log(
1070
+ `Committed work-in-progress (${progressCommit.slice(0, 7)})`
1071
+ );
1072
+ }
1073
+
1074
+ runOnCompleteHook(
1075
+ normalizedOptions.onComplete,
1076
+ {
1077
+ planName: planFileName,
1078
+ planFile: resolvedPlanFile,
1079
+ outcome: "incomplete",
1080
+ workBranch,
1081
+ sourceBranch,
1082
+ prUrl: "",
1083
+ stepsTotal: plan.steps.length,
1084
+ stepsCompleted: plan.steps.filter((s) => s.status === "complete")
1085
+ .length,
1086
+ stepsBlocked: plan.steps.filter((s) => s.status === "blocked").length
1087
+ },
1088
+ worktreePath
1089
+ );
1090
+
1091
+ console.log(
1092
+ "\nPlan not complete. Worktree preserved for later continuation."
1093
+ );
1094
+ console.log(`Worktree: ${worktreePath}`);
1095
+ console.log("\nTo continue:");
1096
+ console.log(" 1. Fix the blocked steps (orrery status)");
1097
+ console.log(
1098
+ ` 2. Run 'orrery resume --plan ${normalizedOptions.plan}' to continue`
1099
+ );
1100
+ }
1101
+ } finally {
1102
+ delete process.env.ORRERY_REPO_ROOT;
1103
+ releaseLock(planId);
1104
+ }
1105
+ }
1106
+
538
1107
  /**
539
1108
  * Process a single plan with branch management
540
1109
  * @param {string} planFile - Path to the plan file
@@ -542,13 +1111,15 @@ async function handleResumeMode(
542
1111
  * @param {string} completedDir - Directory for completed plans
543
1112
  * @param {string} reportsDir - Directory for reports
544
1113
  * @param {boolean} parallelEnabled - Whether parallel execution with worktrees is enabled
1114
+ * @returns {Promise<{isComplete: boolean, isSuccessful: boolean, workBranch: string}>}
545
1115
  */
546
1116
  async function processPlanWithBranching(
547
1117
  planFile,
548
1118
  sourceBranch,
549
1119
  completedDir,
550
1120
  reportsDir,
551
- parallelEnabled = false
1121
+ parallelEnabled = false,
1122
+ onComplete = null
552
1123
  ) {
553
1124
  const planFileName = path.basename(planFile);
554
1125
  console.log(`\n--- Processing: ${planFileName} ---\n`);
@@ -563,16 +1134,18 @@ async function processPlanWithBranching(
563
1134
  plan.metadata.work_branch = workBranch;
564
1135
  savePlan(plan);
565
1136
 
566
- // Commit the metadata update on source branch
567
- const metadataCommit = commit(
568
- `chore: dispatch plan ${planFileName} to ${workBranch}`,
569
- [planFile],
570
- REPO_ROOT
571
- );
572
- if (metadataCommit) {
573
- console.log(
574
- `Marked plan as dispatched on ${sourceBranch} (${metadataCommit.slice(0, 7)})`
1137
+ // Commit the metadata update on source branch (only if work dir is inside repo)
1138
+ if (!isWorkDirExternal()) {
1139
+ const metadataCommit = commit(
1140
+ `chore: dispatch plan ${planFileName} to ${workBranch}`,
1141
+ [planFile],
1142
+ REPO_ROOT
575
1143
  );
1144
+ if (metadataCommit) {
1145
+ console.log(
1146
+ `Marked plan as dispatched on ${sourceBranch} (${metadataCommit.slice(0, 7)})`
1147
+ );
1148
+ }
576
1149
  }
577
1150
 
578
1151
  // Step 3: Create and switch to work branch
@@ -592,6 +1165,8 @@ async function processPlanWithBranching(
592
1165
  const isComplete = plan.isComplete();
593
1166
 
594
1167
  if (isComplete) {
1168
+ const isSuccessful = plan.isSuccessful();
1169
+
595
1170
  // Step 6: Archive the plan (on work branch)
596
1171
  archivePlan(planFile, plan, completedDir);
597
1172
 
@@ -610,6 +1185,25 @@ async function processPlanWithBranching(
610
1185
  const prBody = generatePRBody(plan);
611
1186
  const prInfo = createPullRequest(prTitle, prBody, sourceBranch, REPO_ROOT);
612
1187
  logPullRequestInfo(prInfo);
1188
+
1189
+ runOnCompleteHook(
1190
+ onComplete,
1191
+ {
1192
+ planName: planFileName,
1193
+ planFile,
1194
+ outcome: isSuccessful ? "success" : "partial",
1195
+ workBranch,
1196
+ sourceBranch,
1197
+ prUrl: prInfo.url || "",
1198
+ stepsTotal: plan.steps.length,
1199
+ stepsCompleted: plan.steps.filter((s) => s.status === "complete")
1200
+ .length,
1201
+ stepsBlocked: plan.steps.filter((s) => s.status === "blocked").length
1202
+ },
1203
+ REPO_ROOT
1204
+ );
1205
+
1206
+ return { isComplete: true, isSuccessful, workBranch };
613
1207
  } else {
614
1208
  // Plan not complete (still has pending steps or was interrupted)
615
1209
  // Commit any progress made
@@ -621,9 +1215,29 @@ async function processPlanWithBranching(
621
1215
  if (progressCommit) {
622
1216
  console.log(`Committed work-in-progress (${progressCommit.slice(0, 7)})`);
623
1217
  }
1218
+
1219
+ runOnCompleteHook(
1220
+ onComplete,
1221
+ {
1222
+ planName: planFileName,
1223
+ planFile,
1224
+ outcome: "incomplete",
1225
+ workBranch,
1226
+ sourceBranch,
1227
+ prUrl: "",
1228
+ stepsTotal: plan.steps.length,
1229
+ stepsCompleted: plan.steps.filter((s) => s.status === "complete")
1230
+ .length,
1231
+ stepsBlocked: plan.steps.filter((s) => s.status === "blocked").length
1232
+ },
1233
+ REPO_ROOT
1234
+ );
1235
+
624
1236
  console.log(
625
1237
  "\nPlan not complete. Work branch preserved for later continuation."
626
1238
  );
1239
+
1240
+ return { isComplete: false, isSuccessful: false, workBranch };
627
1241
  }
628
1242
  }
629
1243
 
@@ -696,13 +1310,18 @@ function generatePRBody(plan) {
696
1310
  * @param {string} completedDir - Directory for completed plans
697
1311
  * @param {string} reportsDir - Directory for reports
698
1312
  * @param {boolean} parallelEnabled - Whether parallel execution with worktrees is enabled
1313
+ * @param {{workingDir: string, mainRepoRoot: string}} [ctx] - Execution context for worktree mode
699
1314
  */
700
1315
  async function processPlan(
701
1316
  planFile,
702
1317
  completedDir,
703
1318
  reportsDir,
704
- parallelEnabled = false
1319
+ parallelEnabled = false,
1320
+ ctx
705
1321
  ) {
1322
+ if (!ctx) {
1323
+ ctx = { workingDir: REPO_ROOT, mainRepoRoot: REPO_ROOT };
1324
+ }
706
1325
  let plan = loadPlan(planFile);
707
1326
  const activeAgents = []; // Array of {handle, stepIds, tempPlanFile, worktreeInfo?}
708
1327
 
@@ -739,22 +1358,23 @@ async function processPlan(
739
1358
  tracker
740
1359
  );
741
1360
  plan = loadPlan(planFile);
742
- await mergeWorktreeCommits(results, REPO_ROOT);
1361
+ await mergeWorktreeCommits(results, ctx.workingDir);
743
1362
  } else {
744
1363
  // Serial mode: wait for one
745
1364
  const { stepIds, parsedResults } = await waitForAgentCompletion(
746
1365
  planFile,
747
1366
  activeAgents,
748
1367
  reportsDir,
749
- tracker
1368
+ tracker,
1369
+ ctx
750
1370
  );
751
1371
  plan = loadPlan(planFile);
752
1372
 
753
- if (hasUncommittedChanges(REPO_ROOT)) {
1373
+ if (hasUncommittedChanges(ctx.workingDir)) {
754
1374
  const commitMsg =
755
1375
  parsedResults[0]?.commitMessage ||
756
1376
  `feat: complete step(s) ${stepIds.join(", ")}`;
757
- const commitSha = commit(commitMsg, [], REPO_ROOT);
1377
+ const commitSha = commit(commitMsg, [], ctx.workingDir);
758
1378
  if (commitSha) {
759
1379
  console.log(
760
1380
  `Committed: ${commitMsg.split("\n")[0]} (${commitSha.slice(0, 7)})`
@@ -800,7 +1420,8 @@ async function processPlan(
800
1420
  activeAgents,
801
1421
  tracker,
802
1422
  useWorktree,
803
- parallel.length > 1 // skipLogging for parallel batches
1423
+ parallel.length > 1, // skipLogging for parallel batches
1424
+ ctx
804
1425
  );
805
1426
  }
806
1427
  }
@@ -813,7 +1434,15 @@ async function processPlan(
813
1434
  ) {
814
1435
  break;
815
1436
  }
816
- await startSteps(planFile, [step.id], activeAgents, tracker, false);
1437
+ await startSteps(
1438
+ planFile,
1439
+ [step.id],
1440
+ activeAgents,
1441
+ tracker,
1442
+ false,
1443
+ false,
1444
+ ctx
1445
+ );
817
1446
  }
818
1447
 
819
1448
  // If we started any agents, wait for completion
@@ -832,22 +1461,23 @@ async function processPlan(
832
1461
  tracker
833
1462
  );
834
1463
  plan = loadPlan(planFile);
835
- await mergeWorktreeCommits(results, REPO_ROOT);
1464
+ await mergeWorktreeCommits(results, ctx.workingDir);
836
1465
  } else {
837
1466
  // Serial execution: wait for one agent at a time
838
1467
  const { stepIds, parsedResults } = await waitForAgentCompletion(
839
1468
  planFile,
840
1469
  activeAgents,
841
1470
  reportsDir,
842
- tracker
1471
+ tracker,
1472
+ ctx
843
1473
  );
844
1474
  plan = loadPlan(planFile);
845
1475
 
846
- if (hasUncommittedChanges(REPO_ROOT)) {
1476
+ if (hasUncommittedChanges(ctx.workingDir)) {
847
1477
  const commitMsg =
848
1478
  parsedResults[0]?.commitMessage ||
849
1479
  `feat: complete step(s) ${stepIds.join(", ")}`;
850
- const commitSha = commit(commitMsg, [], REPO_ROOT);
1480
+ const commitSha = commit(commitMsg, [], ctx.workingDir);
851
1481
  if (commitSha) {
852
1482
  console.log(
853
1483
  `Committed: ${commitMsg.split("\n")[0]} (${commitSha.slice(0, 7)})`
@@ -869,6 +1499,8 @@ async function processPlan(
869
1499
  * @param {Object[]} activeAgents - Array of active agent handles
870
1500
  * @param {ProgressTracker} tracker - Progress tracker instance
871
1501
  * @param {boolean} useWorktree - Whether to use a git worktree for isolation
1502
+ * @param {boolean} skipLogging - Whether to skip logging step start
1503
+ * @param {{workingDir: string, mainRepoRoot: string}} [ctx] - Execution context
872
1504
  */
873
1505
  async function startSteps(
874
1506
  planFile,
@@ -876,8 +1508,13 @@ async function startSteps(
876
1508
  activeAgents,
877
1509
  tracker,
878
1510
  useWorktree = false,
879
- skipLogging = false
1511
+ skipLogging = false,
1512
+ ctx
880
1513
  ) {
1514
+ if (!ctx) {
1515
+ ctx = { workingDir: REPO_ROOT, mainRepoRoot: REPO_ROOT };
1516
+ }
1517
+
881
1518
  // Log step start with progress info (unless skipped for parallel batch)
882
1519
  if (!skipLogging) {
883
1520
  tracker.logStepStart(stepIds);
@@ -895,13 +1532,15 @@ async function startSteps(
895
1532
  const condensedPlan = generateCondensedPlan(plan, stepIds);
896
1533
  const tempPlanFile = writeCondensedPlan(condensedPlan, planFile, stepIds);
897
1534
 
898
- // Determine working directory (worktree or main repo)
899
- let workingDir = REPO_ROOT;
1535
+ // Determine working directory (worktree or ctx.workingDir)
1536
+ let workingDir = ctx.workingDir;
900
1537
  let worktreeInfo = null;
901
1538
 
902
1539
  if (useWorktree) {
1540
+ // Step-level worktrees are created relative to the main repo root,
1541
+ // not the plan worktree, to avoid nesting issues
903
1542
  const branchName = `worktree-${stepIds.join("-")}-${Date.now()}`;
904
- const worktreesDir = path.join(REPO_ROOT, ".worktrees");
1543
+ const worktreesDir = path.join(ctx.mainRepoRoot, ".worktrees");
905
1544
 
906
1545
  // Ensure .worktrees directory exists
907
1546
  if (!fs.existsSync(worktreesDir)) {
@@ -911,7 +1550,7 @@ async function startSteps(
911
1550
  const worktreePath = path.join(worktreesDir, branchName);
912
1551
 
913
1552
  try {
914
- addWorktree(worktreePath, branchName, "HEAD", REPO_ROOT);
1553
+ addWorktree(worktreePath, branchName, "HEAD", ctx.mainRepoRoot);
915
1554
  workingDir = worktreePath;
916
1555
  worktreeInfo = { path: worktreePath, branch: branchName };
917
1556
  console.log(`Created worktree for ${stepIds.join(",")}: ${worktreePath}`);
@@ -964,14 +1603,19 @@ async function startSteps(
964
1603
  * @param {Object[]} activeAgents - Array of active agent handles
965
1604
  * @param {string} reportsDir - Directory for reports
966
1605
  * @param {ProgressTracker} tracker - Progress tracker instance
1606
+ * @param {{workingDir: string, mainRepoRoot: string}} [ctx] - Execution context
967
1607
  * @returns {{stepIds: string[], parsedResults: Object[]}} Completed step IDs and parsed results
968
1608
  */
969
1609
  async function waitForAgentCompletion(
970
1610
  planFile,
971
1611
  activeAgents,
972
1612
  reportsDir,
973
- tracker
1613
+ tracker,
1614
+ ctx
974
1615
  ) {
1616
+ if (!ctx) {
1617
+ ctx = { workingDir: REPO_ROOT, mainRepoRoot: REPO_ROOT };
1618
+ }
975
1619
  if (activeAgents.length === 0) return { stepIds: [], parsedResults: [] };
976
1620
 
977
1621
  // Wait for any agent to complete
@@ -1036,7 +1680,7 @@ async function waitForAgentCompletion(
1036
1680
  config,
1037
1681
  tempPlanFile,
1038
1682
  [stepId],
1039
- REPO_ROOT,
1683
+ ctx.workingDir,
1040
1684
  { stepId, timeoutMs: resolveAgentTimeout() }
1041
1685
  );
1042
1686
 
@@ -1091,7 +1735,7 @@ async function waitForAgentCompletion(
1091
1735
  planFile,
1092
1736
  [stepId],
1093
1737
  reviewResult.feedback,
1094
- REPO_ROOT,
1738
+ ctx.workingDir,
1095
1739
  {
1096
1740
  stepId,
1097
1741
  stepIds: [stepId],
@@ -1318,6 +1962,37 @@ function writeReport(reportsDir, planFile, report) {
1318
1962
  console.log(`Report written: ${fileName}`);
1319
1963
  }
1320
1964
 
1965
+ /**
1966
+ * Run the on-complete hook command with plan context as environment variables.
1967
+ * Failures are logged but never thrown.
1968
+ * @param {string} command - Shell command to execute
1969
+ * @param {Object} context - Plan context for environment variables
1970
+ * @param {string} cwd - Working directory to run the command in
1971
+ */
1972
+ function runOnCompleteHook(command, context, cwd) {
1973
+ if (!command) return;
1974
+
1975
+ const env = {
1976
+ ...process.env,
1977
+ ORRERY_PLAN_NAME: String(context.planName || ""),
1978
+ ORRERY_PLAN_FILE: String(context.planFile || ""),
1979
+ ORRERY_PLAN_OUTCOME: String(context.outcome || ""),
1980
+ ORRERY_WORK_BRANCH: String(context.workBranch || ""),
1981
+ ORRERY_SOURCE_BRANCH: String(context.sourceBranch || ""),
1982
+ ORRERY_PR_URL: String(context.prUrl || ""),
1983
+ ORRERY_STEPS_TOTAL: String(context.stepsTotal || 0),
1984
+ ORRERY_STEPS_COMPLETED: String(context.stepsCompleted || 0),
1985
+ ORRERY_STEPS_BLOCKED: String(context.stepsBlocked || 0)
1986
+ };
1987
+
1988
+ try {
1989
+ console.log(`\nRunning on-complete hook: ${command}`);
1990
+ execSync(command, { cwd, env, stdio: "inherit", timeout: 60000 });
1991
+ } catch (err) {
1992
+ console.error(`on-complete hook failed: ${err.message}`);
1993
+ }
1994
+ }
1995
+
1321
1996
  /**
1322
1997
  * Archive a completed plan
1323
1998
  */
@@ -1345,4 +2020,4 @@ if (require.main === module) {
1345
2020
  });
1346
2021
  }
1347
2022
 
1348
- module.exports = { orchestrate };
2023
+ module.exports = { orchestrate, runOnCompleteHook };