@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 +42 -11
- package/README.md +10 -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 +52 -0
- package/lib/cli/commands/plans-dir.js +10 -0
- package/lib/cli/commands/resume.js +96 -33
- package/lib/cli/commands/status.js +13 -0
- package/lib/cli/index.js +2 -0
- package/lib/orchestration/index.js +220 -144
- package/lib/utils/git.js +9 -2
- package/lib/utils/lock.js +170 -0
- package/lib/utils/paths.js +29 -2
- package/package.json +3 -2
package/HELP.md
CHANGED
|
@@ -71,20 +71,25 @@ each step.
|
|
|
71
71
|
|
|
72
72
|
```
|
|
73
73
|
Options:
|
|
74
|
-
--plan <file>
|
|
75
|
-
--dry-run
|
|
76
|
-
--verbose
|
|
77
|
-
--resume
|
|
78
|
-
--review
|
|
79
|
-
--parallel
|
|
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
|
-
--
|
|
98
|
-
--
|
|
99
|
-
--
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
416
|
+
Plan created: <plans-dir>/<date>-<plan-name>.yaml
|
|
409
417
|
|
|
410
418
|
Next steps:
|
|
411
|
-
- /refine-plan
|
|
412
|
-
- /simulate-plan
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
5
|
-
|
|
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
|
-
|
|
35
|
-
let
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
process.
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
await handleResumeMode(plansDir, completedDir, reportsDir, sourceBranch);
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
300
|
+
try {
|
|
301
|
+
console.log("=== Plan Orchestrator Starting ===\n");
|
|
303
302
|
|
|
304
|
-
|
|
305
|
-
|
|
303
|
+
const plansDir = getPlansDir();
|
|
304
|
+
const completedDir = getCompletedDir();
|
|
305
|
+
const reportsDir = getReportsDir();
|
|
306
306
|
|
|
307
|
-
|
|
308
|
-
|
|
307
|
+
// Get list of completed plan filenames (to exclude)
|
|
308
|
+
const completedNames = getCompletedPlanNames(completedDir);
|
|
309
309
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
//
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/lib/utils/paths.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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": "^
|
|
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
|
}
|