@caseyharalson/orrery 0.12.0 → 0.13.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.
@@ -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,
@@ -264,7 +267,8 @@ async function orchestrate(options = {}) {
264
267
  verbose: Boolean(options.verbose),
265
268
  resume: Boolean(options.resume),
266
269
  review: options.review,
267
- parallel: options.parallel
270
+ parallel: options.parallel,
271
+ onComplete: options.onComplete || null
268
272
  };
269
273
 
270
274
  config.logging.streamOutput = normalizedOptions.verbose;
@@ -279,7 +283,48 @@ async function orchestrate(options = {}) {
279
283
  );
280
284
  }
281
285
 
282
- // Acquire execution lock (skip for dry-run)
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
+ }
296
+
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
+ }
326
+
327
+ // Acquire global execution lock (skip for dry-run)
283
328
  if (!normalizedOptions.dryRun) {
284
329
  const lockResult = acquireLock();
285
330
  if (!lockResult.acquired) {
@@ -392,7 +437,8 @@ async function orchestrate(options = {}) {
392
437
  completedDir,
393
438
  reportsDir,
394
439
  sourceBranch,
395
- normalizedOptions.plan
440
+ normalizedOptions.plan,
441
+ normalizedOptions.onComplete
396
442
  );
397
443
  return;
398
444
  }
@@ -410,7 +456,8 @@ async function orchestrate(options = {}) {
410
456
  sourceBranch,
411
457
  completedDir,
412
458
  reportsDir,
413
- parallelEnabled
459
+ parallelEnabled,
460
+ normalizedOptions.onComplete
414
461
  );
415
462
 
416
463
  if (result.isComplete && result.isSuccessful) {
@@ -443,6 +490,172 @@ async function orchestrate(options = {}) {
443
490
  }
444
491
  }
445
492
 
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;
518
+ return;
519
+ }
520
+
521
+ const cleanupLock = () => {
522
+ releaseLock(planId);
523
+ process.exit();
524
+ };
525
+ process.on("SIGINT", cleanupLock);
526
+ process.on("SIGTERM", cleanupLock);
527
+
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"
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
+ }
552
+
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);
568
+ const isComplete = plan.isComplete();
569
+
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)})`);
580
+ }
581
+
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
+ );
628
+ }
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
+ );
650
+ }
651
+
652
+ console.log("\n=== Resume Complete ===");
653
+ } finally {
654
+ delete process.env.ORRERY_REPO_ROOT;
655
+ releaseLock(planId);
656
+ }
657
+ }
658
+
446
659
  /**
447
660
  * Handle resume mode: find and continue plan for current branch
448
661
  */
@@ -451,7 +664,8 @@ async function handleResumeMode(
451
664
  completedDir,
452
665
  reportsDir,
453
666
  currentBranch,
454
- planFileArg
667
+ planFileArg,
668
+ onComplete
455
669
  ) {
456
670
  console.log("=== Resume Mode ===\n");
457
671
 
@@ -478,6 +692,25 @@ async function handleResumeMode(
478
692
  process.exit(1);
479
693
  }
480
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
+
481
714
  if (matchingPlan.metadata.work_branch !== currentBranch) {
482
715
  console.error(
483
716
  `Plan expects branch '${matchingPlan.metadata.work_branch}' but you are on '${currentBranch}'.`
@@ -585,6 +818,26 @@ async function handleResumeMode(
585
818
  const prBody = generatePRBody(matchingPlan);
586
819
  const prInfo = createPullRequest(prTitle, prBody, sourceBranch, REPO_ROOT);
587
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
+ );
588
841
  } else {
589
842
  const progressCommit = commit(
590
843
  `wip: progress on plan ${planFileName}`,
@@ -594,6 +847,26 @@ async function handleResumeMode(
594
847
  if (progressCommit) {
595
848
  console.log(`Committed work-in-progress (${progressCommit.slice(0, 7)})`);
596
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
+
597
870
  console.log(
598
871
  "\nPlan still has pending steps. Run --resume again to continue."
599
872
  );
@@ -602,6 +875,235 @@ async function handleResumeMode(
602
875
  console.log("\n=== Resume Complete ===");
603
876
  }
604
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
+
605
1107
  /**
606
1108
  * Process a single plan with branch management
607
1109
  * @param {string} planFile - Path to the plan file
@@ -616,7 +1118,8 @@ async function processPlanWithBranching(
616
1118
  sourceBranch,
617
1119
  completedDir,
618
1120
  reportsDir,
619
- parallelEnabled = false
1121
+ parallelEnabled = false,
1122
+ onComplete = null
620
1123
  ) {
621
1124
  const planFileName = path.basename(planFile);
622
1125
  console.log(`\n--- Processing: ${planFileName} ---\n`);
@@ -683,6 +1186,23 @@ async function processPlanWithBranching(
683
1186
  const prInfo = createPullRequest(prTitle, prBody, sourceBranch, REPO_ROOT);
684
1187
  logPullRequestInfo(prInfo);
685
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
+
686
1206
  return { isComplete: true, isSuccessful, workBranch };
687
1207
  } else {
688
1208
  // Plan not complete (still has pending steps or was interrupted)
@@ -695,6 +1215,24 @@ async function processPlanWithBranching(
695
1215
  if (progressCommit) {
696
1216
  console.log(`Committed work-in-progress (${progressCommit.slice(0, 7)})`);
697
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
+
698
1236
  console.log(
699
1237
  "\nPlan not complete. Work branch preserved for later continuation."
700
1238
  );
@@ -772,13 +1310,18 @@ function generatePRBody(plan) {
772
1310
  * @param {string} completedDir - Directory for completed plans
773
1311
  * @param {string} reportsDir - Directory for reports
774
1312
  * @param {boolean} parallelEnabled - Whether parallel execution with worktrees is enabled
1313
+ * @param {{workingDir: string, mainRepoRoot: string}} [ctx] - Execution context for worktree mode
775
1314
  */
776
1315
  async function processPlan(
777
1316
  planFile,
778
1317
  completedDir,
779
1318
  reportsDir,
780
- parallelEnabled = false
1319
+ parallelEnabled = false,
1320
+ ctx
781
1321
  ) {
1322
+ if (!ctx) {
1323
+ ctx = { workingDir: REPO_ROOT, mainRepoRoot: REPO_ROOT };
1324
+ }
782
1325
  let plan = loadPlan(planFile);
783
1326
  const activeAgents = []; // Array of {handle, stepIds, tempPlanFile, worktreeInfo?}
784
1327
 
@@ -815,22 +1358,23 @@ async function processPlan(
815
1358
  tracker
816
1359
  );
817
1360
  plan = loadPlan(planFile);
818
- await mergeWorktreeCommits(results, REPO_ROOT);
1361
+ await mergeWorktreeCommits(results, ctx.workingDir);
819
1362
  } else {
820
1363
  // Serial mode: wait for one
821
1364
  const { stepIds, parsedResults } = await waitForAgentCompletion(
822
1365
  planFile,
823
1366
  activeAgents,
824
1367
  reportsDir,
825
- tracker
1368
+ tracker,
1369
+ ctx
826
1370
  );
827
1371
  plan = loadPlan(planFile);
828
1372
 
829
- if (hasUncommittedChanges(REPO_ROOT)) {
1373
+ if (hasUncommittedChanges(ctx.workingDir)) {
830
1374
  const commitMsg =
831
1375
  parsedResults[0]?.commitMessage ||
832
1376
  `feat: complete step(s) ${stepIds.join(", ")}`;
833
- const commitSha = commit(commitMsg, [], REPO_ROOT);
1377
+ const commitSha = commit(commitMsg, [], ctx.workingDir);
834
1378
  if (commitSha) {
835
1379
  console.log(
836
1380
  `Committed: ${commitMsg.split("\n")[0]} (${commitSha.slice(0, 7)})`
@@ -876,7 +1420,8 @@ async function processPlan(
876
1420
  activeAgents,
877
1421
  tracker,
878
1422
  useWorktree,
879
- parallel.length > 1 // skipLogging for parallel batches
1423
+ parallel.length > 1, // skipLogging for parallel batches
1424
+ ctx
880
1425
  );
881
1426
  }
882
1427
  }
@@ -889,7 +1434,15 @@ async function processPlan(
889
1434
  ) {
890
1435
  break;
891
1436
  }
892
- 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
+ );
893
1446
  }
894
1447
 
895
1448
  // If we started any agents, wait for completion
@@ -908,22 +1461,23 @@ async function processPlan(
908
1461
  tracker
909
1462
  );
910
1463
  plan = loadPlan(planFile);
911
- await mergeWorktreeCommits(results, REPO_ROOT);
1464
+ await mergeWorktreeCommits(results, ctx.workingDir);
912
1465
  } else {
913
1466
  // Serial execution: wait for one agent at a time
914
1467
  const { stepIds, parsedResults } = await waitForAgentCompletion(
915
1468
  planFile,
916
1469
  activeAgents,
917
1470
  reportsDir,
918
- tracker
1471
+ tracker,
1472
+ ctx
919
1473
  );
920
1474
  plan = loadPlan(planFile);
921
1475
 
922
- if (hasUncommittedChanges(REPO_ROOT)) {
1476
+ if (hasUncommittedChanges(ctx.workingDir)) {
923
1477
  const commitMsg =
924
1478
  parsedResults[0]?.commitMessage ||
925
1479
  `feat: complete step(s) ${stepIds.join(", ")}`;
926
- const commitSha = commit(commitMsg, [], REPO_ROOT);
1480
+ const commitSha = commit(commitMsg, [], ctx.workingDir);
927
1481
  if (commitSha) {
928
1482
  console.log(
929
1483
  `Committed: ${commitMsg.split("\n")[0]} (${commitSha.slice(0, 7)})`
@@ -945,6 +1499,8 @@ async function processPlan(
945
1499
  * @param {Object[]} activeAgents - Array of active agent handles
946
1500
  * @param {ProgressTracker} tracker - Progress tracker instance
947
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
948
1504
  */
949
1505
  async function startSteps(
950
1506
  planFile,
@@ -952,8 +1508,13 @@ async function startSteps(
952
1508
  activeAgents,
953
1509
  tracker,
954
1510
  useWorktree = false,
955
- skipLogging = false
1511
+ skipLogging = false,
1512
+ ctx
956
1513
  ) {
1514
+ if (!ctx) {
1515
+ ctx = { workingDir: REPO_ROOT, mainRepoRoot: REPO_ROOT };
1516
+ }
1517
+
957
1518
  // Log step start with progress info (unless skipped for parallel batch)
958
1519
  if (!skipLogging) {
959
1520
  tracker.logStepStart(stepIds);
@@ -971,13 +1532,15 @@ async function startSteps(
971
1532
  const condensedPlan = generateCondensedPlan(plan, stepIds);
972
1533
  const tempPlanFile = writeCondensedPlan(condensedPlan, planFile, stepIds);
973
1534
 
974
- // Determine working directory (worktree or main repo)
975
- let workingDir = REPO_ROOT;
1535
+ // Determine working directory (worktree or ctx.workingDir)
1536
+ let workingDir = ctx.workingDir;
976
1537
  let worktreeInfo = null;
977
1538
 
978
1539
  if (useWorktree) {
1540
+ // Step-level worktrees are created relative to the main repo root,
1541
+ // not the plan worktree, to avoid nesting issues
979
1542
  const branchName = `worktree-${stepIds.join("-")}-${Date.now()}`;
980
- const worktreesDir = path.join(REPO_ROOT, ".worktrees");
1543
+ const worktreesDir = path.join(ctx.mainRepoRoot, ".worktrees");
981
1544
 
982
1545
  // Ensure .worktrees directory exists
983
1546
  if (!fs.existsSync(worktreesDir)) {
@@ -987,7 +1550,7 @@ async function startSteps(
987
1550
  const worktreePath = path.join(worktreesDir, branchName);
988
1551
 
989
1552
  try {
990
- addWorktree(worktreePath, branchName, "HEAD", REPO_ROOT);
1553
+ addWorktree(worktreePath, branchName, "HEAD", ctx.mainRepoRoot);
991
1554
  workingDir = worktreePath;
992
1555
  worktreeInfo = { path: worktreePath, branch: branchName };
993
1556
  console.log(`Created worktree for ${stepIds.join(",")}: ${worktreePath}`);
@@ -1040,14 +1603,19 @@ async function startSteps(
1040
1603
  * @param {Object[]} activeAgents - Array of active agent handles
1041
1604
  * @param {string} reportsDir - Directory for reports
1042
1605
  * @param {ProgressTracker} tracker - Progress tracker instance
1606
+ * @param {{workingDir: string, mainRepoRoot: string}} [ctx] - Execution context
1043
1607
  * @returns {{stepIds: string[], parsedResults: Object[]}} Completed step IDs and parsed results
1044
1608
  */
1045
1609
  async function waitForAgentCompletion(
1046
1610
  planFile,
1047
1611
  activeAgents,
1048
1612
  reportsDir,
1049
- tracker
1613
+ tracker,
1614
+ ctx
1050
1615
  ) {
1616
+ if (!ctx) {
1617
+ ctx = { workingDir: REPO_ROOT, mainRepoRoot: REPO_ROOT };
1618
+ }
1051
1619
  if (activeAgents.length === 0) return { stepIds: [], parsedResults: [] };
1052
1620
 
1053
1621
  // Wait for any agent to complete
@@ -1112,7 +1680,7 @@ async function waitForAgentCompletion(
1112
1680
  config,
1113
1681
  tempPlanFile,
1114
1682
  [stepId],
1115
- REPO_ROOT,
1683
+ ctx.workingDir,
1116
1684
  { stepId, timeoutMs: resolveAgentTimeout() }
1117
1685
  );
1118
1686
 
@@ -1167,7 +1735,7 @@ async function waitForAgentCompletion(
1167
1735
  planFile,
1168
1736
  [stepId],
1169
1737
  reviewResult.feedback,
1170
- REPO_ROOT,
1738
+ ctx.workingDir,
1171
1739
  {
1172
1740
  stepId,
1173
1741
  stepIds: [stepId],
@@ -1394,6 +1962,37 @@ function writeReport(reportsDir, planFile, report) {
1394
1962
  console.log(`Report written: ${fileName}`);
1395
1963
  }
1396
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
+
1397
1996
  /**
1398
1997
  * Archive a completed plan
1399
1998
  */
@@ -1421,4 +2020,4 @@ if (require.main === module) {
1421
2020
  });
1422
2021
  }
1423
2022
 
1424
- module.exports = { orchestrate };
2023
+ module.exports = { orchestrate, runOnCompleteHook };