@caseyharalson/orrery 0.11.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.
package/HELP.md CHANGED
@@ -71,20 +71,25 @@ each step.
71
71
 
72
72
  ```
73
73
  Options:
74
- --plan <file> Process only a specific plan file
75
- --dry-run Show what would be executed without running agents
76
- --verbose Show detailed agent output
77
- --resume Resume orchestration on the current work branch
78
- --review Enable code review loop after each step
79
- --parallel Enable parallel execution with git worktrees for isolation
74
+ --plan <file> Process only a specific plan file
75
+ --dry-run Show what would be executed without running agents
76
+ --verbose Show detailed agent output
77
+ --resume Resume orchestration on the current work branch
78
+ --review Enable code review loop after each step
79
+ --parallel Enable parallel execution with git worktrees for isolation
80
+ --background Run orchestration as a detached background process
80
81
  ```
81
82
 
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.
85
+
82
86
  Example:
83
87
 
84
88
  ```bash
85
89
  orrery exec
86
90
  orrery exec --plan my-feature.yaml --review
87
91
  orrery exec --parallel --verbose
92
+ orrery exec --background
88
93
  ```
89
94
 
90
95
  #### `orrery resume`
@@ -94,15 +99,21 @@ work branch, resets blocked steps to pending, commits, and resumes.
94
99
 
95
100
  ```
96
101
  Options:
97
- --step <id> Unblock a specific step before resuming
98
- --all Unblock all blocked steps (default behavior)
99
- --dry-run Preview what would be unblocked without making changes
102
+ --plan <file> Resume a specific plan file (skips branch auto-detection)
103
+ --step <id> Unblock a specific step before resuming
104
+ --all Unblock all blocked steps (default behavior)
105
+ --dry-run Preview what would be unblocked without making changes
100
106
  ```
101
107
 
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.
111
+
102
112
  Example:
103
113
 
104
114
  ```bash
105
115
  orrery resume
116
+ orrery resume --plan my-feature.yaml
106
117
  orrery resume --step step-2
107
118
  orrery resume --dry-run
108
119
  ```
@@ -112,7 +123,8 @@ orrery resume --dry-run
112
123
  #### `orrery status`
113
124
 
114
125
  Show orchestration status for plans in the current project. Auto-detects the
115
- plan when on a work branch.
126
+ plan when on a work branch. Also shows whether an orchestration process is
127
+ currently running (via lock file detection) or if a stale lock exists.
116
128
 
117
129
  ```
118
130
  Options:
@@ -250,6 +262,19 @@ The review agent inspects changes after each step. If issues are found, an edit
250
262
  agent applies fixes and verification re-runs, repeating until approval or the
251
263
  max iteration limit is reached (default: 3).
252
264
 
265
+ ### Background Execution
266
+
267
+ Run orchestration as a detached background process:
268
+
269
+ ```bash
270
+ orrery exec --background
271
+ orrery exec --plan my-feature.yaml --background
272
+ ```
273
+
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.
277
+
253
278
  ### Parallel Execution
254
279
 
255
280
  Run independent steps concurrently using git worktrees for isolation:
@@ -371,8 +396,14 @@ Orrery maintains state in `.agent-work/` (configurable via `ORRERY_WORK_DIR`):
371
396
  plans/ Active plan files (new and in-progress)
372
397
  reports/ Step-level execution logs and outcomes
373
398
  completed/ Successfully executed plans (archived)
399
+ exec.lock Lock file when orchestration is running
400
+ exec.log Output log for background execution
374
401
  ```
375
402
 
403
+ When `ORRERY_WORK_DIR` is set, Orrery automatically scopes to a project
404
+ subdirectory (`<project-name>-<hash>`) so multiple projects can share the same
405
+ work directory without plan conflicts.
406
+
376
407
  ---
377
408
 
378
409
  ## Environment Variables
@@ -385,7 +416,7 @@ Orrery maintains state in `.agent-work/` (configurable via `ORRERY_WORK_DIR`):
385
416
  | `ORRERY_PARALLEL_MAX` | Maximum concurrent parallel agents | `3` |
