@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.
- package/HELP.md +107 -14
- package/README.md +11 -8
- package/agent/skills/discovery/SKILL.md +13 -5
- package/agent/skills/orrery-execute/SKILL.md +1 -1
- package/agent/skills/refine-plan/SKILL.md +3 -2
- package/agent/skills/simulate-plan/SKILL.md +3 -2
- package/lib/cli/commands/orchestrate.js +66 -1
- package/lib/cli/commands/plans-dir.js +10 -0
- package/lib/cli/commands/resume.js +167 -31
- package/lib/cli/commands/status.js +83 -11
- package/lib/cli/index.js +2 -0
- package/lib/orchestration/index.js +830 -155
- package/lib/utils/git.js +52 -2
- package/lib/utils/lock.js +235 -0
- package/lib/utils/paths.js +46 -3
- package/package.json +3 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
process.
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
await handleResumeMode(plansDir, completedDir, reportsDir, sourceBranch);
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
345
|
+
try {
|
|
346
|
+
console.log("=== Plan Orchestrator Starting ===\n");
|
|
303
347
|
|
|
304
|
-
|
|
305
|
-
|
|
348
|
+
const plansDir = getPlansDir();
|
|
349
|
+
const completedDir = getCompletedDir();
|
|
350
|
+
const reportsDir = getReportsDir();
|
|
306
351
|
|
|
307
|
-
|
|
308
|
-
|
|
352
|
+
// Get list of completed plan filenames (to exclude)
|
|
353
|
+
const completedNames = getCompletedPlanNames(completedDir);
|
|
309
354
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
372
|
-
|
|
521
|
+
const cleanupLock = () => {
|
|
522
|
+
releaseLock(planId);
|
|
523
|
+
process.exit();
|
|
524
|
+
};
|
|
525
|
+
process.on("SIGINT", cleanupLock);
|
|
526
|
+
process.on("SIGTERM", cleanupLock);
|
|
373
527
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
385
|
-
|
|
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
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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,
|
|
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(
|
|
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, [],
|
|
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(
|
|
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,
|
|
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(
|
|
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, [],
|
|
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
|
|
899
|
-
let workingDir =
|
|
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(
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|