@caseyharalson/orrery 0.12.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 CHANGED
@@ -78,10 +78,13 @@ Options:
78
78
  --review Enable code review loop after each step
79
79
  --parallel Enable parallel execution with git worktrees for isolation
80
80
  --background Run orchestration as a detached background process
81
+ --on-complete <command> Run a shell command when the orchestrator finishes
81
82
  ```
82
83
 
83
- Only one `exec`/`resume` can run at a time per project. A lock file
84
- (`exec.lock`) prevents concurrent runs and is automatically cleaned up.
84
+ A lock file prevents concurrent runs and is automatically cleaned up. Without
85
+ `--plan`, a global lock (`exec.lock`) allows only one execution at a time.
86
+ With `--plan`, each plan gets its own lock (`exec-<planId>.lock`) and runs in
87
+ an isolated worktree, so multiple plans can execute concurrently.
85
88
 
86
89
  Example:
87
90
 
@@ -103,17 +106,21 @@ Options:
103
106
  --step <id> Unblock a specific step before resuming
104
107
  --all Unblock all blocked steps (default behavior)
105
108
  --dry-run Preview what would be unblocked without making changes
109
+ --background Run resume as a detached background process
110
+ --on-complete <command> Run a shell command when the orchestrator finishes
106
111
  ```
107
112
 
108
- When `--plan` is provided, the plan's `work_branch` must match the current
109
- branch. If the plan hasn't been dispatched yet (no `work_branch`), use
110
- `orrery exec --plan` first.
113
+ When `--plan` is provided and a worktree exists for the plan, resume runs
114
+ directly inside the worktree. Otherwise, the plan's `work_branch` must match
115
+ the current branch. If the plan hasn't been dispatched yet (no `work_branch`),
116
+ use `orrery exec --plan` first.
111
117
 
112
118
  Example:
113
119
 
114
120
  ```bash
115
121
  orrery resume
116
122
  orrery resume --plan my-feature.yaml
123
+ orrery resume --plan my-feature.yaml --background
117
124
  orrery resume --step step-2
118
125
  orrery resume --dry-run
119
126
  ```
@@ -264,16 +271,69 @@ max iteration limit is reached (default: 3).
264
271
 
265
272
  ### Background Execution
266
273
 
267
- Run orchestration as a detached background process:
274
+ Run orchestration or resume as a detached background process:
268
275
 
269
276
  ```bash
270
277
  orrery exec --background
271
278
  orrery exec --plan my-feature.yaml --background
279
+ orrery resume --plan my-feature.yaml --background
272
280
  ```
273
281
 
274
- The process runs detached and logs output to `<work-dir>/exec.log`. Use
275
- `orrery status` to check progress. A lock file prevents starting a second
276
- execution while one is already running.
282
+ The process runs detached and logs output to `<work-dir>/exec.log` (or
283
+ `exec-<planId>.log` for per-plan execution). Use `orrery status` to check
284
+ progress. Each execution acquires its own lock file — a global `exec.lock` for non-plan
285
+ runs, or `exec-<planId>.lock` when `--plan` is used — so background per-plan
286
+ executions can run concurrently.
287
+
288
+ ### Concurrent Plan Execution
289
+
290
+ Run multiple plans at the same time by using `--plan` in separate terminals.
291
+ Each plan executes in its own git worktree (`.worktrees/plan-<planId>`) with
292
+ an independent lock file, so plans do not interfere with each other.
293
+
294
+ ```bash
295
+ # Terminal 1
296
+ orrery exec --plan auth-feature.yaml
297
+
298
+ # Terminal 2
299
+ orrery exec --plan search-feature.yaml
300
+
301
+ # Or run both in the background from a single terminal
302
+ orrery exec --plan auth-feature.yaml --background
303
+ orrery exec --plan search-feature.yaml --background
304
+ ```
305
+
306
+ Use `orrery status` to see all running plans at once. Resume a specific plan
307
+ with `orrery resume --plan <file>`, which automatically re-enters the plan's
308
+ existing worktree.
309
+
310
+ Note: Without `--plan`, execution uses a global lock and only one run is
311
+ allowed at a time.
312
+
313
+ ### Completion Hook
314
+
315
+ Run a shell command when the orchestrator finishes using `--on-complete`:
316
+
317
+ ```bash
318
+ orrery exec --on-complete "notify-send 'Plan finished'"
319
+ orrery resume --plan my-feature.yaml --on-complete "./scripts/on-done.sh"
320
+ ```
321
+
322
+ The command receives plan context through environment variables:
323
+
324
+ | Variable | Description | Example |
325
+ | :----------------------- | :--------------------------------------------- | :----------------------------------------------------- |
326
+ | `ORRERY_PLAN_NAME` | Plan file name | `my-feature.yaml` |
327
+ | `ORRERY_PLAN_FILE` | Absolute path to the plan file | `/home/user/project/.agent-work/plans/my-feature.yaml` |
328
+ | `ORRERY_PLAN_OUTCOME` | Outcome: `success`, `partial`, or `incomplete` | `success` |
329
+ | `ORRERY_WORK_BRANCH` | Work branch name | `plan/my-feature` |
330
+ | `ORRERY_SOURCE_BRANCH` | Source branch name | `main` |
331
+ | `ORRERY_PR_URL` | PR URL (empty if not created) | `https://github.com/user/repo/pull/42` |
332
+ | `ORRERY_STEPS_TOTAL` | Total number of steps | `5` |
333
+ | `ORRERY_STEPS_COMPLETED` | Number of completed steps | `4` |
334
+ | `ORRERY_STEPS_BLOCKED` | Number of blocked steps | `1` |
335
+
336
+ Hook failures are logged but do not fail the orchestrator.
277
337
 