386
417
  | `ORRERY_REVIEW_ENABLED` | Enable the review loop | `false` |
387
418
  | `ORRERY_REVIEW_MAX_ITERATIONS` | Maximum review-edit loop iterations | `3` |
388
- | `ORRERY_WORK_DIR` | Override the work directory path | `.agent-work` |
419
+ | `ORRERY_WORK_DIR` | Override the work directory path (project-scoped) | `.agent-work` |
389
420
 
390
421
  ---
391
422
 
package/README.md CHANGED
@@ -88,6 +88,7 @@ For power users, Orrery offers additional capabilities:
88
88
  - **External Plan Creation** - Import plans from other tools or LLMs
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
+ - **Background Execution** - Run orchestration as a detached process and poll status
91
92
  - **Handling Blocked Plans** - Recovery workflows when steps cannot complete
92
93
 
93
94
  See [Advanced Workflows](docs/advanced-workflows.md) for details.
@@ -120,17 +121,18 @@ The Orchestrator (`orrery exec`) is the engine that drives the process. It loads
120
121
 
121
122
  ## Command Reference
122
123
 
123
- | Command | Description |
124
- | :------------------- | :------------------------------------------------------------------------------------------------------------ |
125
- | `orrery` | Command reference. |
126
- | `orrery init` | Initialize Orrery: install skills to detected agents. |
127
- | `orrery manual` | Show the full CLI reference manual. |
128
- | `orrery orchestrate` | Executes the active plan. Use `--review` for review loop, `--parallel` for parallel execution. Alias: `exec`. |
129
- | `orrery status` | Shows the progress of current plans. |
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. |
130
132
 
131
133
  ## Directory Structure
132
134
 
133
- Orrery maintains its state in the `.agent-work/` directory (configurable via `ORRERY_WORK_DIR`).
135
+ Orrery maintains its state in the `.agent-work/` directory (configurable via `ORRERY_WORK_DIR`). When `ORRERY_WORK_DIR` is set, each project gets an isolated subdirectory so multiple projects can share the same work directory.
134
136
 
135
137
  - `.agent-work/plans/`: **Active Plans.** New and in-progress plan files.
136
138
  - `.agent-work/reports/`: **Reports.** Step-level execution logs and outcomes.
@@ -191,7 +191,15 @@ Use the schema defined in `./schemas/plan-schema.yaml`.
191
191
 
192
192
  **Output Location:**
193
193
 
194
- - Directory: `.agent-work/plans/`
194
+ First, determine the plans directory by running:
195
+
196
+ ```bash
197
+ orrery plans-dir
198
+ ```
199
+
200
+ This prints the resolved plans directory (respects `ORRERY_WORK_DIR` when set).
201
+
202
+ - Directory: the path returned by `orrery plans-dir`
195
203
  - Filename: `<date>-<plan-name>.yaml`
196
204
  - Date format: YYYY-MM-DD (e.g., `2026-01-11`)
197
205
  - Plan name: kebab-case description of the task (e.g., `fix-clone-agent-skills`)
@@ -215,7 +223,7 @@ Plans are automatically validated via the PostToolUse hook when written.
215
223
  For manual validation, run:
216
224
 
217
225
  ```bash
218
- orrery validate-plan .agent-work/plans/<plan>.yaml
226
+ orrery validate-plan <plans-dir>/<plan>.yaml
219
227
  ```
220
228
 
221
229
  This catches common YAML issues like unquoted colons and normalizes formatting.
@@ -405,11 +413,11 @@ Discovery is complete when:
405
413
  When the plan is complete and validated, output the plan file path and present the user with their next options:
406
414
 
