@caseyharalson/orrery 0.10.0 → 0.12.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.
@@ -1,8 +1,13 @@
1
+ const fs = require("fs");
1
2
  const path = require("path");
2
3
 
3
4
  const { findPlanForCurrentBranch } = require("../../utils/plan-detect");
4
- const { updateStepsStatus } = require("../../orchestration/plan-loader");
5
- const { commit } = require("../../utils/git");
5
+ const {
6
+ loadPlan,
7
+ updateStepsStatus
8
+ } = require("../../orchestration/plan-loader");
9
+ const { commit, getCurrentBranch } = require("../../utils/git");
10
+ const { getPlansDir } = require("../../utils/paths");
6
11
  const { orchestrate } = require("../../orchestration");
7
12
 
8
13
  function supportsColor() {
@@ -24,6 +29,7 @@ module.exports = function registerResumeCommand(program) {
24
29
  program
25
30
  .command("resume")
26
31
  .description("Unblock steps and resume orchestration")
32
+ .option("--plan <file>", "Resume a specific plan file")
27
33
  .option("--step <id>", "Unblock a specific step before resuming")
28
34
  .option("--all", "Unblock all blocked steps (default behavior)")
29
35
  .option(
@@ -31,31 +37,90 @@ module.exports = function registerResumeCommand(program) {
31
37
  "Preview what would be unblocked without making changes"
32
38
  )
33
39
  .action(async (options) => {
34
- // 1. Find plan for current branch
35
- let match;
36
- try {
37
- match = findPlanForCurrentBranch();
38
- } catch {
39
- console.error("Error detecting plan from current branch.");
40
- console.log(
41
- "Make sure you're on a work branch (e.g., plan/feature-name)."
42
- );
43
- process.exitCode = 1;
44
- return;
45
- }
40
+ let planFile;
41
+ let plan;
42
+
43
+ if (options.plan) {
44
+ // Resolve plan path (same pattern as status.js)
45
+ const planArg = options.plan;
46
+ let resolvedPath;
47
+ if (path.isAbsolute(planArg)) {
48
+ resolvedPath = planArg;
49
+ } else if (planArg.includes(path.sep)) {
50
+ resolvedPath = path.resolve(process.cwd(), planArg);
51
+ } else {
52
+ resolvedPath = path.join(getPlansDir(), planArg);
53
+ }
46
54
 
47
- if (!match) {
48
- console.error(
49
- "Not on a work branch. No plan found for current branch."
50
- );
51
- console.log("\nTo resume a plan:");
52
- console.log(" 1. git checkout <work-branch>");
53
- console.log(" 2. orrery resume");
54
- process.exitCode = 1;
55
- return;
56
- }
55
+ if (!resolvedPath || !fs.existsSync(resolvedPath)) {
56
+ console.error(`Plan not found: ${planArg}`);
57
+ process.exitCode = 1;
58
+ return;
59
+ }
60
+
61
+ plan = loadPlan(resolvedPath);
62
+ planFile = resolvedPath;
63
+
64
+ // Validate work_branch
65
+ if (!plan.metadata.work_branch) {
66
+ console.error(
67
+ "Plan has no work_branch — it hasn't been dispatched yet."
68
+ );
69
+ console.log(
70
+ "\nUse 'orrery exec --plan <file>' to dispatch the plan first."
71
+ );
72
+ process.exitCode = 1;
73
+ return;
74
+ }
75
+
76
+ // Verify current branch matches plan's work_branch
77
+ let currentBranch;
78
+ try {
79
+ currentBranch = getCurrentBranch(process.cwd());
80
+ } catch {
81
+ console.error("Error detecting current branch.");
82
+ process.exitCode = 1;
83
+ return;
84
+ }
85
+
86
+ if (currentBranch !== plan.metadata.work_branch) {
87
+ console.error(
88
+ `Plan expects branch '${plan.metadata.work_branch}' but you are on '${currentBranch}'.`
89
+ );
90
+ console.log(`\nRun: git checkout ${plan.metadata.work_branch}`);
91
+ process.exitCode = 1;
92
+ return;
93
+ }
94
+ } else {
95
+ // 1. Find plan for current branch (existing behavior)
96
+ let match;
97
+ try {
98
+ match = findPlanForCurrentBranch();
99
+ } catch {
100
+ console.error("Error detecting plan from current branch.");
101
+ console.log(
102
+ "Make sure you're on a work branch (e.g., plan/feature-name)."
103
+ );
104
+ process.exitCode = 1;
105
+ return;
106
+ }
57
107
 
58
- const { planFile, plan } = match;
108
+ if (!match) {
109
+ console.error(
110
+ "Not on a work branch. No plan found for current branch."
111
+ );
112
+ console.log("\nTo resume a plan:");
113
+ console.log(" 1. git checkout <work-branch>");
114
+ console.log(" 2. orrery resume");
115
+ console.log("\nOr specify a plan directly:");
116
+ console.log(" orrery resume --plan <file>");
117
+ process.exitCode = 1;
118
+ return;
119
+ }
120
+
121
+ planFile = match.planFile;
122
+ plan = match.plan;
123
+ }
59
124
  const planFileName = path.basename(planFile);
60
125
  console.log(`(detected plan: ${planFileName})\n`);
61
126
 
@@ -72,13 +137,11 @@ module.exports = function registerResumeCommand(program) {
72
137
  }
73
138
 
74
139
  console.log("Resuming orchestration...\n");
75
- await orchestrate({ resume: true });
140
+ await orchestrate({ resume: true, plan: options.plan });
76
141
  return;
77
142
  }
78
143
 
79
144
  // 4. Determine which steps to unblock
80
- let stepsToUnblock = [];
81
-
82
145
  if (options.step) {
83
146
  // Unblock specific step
84
147
  const step = blockedSteps.find((s) => s.id === options.step);
@@ -93,12 +156,12 @@ module.exports = function registerResumeCommand(program) {
93
156
  process.exitCode = 1;
94
157
  return;
95
158
  }
96
- stepsToUnblock = [step];
97
- } else {
98
- // Default: unblock all (--all is implicit)
99
- stepsToUnblock = blockedSteps;
100
159
  }
101
160
 
161
+ const stepsToUnblock = options.step
162
+ ? [blockedSteps.find((s) => s.id === options.step)]
163
+ : blockedSteps;
164
+
102
165
  // 5. Dry-run mode: show preview
103
166
  if (options.dryRun) {
104
167
  console.log("Dry run - would unblock the following steps:\n");
@@ -141,6 +204,6 @@ module.exports = function registerResumeCommand(program) {
141
204
 
142
205
  // 8. Resume orchestration
143
206
  console.log("\nResuming orchestration...\n");
144
- await orchestrate({ resume: true });
207
+ await orchestrate({ resume: true, plan: options.plan });
145
208
  });
146
209
  };
@@ -4,6 +4,7 @@ const { getPlansDir } = require("../../utils/paths");
4
4
  const { getPlanFiles, loadPlan } = require("../../orchestration/plan-loader");
5
5
  const { findPlanForCurrentBranch } = require("../../utils/plan-detect");
6
6
  const { getCurrentBranch } = require("../../utils/git");
7
+ const { getLockStatus } = require("../../utils/lock");
7
8
 
8
9
  function supportsColor() {
9
10
  return Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
@@ -92,6 +93,18 @@ module.exports = function registerStatusCommand(program) {
92
93
  .description("Show orchestration status for plans in the current project")
93
94
  .option("--plan <file>", "Show detailed status for a specific plan")
94
95
  .action((options) => {
96
+ // Check for active execution
97
+ const lock = getLockStatus();
98
+ if (lock.locked) {
99
+ console.log(
100
+ `Execution in progress (PID ${lock.pid}, started ${lock.startedAt})\n`
101
+ );
102
+ } else if (lock.stale) {
103
+ console.log(
104
+ `Note: Stale lock detected (PID ${lock.pid} no longer running)\n`
105
+ );
106
+ }
107
+
95
108
  const plansDir = getPlansDir();
96
109
  const planArg = options.plan;
97
110
 
package/lib/cli/index.js CHANGED
@@ -9,6 +9,8 @@ const registerStatus = require("./commands/status");
9
9
  const registerResume = require("./commands/resume");
10
10
  const registerValidatePlan = require("./commands/validate-plan");
11
11
  const registerIngestPlan = require("./commands/ingest-plan");
12
+ const registerManual = require("./commands/manual");
13
+ const registerPlansDir = require("./commands/plans-dir");
12
14
  const registerHelp = require("./commands/help");
13
15
 
14
16
  function getPackageVersion() {
@@ -34,6 +36,8 @@ function buildProgram() {
34
36
  registerResume(program);
35
37
  registerValidatePlan(program);
36
38
  registerIngestPlan(program);
39
+ registerManual(program);
40
+ registerPlansDir(program);
37
41
  registerHelp(program);
38
42
 
39
43
  program.on("command:*", (operands) => {
@@ -62,7 +62,8 @@ const config = require("./config");
62
62
  const {
63
63
  getPlansDir,
64
64
  getCompletedDir,
65
- getReportsDir
65
+ getReportsDir,
66
+ isWorkDirExternal
66
67
  } = require("../utils/paths");
67
68
 
68
69
  const {
@@ -72,6 +73,7 @@ const {
72
73
  } = require("./condensed-plan");
73
74
 
74
75
  const { ProgressTracker } = require("./progress-tracker");
76
+ const { acquireLock, releaseLock } = require("../utils/lock");
75
77
 
76
78
  const REPO_ROOT = process.cwd();
77
79
 
@@ -277,140 +279,168 @@ async function orchestrate(options = {}) {
277
279
  );
278
280
  }
279
281
 
280
- console.log("=== Plan Orchestrator Starting ===\n");
281
-
282
- const plansDir = getPlansDir();
283
- const completedDir = getCompletedDir();
284
- const reportsDir = getReportsDir();
285
-
286
- // Record the source branch we're starting from
287
- const sourceBranch = getCurrentBranch(REPO_ROOT);
288
- console.log(`Source branch: ${sourceBranch}\n`);
282
+ // Acquire execution lock (skip for dry-run)
283
+ if (!normalizedOptions.dryRun) {
284
+ const lockResult = acquireLock();
285
+ if (!lockResult.acquired) {
286
+ console.error(`Cannot start: ${lockResult.reason}`);
287
+ process.exitCode = 1;
288
+ return;
289
+ }
289
290
 
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);
291
+ // Clean up lock on signals
292
+ const cleanupLock = () => {
293
+ releaseLock();
294
+ process.exit();
295
+ };
296
+ process.on("SIGINT", cleanupLock);
297
+ process.on("SIGTERM", cleanupLock);
296
298
  }
297
299
 
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
- }
300
+ try {
301
+ console.log("=== Plan Orchestrator Starting ===\n");
303
302
 
304
- // Get list of completed plan filenames (to exclude)
305
- const completedNames = getCompletedPlanNames(completedDir);
303
+ const plansDir = getPlansDir();
304
+ const completedDir = getCompletedDir();
305
+ const reportsDir = getReportsDir();
306
306
 
307
- let planFiles = [];
308
- let allPlanFiles = [];
307
+ // Get list of completed plan filenames (to exclude)
308
+ const completedNames = getCompletedPlanNames(completedDir);
309
309
 
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);
310
+ let planFiles = [];
311
+ let allPlanFiles = [];
312
+
313
+ if (normalizedOptions.plan) {
314
+ const resolvedPlanFile = resolvePlanFile(
315
+ normalizedOptions.plan,
316
+ plansDir
317
+ );
318
+ if (!resolvedPlanFile) {
319
+ console.error(`Plan file not found: ${normalizedOptions.plan}`);
320
+ process.exit(1);
321
+ }
322
+ if (completedNames.has(path.basename(resolvedPlanFile))) {
323
+ console.log(
324
+ `Plan already completed: ${path.basename(resolvedPlanFile)}`
325
+ );
326
+ return;
327
+ }
328
+ allPlanFiles = [resolvedPlanFile];
329
+ } else {
330
+ // Scan for active plans
331
+ allPlanFiles = getPlanFiles(plansDir).filter(
332
+ (f) => !completedNames.has(path.basename(f))
333
+ );
315
334
  }
316
- if (completedNames.has(path.basename(resolvedPlanFile))) {
317
- console.log(`Plan already completed: ${path.basename(resolvedPlanFile)}`);
318
- return;
335
+
336
+ // Filter out plans that are already dispatched (have work_branch set)
337
+ const dispatchedPlans = [];
338
+
339
+ for (const planFile of allPlanFiles) {
340
+ const plan = loadPlan(planFile);
341
+ if (plan.metadata.work_branch) {
342
+ dispatchedPlans.push({
343
+ file: path.basename(planFile),
344
+ workBranch: plan.metadata.work_branch
345
+ });
346
+ } else {
347
+ planFiles.push(planFile);
348
+ }
319
349
  }
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
350
 
328
- // Filter out plans that are already dispatched (have work_branch set)
329
- const dispatchedPlans = [];
351
+ if (dispatchedPlans.length > 0) {
352
+ console.log(
353
+ `Skipping ${dispatchedPlans.length} already-dispatched plan(s):`
354
+ );
355
+ for (const dp of dispatchedPlans) {
356
+ console.log(` - ${dp.file} (work branch: ${dp.workBranch})`);
357
+ }
358
+ console.log();
359
+ }
330
360
 
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);
361
+ if (planFiles.length === 0) {
362
+ console.log(
363
+ `No new plans to process in ${path.relative(process.cwd(), plansDir)}/`
364
+ );
365
+ console.log(
366
+ "Create a plan file without work_branch metadata to get started."
367
+ );
368
+ return;
340
369
  }
341
- }
342
370
 
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})`);
371
+ if (normalizedOptions.dryRun) {
372
+ logDryRunSummary(planFiles);
373
+ return;
349
374
  }
350
- console.log();
351
- }
352
375
 
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;
361
- }
376
+ // Record the source branch we're starting from
377
+ const sourceBranch = getCurrentBranch(REPO_ROOT);
378
+ console.log(`Source branch: ${sourceBranch}\n`);
362
379
 
363
- if (normalizedOptions.dryRun) {
364
- logDryRunSummary(planFiles);
365
- return;
366
- }
380
+ // Check for uncommitted changes
381
+ if (hasUncommittedChanges(REPO_ROOT)) {
382
+ console.error(
383
+ "Error: Uncommitted changes detected. Please commit or stash before running orchestrator."
384
+ );
385
+ process.exit(1);
386
+ }
367
387
 
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();
388
+ // Resume mode: find and continue the plan for the current branch
389
+ if (normalizedOptions.resume) {
390
+ await handleResumeMode(
391
+ plansDir,
392
+ completedDir,
393
+ reportsDir,
394
+ sourceBranch,
395
+ normalizedOptions.plan
396
+ );
397
+ return;
398
+ }
373
399
 
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
382
- );
400
+ console.log(`Found ${planFiles.length} plan(s) to process:\n`);
401
+ for (const pf of planFiles) {
402
+ console.log(` - ${path.basename(pf)}`);
403
+ }
404
+ console.log();
383
405
 
384
- // Reload plan to check final state
385
- const plan = loadPlan(planFile);
386
- const isComplete = plan.isComplete();
387
- const isSuccessful = plan.isSuccessful();
406
+ // Process each plan (one at a time, with branch switching)
407
+ for (const planFile of planFiles) {
408
+ const result = await processPlanWithBranching(
409
+ planFile,
410
+ sourceBranch,
411
+ completedDir,
412
+ reportsDir,
413
+ parallelEnabled
414
+ );
388
415
 
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);
395
- }
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
-
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).`);
416
+ if (result.isComplete && result.isSuccessful) {
417
+ // Plan completed successfully - return to source branch for next plan
418
+ const currentBranch = getCurrentBranch(REPO_ROOT);
419
+ if (currentBranch !== sourceBranch) {
420
+ console.log(`\nReturning to source branch: ${sourceBranch}`);
421
+ checkoutBranch(sourceBranch, REPO_ROOT);
422
+ }
423
+ } else {
424
+ // Plan is blocked - stay on work branch and stop processing
425
+ console.log(`\nPlan "${path.basename(planFile)}" is blocked.`);
426
+ console.log(`Staying on work branch: ${result.workBranch}`);
427
+ console.log("\nTo continue:");
428
+ console.log(" 1. Fix the blocked steps (orrery status)");
429
+ console.log(" 2. Run 'orrery resume' to unblock and continue");
430
+
431
+ // List remaining unprocessed plans
432
+ const remaining = planFiles.slice(planFiles.indexOf(planFile) + 1);
433
+ if (remaining.length > 0) {
434
+ console.log(`\nSkipped ${remaining.length} remaining plan(s).`);
435
+ }
436
+ break; // Stop processing
408
437
  }
409
- break; // Stop processing
410
438
  }
411
- }
412
439
 
413
- console.log("\n=== Orchestrator Complete ===");
440
+ console.log("\n=== Orchestrator Complete ===");
441
+ } finally {
442
+ if (!normalizedOptions.dryRun) releaseLock();
443
+ }
414
444
  }
415
445
 
416
446
  /**
@@ -420,36 +450,73 @@ async function handleResumeMode(
420
450
  plansDir,
421
451
  completedDir,
422
452
  reportsDir,
423
- currentBranch
453
+ currentBranch,
454
+ planFileArg
424
455
  ) {
425
456
  console.log("=== Resume Mode ===\n");
426
- console.log(`Looking for plan with work_branch: ${currentBranch}\n`);
427
457
 
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
458
  let matchingPlanFile = null;
436
459
  let matchingPlan = null;
437
460
 
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;
461
+ if (planFileArg) {
462
+ // Resolve the plan file from argument
463
+ const resolved = resolvePlanFile(planFileArg, plansDir);
464
+ if (!resolved) {
465
+ console.error(`Plan file not found: ${planFileArg}`);
466
+ process.exit(1);
444
467
  }
445
- }
446
468
 
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);
469
+ matchingPlan = loadPlan(resolved);
470
+ matchingPlanFile = resolved;
471
+
472
+ // Validate work_branch
473
+ if (!matchingPlan.metadata.work_branch) {
474
+ console.error("Plan has no work_branch — it hasn't been dispatched yet.");
475
+ console.log(
476
+ "\nUse 'orrery exec --plan <file>' to dispatch the plan first."
477
+ );
478
+ process.exit(1);
479
+ }
480
+
481
+ if (matchingPlan.metadata.work_branch !== currentBranch) {
482
+ console.error(
483
+ `Plan expects branch '${matchingPlan.metadata.work_branch}' but you are on '${currentBranch}'.`
484
+ );
485
+ console.log(`\nRun: git checkout ${matchingPlan.metadata.work_branch}`);
486
+ process.exit(1);
487
+ }
488
+
489
+ console.log(`Using specified plan: ${path.basename(resolved)}\n`);
490
+ } else {
491
+ console.log(`Looking for plan with work_branch: ${currentBranch}\n`);
492
+
493
+ // Get all plan files (including dispatched ones)
494
+ const completedNames = getCompletedPlanNames(completedDir);
495
+ const allPlanFiles = getPlanFiles(plansDir).filter(
496
+ (f) => !completedNames.has(path.basename(f))
497
+ );
498
+
499
+ // Find plan matching current branch
500
+ for (const planFile of allPlanFiles) {
501
+ const plan = loadPlan(planFile);
502
+ if (plan.metadata.work_branch === currentBranch) {
503
+ matchingPlanFile = planFile;
504
+ matchingPlan = plan;
505
+ break;
506
+ }
507
+ }
508
+
509
+ if (!matchingPlanFile) {
510
+ console.error(
511
+ `No plan found with work_branch matching "${currentBranch}"`
512
+ );
513
+ console.log("\nTo resume a plan:");
514
+ console.log(" 1. git checkout <work-branch>");
515
+ console.log(" 2. orrery exec --resume");
516
+ console.log("\nOr specify a plan directly:");
517
+ console.log(" orrery exec --resume --plan <file>");
518
+ process.exit(1);
519
+ }
453
520
  }
454
521
 
455
522
  const planFileName = path.basename(matchingPlanFile);
@@ -542,6 +609,7 @@ async function handleResumeMode(
542
609
  * @param {string} completedDir - Directory for completed plans
543
610
  * @param {string} reportsDir - Directory for reports
544
611
  * @param {boolean} parallelEnabled - Whether parallel execution with worktrees is enabled
612
+ * @returns {Promise<{isComplete: boolean, isSuccessful: boolean, workBranch: string}>}
545
613
  */
546
614
  async function processPlanWithBranching(
547
615
  planFile,
@@ -563,16 +631,18 @@ async function processPlanWithBranching(
563
631
  plan.metadata.work_branch = workBranch;
564
632
  savePlan(plan);
565
633
 
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)})`
634
+ // Commit the metadata update on source branch (only if work dir is inside repo)
635
+ if (!isWorkDirExternal()) {
636
+ const metadataCommit = commit(
637
+ `chore: dispatch plan ${planFileName} to ${workBranch}`,
638
+ [planFile],
639
+ REPO_ROOT
575
640
  );
641
+ if (metadataCommit) {
642
+ console.log(
643
+ `Marked plan as dispatched on ${sourceBranch} (${metadataCommit.slice(0, 7)})`
644
+ );
645
+ }
576
646
  }
577
647
 
578
648
  // Step 3: Create and switch to work branch
@@ -592,6 +662,8 @@ async function processPlanWithBranching(
592
662
  const isComplete = plan.isComplete();
593
663
 
594
664
  if (isComplete) {
665
+ const isSuccessful = plan.isSuccessful();
666
+
595
667
  // Step 6: Archive the plan (on work branch)
596
668
  archivePlan(planFile, plan, completedDir);
597
669
 
@@ -610,6 +682,8 @@ async function processPlanWithBranching(
610
682
  const prBody = generatePRBody(plan);
611
683
  const prInfo = createPullRequest(prTitle, prBody, sourceBranch, REPO_ROOT);
612
684
  logPullRequestInfo(prInfo);
685
+
686
+ return { isComplete: true, isSuccessful, workBranch };
613
687
  } else {
614
688
  // Plan not complete (still has pending steps or was interrupted)
615
689
  // Commit any progress made
@@ -624,6 +698,8 @@ async function processPlanWithBranching(
624
698
  console.log(
625
699
  "\nPlan not complete. Work branch preserved for later continuation."
626
700
  );
701
+
702
+ return { isComplete: false, isSuccessful: false, workBranch };
627
703
  }
628
704
  }
629
705