278
338
  ### Parallel Execution
279
339
 
@@ -393,11 +453,13 @@ Orrery maintains state in `.agent-work/` (configurable via `ORRERY_WORK_DIR`):
393
453
 
394
454
  ```
395
455
  .agent-work/
396
- plans/ Active plan files (new and in-progress)
397
- reports/ Step-level execution logs and outcomes
398
- completed/ Successfully executed plans (archived)
399
- exec.lock Lock file when orchestration is running
400
- exec.log Output log for background execution
456
+ plans/ Active plan files (new and in-progress)
457
+ reports/ Step-level execution logs and outcomes
458
+ completed/ Successfully executed plans (archived)
459
+ exec.lock Lock file when orchestration is running
460
+ exec-<planId>.lock Per-plan lock file for concurrent execution
461
+ exec.log Output log for background execution
462
+ exec-<planId>.log Per-plan output log for concurrent background execution
401
463
  ```
402
464
 
403
465
  When `ORRERY_WORK_DIR` is set, Orrery automatically scopes to a project
package/README.md CHANGED
@@ -89,6 +89,7 @@ For power users, Orrery offers additional capabilities:
89
89
  - **Review Loop** - Iterative code review after each step with automatic fixes
90
90
  - **Parallel Execution** - Run independent steps concurrently with git worktree isolation
91
91
  - **Background Execution** - Run orchestration as a detached process and poll status
92
+ - **Completion Hook** - Run a command when orchestration finishes
92
93
  - **Handling Blocked Plans** - Recovery workflows when steps cannot complete
93
94
 
94
95
  See [Advanced Workflows](docs/advanced-workflows.md) for details.
@@ -121,14 +122,14 @@ The Orchestrator (`orrery exec`) is the engine that drives the process. It loads
121
122
 
122
123
  ## Command Reference
123
124
 
124
- | Command | Description |
125
- | :------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------- |
126
- | `orrery` | Command reference. |
127
- | `orrery init` | Initialize Orrery: install skills to detected agents. |
128
- | `orrery manual` | Show the full CLI reference manual. |
129
- | `orrery orchestrate` | Executes the active plan. Use `--review` for review loop, `--parallel` for parallel execution, `--background` for detached mode. Alias: `exec`. |
130
- | `orrery resume` | Unblock steps and resume orchestration. Use `--plan` to target a specific plan. |
131
- | `orrery status` | Shows the progress of current plans and active execution status. |
125
+ | Command | Description |
126
+ | :------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
+ | `orrery` | Command reference. |
128
+ | `orrery init` | Initialize Orrery: install skills to detected agents. |
129
+ | `orrery manual` | Show the full CLI reference manual. |
130
+ | `orrery orchestrate` | Executes the active plan. Use `--review` for review loop, `--parallel` for parallel execution, `--background` for detached mode, `--on-complete` for a completion hook. Alias: `exec`. |
131
+ | `orrery resume` | Unblock steps and resume orchestration. Use `--plan` to target a specific plan, `--background` for detached mode. |
132
+ | `orrery status` | Shows the progress of current plans and active execution status. |
132
133
 
133
134
  ## Directory Structure
134
135
 
@@ -4,6 +4,7 @@ const path = require("path");
4
4
 