407
415
  ```
408
- Plan created: .agent-work/plans/<date>-<plan-name>.yaml
416
+ Plan created: <plans-dir>/<date>-<plan-name>.yaml
409
417
 
410
418
  Next steps:
411
- - /refine-plan .agent-work/plans/<plan-file> — Analyze and improve the plan before execution
412
- - /simulate-plan .agent-work/plans/<plan-file> — Explore the plan through dialogue, ask "what if" questions
419
+ - /refine-plan <plans-dir>/<plan-file> — Analyze and improve the plan before execution
420
+ - /simulate-plan <plans-dir>/<plan-file> — Explore the plan through dialogue, ask "what if" questions
413
421
  - orrery exec — (Command run from the terminal) Execute the plan with the orrery orchestrator
414
422
  ```
415
423
 
@@ -39,7 +39,7 @@ These guidelines override generic examples in this skill. For example, if a guid
39
39
 
40
40
  ### Git State
41
41
 
42
- The orchestrator modifies `.agent-work/` files before you start (marking steps `in_progress`, creating temp files). This is expected. **Ignore changes in `.agent-work/`** when checking git status - these are orchestrator bookkeeping files, not unexpected changes.
42
+ The orchestrator modifies plan and work directory files before you start (marking steps `in_progress`, creating temp files). This is expected. **Ignore changes in the orchestrator work directory** (e.g., `.agent-work/` or the path from `orrery plans-dir`) when checking git status - these are orchestrator bookkeeping files, not unexpected changes.
43
43
 
44
44
  ### Step 1: Read the Plan
45
45
 
@@ -3,7 +3,8 @@ name: refine-plan
3
3
  description: >
4
4
  Analyze and improve an existing plan file. Reviews plan structure, dependencies,
5
5
  context quality, and acceptance criteria, then implements improvements directly.
6
- Requires a plan file argument (e.g., /refine-plan .agent-work/plans/my-plan.yaml).
6
+ Requires a plan file argument (e.g., /refine-plan my-plan.yaml).
7
+ Run `orrery plans-dir` to find the plans directory.
7
8
  hooks:
8
9
  PostToolUse:
9
10
  - matcher: "Write"
@@ -273,7 +274,7 @@ Ready for execution.
273
274
  ## Example Dialogue
274
275
 
275
276
  ```
276
- User: /refine-plan .agent-work/plans/analytics-dashboard.yaml
277
+ User: /refine-plan analytics-dashboard.yaml
277
278
 
278
279
  Agent: I've loaded the analytics dashboard plan. It has 6 steps delivering
279
280
  two outcomes. Let me analyze it for improvements.
@@ -2,7 +2,8 @@
2
2
  name: simulate-plan
3
3
  description: >
4
4
  Explore a plan through conversational dialogue before committing to execution.
5
- Requires a plan file argument (e.g., /simulate-plan .agent-work/plans/my-plan.yaml, /simulate-plan my-plan).
5
+ Requires a plan file argument (e.g., /simulate-plan my-plan.yaml, /simulate-plan my-plan).
6
+ Run `orrery plans-dir` to find the plans directory.
6
7
  Ask "what if" questions, trace dependencies, and build intuition about what you're building.
7
8
  ---
8
9
 
@@ -164,7 +165,7 @@ Only discuss what's in the plan. If asked about implementation details not cover
164
165
  ## Example Dialogue
165
166
 
166
167
  ```
167
- User: /simulate .agent-work/plans/analytics-dashboard.yaml
168
+ User: /simulate-plan analytics-dashboard.yaml
168
169
 
169
170
  Agent: I've loaded the analytics dashboard plan. It has 6 steps delivering
170
171
  two outcomes: "Users can see usage trends" and "Admins can export reports."
@@ -1,4 +1,9 @@
1
+ const { spawn } = require("child_process");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+
1
5
  const { orchestrate } = require("../../orchestration");
6
+ const { getWorkDir } = require("../../utils/paths");
2
7
 
3
8
  module.exports = function registerOrchestrateCommand(program) {
4
9
  program
@@ -14,7 +19,54 @@ module.exports = function registerOrchestrateCommand(program) {
14
19
  "--parallel",
15
20
  "Enable parallel execution with git worktrees for isolation"
16
21
  )
22
+ .option(
23
+ "--background",
24
+ "Run orchestration as a detached background process"
25
+ )
17
26
  .action(async (options) => {
27
+ // Background mode: re-spawn as detached process
28
+ if (options.background) {
29
+ if (options.dryRun) {
30
+ console.log(
31
+ "Note: --background with --dry-run runs in foreground.\n"
32
+ );
33
+ // Fall through to normal execution
34
+ } else {
35
+ const args = [];
36
+ if (options.plan) args.push("--plan", options.plan);
37
+ if (options.verbose) args.push("--verbose");
38
+ if (options.resume) args.push("--resume");
39
+ if (options.review) args.push("--review");
40
+ if (options.parallel) args.push("--parallel");
41
+
42
+ const logFile = path.join(getWorkDir(), "exec.log");
43
+ const logFd = fs.openSync(logFile, "a");
44
+
45
+ const binPath = path.join(
46
+ __dirname,
47
+ "..",
48
+ "..",
49
+ "..",
50
+ "bin",
51
+ "orrery.js"
52
+ );
53
+ const child = spawn(process.execPath, [binPath, "exec", ...args], {
54
+ detached: true,
55
+ stdio: ["ignore", logFd, logFd],
56
+ cwd: process.cwd(),
57
+ env: process.env
58
+ });
59
+
60
+ child.unref();
61
+ fs.closeSync(logFd);
62
+
63
+ console.log(`Background execution started (PID ${child.pid})`);
64
+ console.log(`Log file: ${logFile}`);
65
+ console.log("\nUse 'orrery status' to check progress.");
66
+ return;
67
+ }
68
+ }
69
+
18
70
  try {
19
71
  await orchestrate({
20
72
  plan: options.plan,
@@ -0,0 +1,10 @@
1
+ const { getPlansDir } = require("../../utils/paths");
2
+
3
+ module.exports = function registerPlansDirCommand(program) {
4
+ program
5
+ .command("plans-dir")
6
+ .description("Print the resolved plans directory path")
7
+ .action(() => {
8
+ console.log(getPlansDir());
9
+ });
10
+ };
@@ -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
@@ -10,6 +10,7 @@ const registerResume = require("./commands/resume");
10
10
  const registerValidatePlan = require("./commands/validate-plan");
11
11
  const registerIngestPlan = require("./commands/ingest-plan");
12
12
  const registerManual = require("./commands/manual");
13
+ const registerPlansDir = require("./commands/plans-dir");
13
14
  const registerHelp = require("./commands/help");
14
15
 
15
16
  function getPackageVersion() {
@@ -36,6 +37,7 @@ function buildProgram() {
36
37
  registerValidatePlan(program);
37
38
  registerIngestPlan(program);
38
39
  registerManual(program);
40
+ registerPlansDir(program);
39
41
  registerHelp(program);
40
42
 
41
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
 
package/lib/utils/git.js CHANGED
@@ -22,7 +22,9 @@ function git(command, cwd) {
22
22
  } catch (error) {
23
23
  // Return stderr if available, otherwise throw
24
24
  if (error.stderr) {
25
- throw new Error(`git ${command} failed: ${error.stderr.trim()}`);
25
+ throw new Error(`git ${command} failed: ${error.stderr.trim()}`, {
26
+ cause: error
27
+ });
26
28
  }
27
29
  throw error;
28
30
  }
@@ -34,7 +36,12 @@ function git(command, cwd) {
34
36
  * @returns {string} - Current branch name
35
37
  */
36
38
  function getCurrentBranch(cwd) {
37
- return git("rev-parse --abbrev-ref HEAD", cwd);
39
+ try {
40
+ return git("rev-parse --abbrev-ref HEAD", cwd);
41
+ } catch {
42
+ // Unborn branch (no commits yet) — symbolic-ref still works
43
+ return git("symbolic-ref --short HEAD", cwd);
44
+ }
38
45
  }
39
46
 
40
47
  /**
@@ -0,0 +1,170 @@
1
+ const { execSync } = require("child_process");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+
5
+ const { getWorkDir } = require("./paths");
6
+
7
+ const LOCK_FILE = "exec.lock";
8
+
9
+ function getLockPath() {
10
+ return path.join(getWorkDir(), LOCK_FILE);
11
+ }
12
+
13
+ /**
14
+ * Check if a process with the given PID is running.
15
+ * @param {number} pid - Process ID
16
+ * @returns {boolean}
17
+ */
18
+ function isProcessRunning(pid) {
19
+ try {
20
+ process.kill(pid, 0);
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Check if a PID belongs to an orrery process.
29
+ * @param {number} pid - Process ID
30
+ * @returns {boolean}
31
+ */
32
+ function isOrreryProcess(pid) {
33
+ try {
34
+ // Linux: read /proc/<pid>/cmdline (null-separated args)
35
+ const cmdlinePath = `/proc/${pid}/cmdline`;
36
+ if (fs.existsSync(cmdlinePath)) {
37
+ const raw = fs.readFileSync(cmdlinePath, "utf8");
38
+ const args = raw.split("\0").filter(Boolean);
39
+ // Check if any argument ends with the orrery binary (bin/orrery.js or bin/orrery)
40
+ return args.some(
41
+ (arg) => arg.endsWith("bin/orrery.js") || arg.endsWith("bin/orrery")
42
+ );
43
+ }
44
+
45
+ // macOS/other: use ps
46
+ const args = execSync(`ps -p ${pid} -o args=`, {
47
+ encoding: "utf8",
48
+ stdio: ["pipe", "pipe", "pipe"]
49
+ }).trim();
50
+ return args.includes("bin/orrery.js") || args.includes("bin/orrery ");
51
+ } catch {
52
+ // Cannot determine — treat as stale (safe default)
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Read and parse the lock file.
59
+ * @returns {{pid: number, startedAt: string, command: string}|null}
60
+ */
61
+ function readLock() {
62
+ const lockPath = getLockPath();
63
+ try {
64
+ const content = fs.readFileSync(lockPath, "utf8");
65
+ return JSON.parse(content);
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Attempt to acquire the execution lock.
73
+ * @returns {{acquired: boolean, reason?: string, pid?: number}}
74
+ */
75
+ function acquireLock() {
76
+ const lockPath = getLockPath();
77
+ const lockData = {
78
+ pid: process.pid,
79
+ startedAt: new Date().toISOString(),
80
+ command: process.argv.slice(2).join(" ")
81
+ };
82
+
83
+ // Check for existing lock
84
+ const existing = readLock();
85
+ if (existing) {
86
+ const running = isProcessRunning(existing.pid);
87
+ if (running && isOrreryProcess(existing.pid)) {
88
+ return {
89
+ acquired: false,
90
+ reason: `Another orrery process is running (PID ${existing.pid}, started ${existing.startedAt})`,
91
+ pid: existing.pid
92
+ };
93
+ }
94
+
95
+ // Stale lock — remove it
96
+ try {
97
+ fs.unlinkSync(lockPath);
98
+ } catch {
99
+ // Ignore cleanup errors
100
+ }
101
+ }
102
+
103
+ // Atomic create
104
+ try {
105
+ fs.writeFileSync(lockPath, JSON.stringify(lockData, null, 2) + "\n", {
106
+ flag: "wx"
107
+ });
108
+ return { acquired: true };
109
+ } catch (err) {
110
+ if (err.code === "EEXIST") {
111
+ // Race condition — another process acquired between check and write
112
+ const raceWinner = readLock();
113
+ return {
114
+ acquired: false,
115
+ reason: `Another orrery process just started (PID ${raceWinner?.pid || "unknown"})`,
116
+ pid: raceWinner?.pid
117
+ };
118
+ }
119
+ return {
120
+ acquired: false,
121
+ reason: `Failed to create lock file: ${err.message}`
122
+ };
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Release the execution lock (only if owned by current process).
128
+ */
129
+ function releaseLock() {
130
+ const lockPath = getLockPath();
131
+ const existing = readLock();
132
+
133
+ if (existing && existing.pid === process.pid) {
134
+ try {
135
+ fs.unlinkSync(lockPath);
136
+ } catch {
137
+ // Ignore cleanup errors
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Get the current lock status (read-only).
144
+ * @returns {{locked: boolean, pid?: number, startedAt?: string, stale: boolean}}
145
+ */
146
+ function getLockStatus() {
147
+ const existing = readLock();
148
+
149
+ if (!existing) {
150
+ return { locked: false, stale: false };
151
+ }
152
+
153
+ const running = isProcessRunning(existing.pid);
154
+ const isOrrery = running && isOrreryProcess(existing.pid);
155
+
156
+ return {
157
+ locked: isOrrery,
158
+ pid: existing.pid,
159
+ startedAt: existing.startedAt,
160
+ stale: !isOrrery
161
+ };
162
+ }
163
+
164
+ module.exports = {
165
+ acquireLock,
166
+ releaseLock,
167
+ getLockStatus,
168
+ isProcessRunning,
169
+ isOrreryProcess
170
+ };
@@ -1,3 +1,4 @@
1
+ const crypto = require("crypto");
1
2
  const fs = require("fs");
2
3
  const path = require("path");
3
4
 
@@ -10,10 +11,28 @@ function ensureDir(dirPath) {
10
11
  return dirPath;
11
12
  }
12
13
 
14
+ /**
15
+ * Generate a deterministic project identifier from the current working directory.
16
+ * Format: <sanitized-basename>-<hash8>
17
+ * @returns {string} - Project identifier
18
+ */
19
+ function getProjectId() {
20
+ const cwd = path.resolve(process.cwd());
21
+ const basename = path.basename(cwd) || "root";
22
+ const sanitized = basename.replace(/[^a-zA-Z0-9._-]/g, "_");
23
+ const hash = crypto
24
+ .createHash("sha256")
25
+ .update(cwd)
26
+ .digest("hex")
27
+ .slice(0, 8);
28
+ return `${sanitized}-${hash}`;
29
+ }
30
+
13
31
  function getWorkDir() {
14
32
  const envDir = process.env[WORK_DIR_ENV];
15
33
  if (envDir && envDir.trim()) {
16
- return ensureDir(envDir.trim());
34
+ const projectScoped = path.join(envDir.trim(), getProjectId());
35
+ return ensureDir(projectScoped);
17
36
  }
18
37
  return ensureDir(path.join(process.cwd(), ".agent-work"));
19
38
  }
@@ -34,10 +53,18 @@ function getTempDir() {
34
53
  return ensureDir(path.join(getWorkDir(), "temp"));
35
54
  }
36
55
 
56
+ function isWorkDirExternal() {
57
+ const workDir = path.resolve(getWorkDir());
58
+ const cwd = path.resolve(process.cwd());
59
+ return !workDir.startsWith(cwd + path.sep) && workDir !== cwd;
60
+ }
61
+
37
62
  module.exports = {
38
63
  getWorkDir,
39
64
  getPlansDir,
40
65
  getCompletedDir,
41
66
  getReportsDir,
42
- getTempDir
67
+ getTempDir,
68
+ getProjectId,
69
+ isWorkDirExternal
43
70
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caseyharalson/orrery",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Workflow planning and orchestration CLI for AI agents",
5
5
  "license": "MIT",
6
6
  "author": "Casey Haralson",
@@ -52,7 +52,8 @@
52
52
  "yaml": "^2.8.2"
53
53
  },
54
54
  "devDependencies": {
55
- "eslint": "^9.39.2",
55
+ "@eslint/js": "^10.0.1",
56
+ "eslint": "^10.0.1",
56
57
  "eslint-config-prettier": "^10.1.8",
57
58
  "prettier": "^3.8.1"
58
59
  }