5
5
  const { orchestrate } = require("../../orchestration");
6
6
  const { getWorkDir } = require("../../utils/paths");
7
+ const { derivePlanId } = require("../../utils/git");
7
8
 
8
9
  module.exports = function registerOrchestrateCommand(program) {
9
10
  program
@@ -23,6 +24,10 @@ module.exports = function registerOrchestrateCommand(program) {
23
24
  "--background",
24
25
  "Run orchestration as a detached background process"
25
26
  )
27
+ .option(
28
+ "--on-complete <command>",
29
+ "Run a shell command when the orchestrator finishes"
30
+ )
26
31
  .action(async (options) => {
27
32
  // Background mode: re-spawn as detached process
28
33
  if (options.background) {
@@ -38,8 +43,15 @@ module.exports = function registerOrchestrateCommand(program) {
38
43
  if (options.resume) args.push("--resume");
39
44
  if (options.review) args.push("--review");
40
45
  if (options.parallel) args.push("--parallel");
46
+ if (options.onComplete)
47
+ args.push("--on-complete", options.onComplete);
41
48
 
42
- const logFile = path.join(getWorkDir(), "exec.log");
49
+ let logFileName = "exec.log";
50
+ if (options.plan) {
51
+ const planId = derivePlanId(path.basename(options.plan));
52
+ logFileName = `exec-${planId}.log`;
53
+ }
54
+ const logFile = path.join(getWorkDir(), logFileName);
43
55
  const logFd = fs.openSync(logFile, "a");
44
56
 
45
57
  const binPath = path.join(
@@ -74,7 +86,8 @@ module.exports = function registerOrchestrateCommand(program) {
74
86
  verbose: options.verbose,
75
87
  resume: options.resume,
76
88
  review: options.review,
77
- parallel: options.parallel
89
+ parallel: options.parallel,
90
+ onComplete: options.onComplete
78
91
  });
79
92
  } catch (error) {
80
93
  console.error(error && error.message ? error.message : error);
@@ -1,3 +1,4 @@
1
+ const { spawn } = require("child_process");
1
2
  const fs = require("fs");
2
3
  const path = require("path");
3
4
 
@@ -6,8 +7,8 @@ const {
6
7
  loadPlan,
7
8
  updateStepsStatus
8
9
  } = require("../../orchestration/plan-loader");
9
- const { commit, getCurrentBranch } = require("../../utils/git");
10
- const { getPlansDir } = require("../../utils/paths");
10
+ const { commit, getCurrentBranch, derivePlanId } = require("../../utils/git");
11
+ const { getPlansDir, getWorkDir } = require("../../utils/paths");
11
12
  const { orchestrate } = require("../../orchestration");
12
13
 
13
14
  function supportsColor() {
@@ -36,7 +37,60 @@ module.exports = function registerResumeCommand(program) {
36
37
  "--dry-run",
37
38
  "Preview what would be unblocked without making changes"
38
39
  )
40
+ .option("--background", "Run resume as a detached background process")
41
+ .option(
42
+ "--on-complete <command>",
43
+ "Run a shell command when the orchestrator finishes"
44
+ )
39
45
  .action(async (options) => {
46
+ // Background mode: re-spawn as detached process
47
+ if (options.background) {
48
+ if (options.dryRun) {
49
+ console.log(
50
+ "Note: --background with --dry-run runs in foreground.\n"
51
+ );
52
+ // Fall through to normal execution
53
+ } else {
54
+ const args = [];
55
+ if (options.plan) args.push("--plan", options.plan);
56
+ if (options.step) args.push("--step", options.step);
57
+ if (options.all) args.push("--all");
58
+ if (options.onComplete)
59
+ args.push("--on-complete", options.onComplete);
60
+
61
+ let logFileName = "exec.log";
62
+ if (options.plan) {
63
+ const planId = derivePlanId(path.basename(options.plan));
64
+ logFileName = `exec-${planId}.log`;
65
+ }
66
+ const logFile = path.join(getWorkDir(), logFileName);
67
+ const logFd = fs.openSync(logFile, "a");
68
+
69
+ const binPath = path.join(
70
+ __dirname,
71
+ "..",
72
+ "..",
73
+ "..",
74
+ "bin",
75
+ "orrery.js"
76
+ );
77
+ const child = spawn(process.execPath, [binPath, "resume", ...args], {
78
+ detached: true,
79
+ stdio: ["ignore", logFd, logFd],
80
+ cwd: process.cwd(),
81
+ env: process.env
82
+ });
83
+
84
+ child.unref();
85
+ fs.closeSync(logFd);
86
+
87
+ console.log(`Background resume started (PID ${child.pid})`);
88
+ console.log(`Log file: ${logFile}`);
89
+ console.log("\nUse 'orrery status' to check progress.");
90
+ return;
91
+ }
92
+ }
93
+
40
94
  let planFile;
41
95
  let plan;
42
96
 
@@ -73,23 +127,34 @@ module.exports = function registerResumeCommand(program) {
73
127
  return;
74
128
  }
75
129
 
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
- }
130
+ // Check for existing worktree if found, skip branch validation
131
+ const planId = derivePlanId(path.basename(resolvedPath));
132
+ const worktreePath = path.join(
133
+ process.cwd(),
134
+ ".worktrees",
135
+ `plan-${planId}`
136
+ );
137
+ const hasWorktree = require("fs").existsSync(worktreePath);
85
138
 
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;
139
+ if (!hasWorktree) {
140
+ // Verify current branch matches plan's work_branch
141
+ let currentBranch;
142
+ try {
143
+ currentBranch = getCurrentBranch(process.cwd());
144
+ } catch {
145
+ console.error("Error detecting current branch.");
146
+ process.exitCode = 1;
147
+ return;
148
+ }
149
+
150
+ if (currentBranch !== plan.metadata.work_branch) {
151
+ console.error(
152
+ `Plan expects branch '${plan.metadata.work_branch}' but you are on '${currentBranch}'.`
153
+ );
154
+ console.log(`\nRun: git checkout ${plan.metadata.work_branch}`);
155
+ process.exitCode = 1;
156
+ return;
157
+ }
93
158
  }
94
159
  } else {
95
160
  // 1. Find plan for current branch (existing behavior)
@@ -137,7 +202,11 @@ module.exports = function registerResumeCommand(program) {
137
202
  }
138
203
 
139
204
  console.log("Resuming orchestration...\n");
140
- await orchestrate({ resume: true, plan: options.plan });
205
+ await orchestrate({
206
+ resume: true,
207
+ plan: options.plan,
208
+ onComplete: options.onComplete
209
+ });
141
210
  return;
142
211
  }
143
212
 
@@ -204,6 +273,10 @@ module.exports = function registerResumeCommand(program) {
204
273
 
205
274
  // 8. Resume orchestration
206
275
  console.log("\nResuming orchestration...\n");
207
- await orchestrate({ resume: true, plan: options.plan });
276
+ await orchestrate({
277
+ resume: true,
278
+ plan: options.plan,
279
+ onComplete: options.onComplete
280
+ });
208
281
  });
209
282
  };
@@ -1,10 +1,15 @@
1
+ const fs = require("fs");
1
2
  const path = require("path");
2
3
 
3
- const { getPlansDir } = require("../../utils/paths");
4
+ const {
5
+ getPlansDir,
6
+ getCompletedDir,
7
+ getWorkDir
8
+ } = require("../../utils/paths");
4
9
  const { getPlanFiles, loadPlan } = require("../../orchestration/plan-loader");
5
10
  const { findPlanForCurrentBranch } = require("../../utils/plan-detect");
6
- const { getCurrentBranch } = require("../../utils/git");
7
- const { getLockStatus } = require("../../utils/lock");
11
+ const { getCurrentBranch, derivePlanId } = require("../../utils/git");
12
+ const { getLockStatus, listPlanLocks } = require("../../utils/lock");
8
13
 
9
14
  function supportsColor() {
10
15
  return Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
@@ -48,7 +53,21 @@ function resolvePlanPath(planArg) {
48
53
  if (!planArg) return null;
49
54
  if (path.isAbsolute(planArg)) return planArg;
50
55
  if (planArg.includes(path.sep)) return path.resolve(process.cwd(), planArg);
51
- return path.join(getPlansDir(), planArg);
56
+
57
+ // Build candidate filenames: exact name, then with .yaml/.yml appended
58
+ const names = [planArg];
59
+ if (!planArg.endsWith(".yaml") && !planArg.endsWith(".yml")) {
60
+ names.push(`${planArg}.yaml`, `${planArg}.yml`);
61
+ }
62
+
63
+ // Check active plans first, then completed
64
+ for (const dir of [getPlansDir(), getCompletedDir()]) {
65
+ for (const name of names) {
66
+ const candidate = path.join(dir, name);
67
+ if (fs.existsSync(candidate)) return candidate;
68
+ }
69
+ }
70
+ return path.join(getPlansDir(), planArg); // default so caller's error message still works
52
71
  }
53
72
 
54
73
  function summarizePlans(plans) {
@@ -76,6 +95,14 @@ function renderPlanList(plans) {
76
95
  function renderPlanDetail(plan) {
77
96
  const status = getPlanStatus(plan);
78
97
  console.log(`${formatStatusLabel(status)} ${plan.fileName}`);
98
+ if (plan.metadata.completed_at) {
99
+ console.log(` Completed: ${plan.metadata.completed_at}`);
100
+ }
101
+ const planId = derivePlanId(plan.fileName);
102
+ const logFile = path.join(getWorkDir(), `exec-${planId}.log`);
103
+ if (fs.existsSync(logFile)) {
104
+ console.log(` Log: ${logFile}`);
105
+ }
79
106
  for (const step of plan.steps) {
80
107
  const stepLabel = formatStatusLabel(step.status || "pending");
81
108
  const description = step.description ? ` - ${step.description}` : "";
@@ -93,7 +120,7 @@ module.exports = function registerStatusCommand(program) {
93
120
  .description("Show orchestration status for plans in the current project")
94
121
  .option("--plan <file>", "Show detailed status for a specific plan")
95
122
  .action((options) => {
96
- // Check for active execution
123
+ // Check for active global execution
97
124
  const lock = getLockStatus();
98
125
  if (lock.locked) {
99
126
  console.log(
@@ -105,12 +132,32 @@ module.exports = function registerStatusCommand(program) {
105
132
  );
106
133
  }
107
134
 
135
+ // Check for per-plan executions
136
+ const planLocks = listPlanLocks();
137
+ const activePlanLocks = planLocks.filter((l) => l.active);
138
+ const stalePlanLocks = planLocks.filter((l) => l.stale);
139
+
140
+ if (activePlanLocks.length > 0) {
141
+ console.log(`Active plan executions (${activePlanLocks.length}):`);
142
+ for (const pl of activePlanLocks) {
143
+ let line = ` - ${pl.planId} (PID ${pl.pid}, started ${pl.startedAt})`;
144
+ if (pl.worktreePath) line += `\n worktree: ${pl.worktreePath}`;
145
+ console.log(line);
146
+ }
147
+ console.log();
148
+ }
149
+ if (stalePlanLocks.length > 0) {
150
+ console.log(
151
+ `Note: ${stalePlanLocks.length} stale per-plan lock(s) detected\n`
152
+ );
153
+ }
154
+
108
155
  const plansDir = getPlansDir();
109
156
  const planArg = options.plan;
110
157
 
111
158
  if (planArg) {
112
159
  const planPath = resolvePlanPath(planArg);
113
- if (!planPath || !require("fs").existsSync(planPath)) {
160
+ if (!planPath || !fs.existsSync(planPath)) {
114
161
  console.error(`Plan not found: ${planArg}`);
115
162
  process.exitCode = 1;
116
163
  return;
@@ -135,16 +182,28 @@ module.exports = function registerStatusCommand(program) {
135
182
  }
136
183
 
137
184
  const planFiles = getPlanFiles(plansDir);
138
- if (planFiles.length === 0) {
185
+ const completedDir = getCompletedDir();
186
+ const completedFiles = getPlanFiles(completedDir);
187
+
188
+ if (planFiles.length === 0 && completedFiles.length === 0) {
139
189
  console.log("No plans found");
140
190
  return;
141
191
  }
142
192
 
143
- const plans = planFiles.map((planFile) => loadPlan(planFile));
144
- const { pendingSteps, completedSteps } = summarizePlans(plans);
145
- console.log(
146
- `${plans.length} plans, ${pendingSteps} pending steps, ${completedSteps} completed`
147
- );
148
- renderPlanList(plans);
193
+ if (planFiles.length > 0) {
194
+ const plans = planFiles.map((planFile) => loadPlan(planFile));
195
+ const { pendingSteps, completedSteps } = summarizePlans(plans);
196
+ console.log(
197
+ `${plans.length} plans, ${pendingSteps} pending steps, ${completedSteps} completed`
198
+ );
199
+ renderPlanList(plans);
200
+ }
201
+
202
+ if (completedFiles.length > 0) {
203
+ if (planFiles.length > 0) console.log();
204
+ console.log(`Completed (${completedFiles.length}):`);
205
+ const completedPlans = completedFiles.map((f) => loadPlan(f));
206
+ renderPlanList(completedPlans);
207
+ }
149
208
  });
150
209
  };