@caseyharalson/orrery 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HELP.md +107 -14
- package/README.md +11 -8
- package/agent/skills/discovery/SKILL.md +13 -5
- package/agent/skills/orrery-execute/SKILL.md +1 -1
- package/agent/skills/refine-plan/SKILL.md +3 -2
- package/agent/skills/simulate-plan/SKILL.md +3 -2
- package/lib/cli/commands/orchestrate.js +66 -1
- package/lib/cli/commands/plans-dir.js +10 -0
- package/lib/cli/commands/resume.js +167 -31
- package/lib/cli/commands/status.js +83 -11
- package/lib/cli/index.js +2 -0
- package/lib/orchestration/index.js +830 -155
- package/lib/utils/git.js +52 -2
- package/lib/utils/lock.js +235 -0
- package/lib/utils/paths.js +46 -3
- package/package.json +3 -2
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
|
/**
|
|
@@ -232,6 +239,22 @@ function getUncommittedDiff(cwd, files) {
|
|
|
232
239
|
}
|
|
233
240
|
}
|
|
234
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Derive a plan ID from a plan filename (for lock/worktree naming).
|
|
244
|
+
* @param {string} planFileName - Plan filename (e.g., "2026-01-11-add-dummy-script.yaml")
|
|
245
|
+
* @returns {string} - Plan ID (e.g., "add-dummy-script")
|
|
246
|
+
*/
|
|
247
|
+
function derivePlanId(planFileName) {
|
|
248
|
+
let name = planFileName.replace(/\.ya?ml$/, "");
|
|
249
|
+
name = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
|
|
250
|
+
name = name
|
|
251
|
+
.toLowerCase()
|
|
252
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
253
|
+
.replace(/-+/g, "-")
|
|
254
|
+
.replace(/^-|-$/g, "");
|
|
255
|
+
return name;
|
|
256
|
+
}
|
|
257
|
+
|
|
235
258
|
/**
|
|
236
259
|
* Derive a branch name from a plan filename
|
|
237
260
|
* @param {string} planFileName - Plan filename (e.g., "2026-01-11-add-dummy-script.yaml")
|
|
@@ -334,6 +357,30 @@ function listWorktrees(cwd) {
|
|
|
334
357
|
return worktrees;
|
|
335
358
|
}
|
|
336
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Get the main (first) worktree root path.
|
|
362
|
+
* Useful for finding the true repo root from inside a linked worktree.
|
|
363
|
+
* @param {string} cwd - Working directory (can be inside any worktree)
|
|
364
|
+
* @returns {string} - Absolute path to the main worktree
|
|
365
|
+
*/
|
|
366
|
+
function getMainRepoRoot(cwd) {
|
|
367
|
+
const worktrees = listWorktrees(cwd);
|
|
368
|
+
if (worktrees.length === 0) {
|
|
369
|
+
throw new Error("No worktrees found");
|
|
370
|
+
}
|
|
371
|
+
return worktrees[0].worktree;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Add a worktree for an existing branch (without creating a new branch).
|
|
376
|
+
* @param {string} worktreePath - Path where the worktree will be created
|
|
377
|
+
* @param {string} branchName - Existing branch to check out in the worktree
|
|
378
|
+
* @param {string} cwd - Working directory of the main repository
|
|
379
|
+
*/
|
|
380
|
+
function addWorktreeExistingBranch(worktreePath, branchName, cwd) {
|
|
381
|
+
git(`worktree add "${worktreePath}" ${branchName}`, cwd);
|
|
382
|
+
}
|
|
383
|
+
|
|
337
384
|
/**
|
|
338
385
|
* Get commits between two refs
|
|
339
386
|
* @param {string} fromRef - Starting reference (exclusive)
|
|
@@ -396,11 +443,14 @@ module.exports = {
|
|
|
396
443
|
hasUncommittedChanges,
|
|
397
444
|
getUncommittedDiff,
|
|
398
445
|
deriveBranchName,
|
|
446
|
+
derivePlanId,
|
|
399
447
|
stash,
|
|
400
448
|
stashPop,
|
|
401
449
|
addWorktree,
|
|
450
|
+
addWorktreeExistingBranch,
|
|
402
451
|
removeWorktree,
|
|
403
452
|
listWorktrees,
|
|
453
|
+
getMainRepoRoot,
|
|
404
454
|
getCommitRange,
|
|
405
455
|
cherryPick,
|
|
406
456
|
cherryPickAbort,
|
|
@@ -0,0 +1,235 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* Get the lock file path.
|
|
11
|
+
* @param {string} [planId] - Optional plan ID for per-plan locks
|
|
12
|
+
* @returns {string} - Path to the lock file
|
|
13
|
+
*/
|
|
14
|
+
function getLockPath(planId) {
|
|
15
|
+
const fileName = planId ? `exec-${planId}.lock` : LOCK_FILE;
|
|
16
|
+
return path.join(getWorkDir(), fileName);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if a process with the given PID is running.
|
|
21
|
+
* @param {number} pid - Process ID
|
|
22
|
+
* @returns {boolean}
|
|
23
|
+
*/
|
|
24
|
+
function isProcessRunning(pid) {
|
|
25
|
+
try {
|
|
26
|
+
process.kill(pid, 0);
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check if a PID belongs to an orrery process.
|
|
35
|
+
* @param {number} pid - Process ID
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
function isOrreryProcess(pid) {
|
|
39
|
+
try {
|
|
40
|
+
// Linux: read /proc/<pid>/cmdline (null-separated args)
|
|
41
|
+
const cmdlinePath = `/proc/${pid}/cmdline`;
|
|
42
|
+
if (fs.existsSync(cmdlinePath)) {
|
|
43
|
+
const raw = fs.readFileSync(cmdlinePath, "utf8");
|
|
44
|
+
const args = raw.split("\0").filter(Boolean);
|
|
45
|
+
// Check if any argument ends with the orrery binary (bin/orrery.js or bin/orrery)
|
|
46
|
+
return args.some(
|
|
47
|
+
(arg) => arg.endsWith("bin/orrery.js") || arg.endsWith("bin/orrery")
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// macOS/other: use ps
|
|
52
|
+
const args = execSync(`ps -p ${pid} -o args=`, {
|
|
53
|
+
encoding: "utf8",
|
|
54
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
55
|
+
}).trim();
|
|
56
|
+
return args.includes("bin/orrery.js") || args.includes("bin/orrery ");
|
|
57
|
+
} catch {
|
|
58
|
+
// Cannot determine — treat as stale (safe default)
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read and parse the lock file.
|
|
65
|
+
* @param {string} [planId] - Optional plan ID for per-plan locks
|
|
66
|
+
* @returns {{pid: number, startedAt: string, command: string, planId?: string, worktreePath?: string}|null}
|
|
67
|
+
*/
|
|
68
|
+
function readLock(planId) {
|
|
69
|
+
const lockPath = getLockPath(planId);
|
|
70
|
+
try {
|
|
71
|
+
const content = fs.readFileSync(lockPath, "utf8");
|
|
72
|
+
return JSON.parse(content);
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Attempt to acquire the execution lock.
|
|
80
|
+
* @param {string} [planId] - Optional plan ID for per-plan locks
|
|
81
|
+
* @param {{worktreePath?: string}} [extras] - Extra fields to store in the lock
|
|
82
|
+
* @returns {{acquired: boolean, reason?: string, pid?: number}}
|
|
83
|
+
*/
|
|
84
|
+
function acquireLock(planId, extras) {
|
|
85
|
+
const lockPath = getLockPath(planId);
|
|
86
|
+
const lockData = {
|
|
87
|
+
pid: process.pid,
|
|
88
|
+
startedAt: new Date().toISOString(),
|
|
89
|
+
command: process.argv.slice(2).join(" ")
|
|
90
|
+
};
|
|
91
|
+
if (planId) lockData.planId = planId;
|
|
92
|
+
if (extras && extras.worktreePath) {
|
|
93
|
+
lockData.worktreePath = extras.worktreePath;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check for existing lock
|
|
97
|
+
const existing = readLock(planId);
|
|
98
|
+
if (existing) {
|
|
99
|
+
const running = isProcessRunning(existing.pid);
|
|
100
|
+
if (running && isOrreryProcess(existing.pid)) {
|
|
101
|
+
return {
|
|
102
|
+
acquired: false,
|
|
103
|
+
reason: `Another orrery process is running (PID ${existing.pid}, started ${existing.startedAt})`,
|
|
104
|
+
pid: existing.pid
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Stale lock — remove it
|
|
109
|
+
try {
|
|
110
|
+
fs.unlinkSync(lockPath);
|
|
111
|
+
} catch {
|
|
112
|
+
// Ignore cleanup errors
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Atomic create
|
|
117
|
+
try {
|
|
118
|
+
fs.writeFileSync(lockPath, JSON.stringify(lockData, null, 2) + "\n", {
|
|
119
|
+
flag: "wx"
|
|
120
|
+
});
|
|
121
|
+
return { acquired: true };
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (err.code === "EEXIST") {
|
|
124
|
+
// Race condition — another process acquired between check and write
|
|
125
|
+
const raceWinner = readLock(planId);
|
|
126
|
+
return {
|
|
127
|
+
acquired: false,
|
|
128
|
+
reason: `Another orrery process just started (PID ${raceWinner?.pid || "unknown"})`,
|
|
129
|
+
pid: raceWinner?.pid
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
acquired: false,
|
|
134
|
+
reason: `Failed to create lock file: ${err.message}`
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Release the execution lock (only if owned by current process).
|
|
141
|
+
* @param {string} [planId] - Optional plan ID for per-plan locks
|
|
142
|
+
*/
|
|
143
|
+
function releaseLock(planId) {
|
|
144
|
+
const lockPath = getLockPath(planId);
|
|
145
|
+
const existing = readLock(planId);
|
|
146
|
+
|
|
147
|
+
if (existing && existing.pid === process.pid) {
|
|
148
|
+
try {
|
|
149
|
+
fs.unlinkSync(lockPath);
|
|
150
|
+
} catch {
|
|
151
|
+
// Ignore cleanup errors
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Get the current lock status (read-only).
|
|
158
|
+
* @param {string} [planId] - Optional plan ID for per-plan locks
|
|
159
|
+
* @returns {{locked: boolean, pid?: number, startedAt?: string, stale: boolean, planId?: string, worktreePath?: string}}
|
|
160
|
+
*/
|
|
161
|
+
function getLockStatus(planId) {
|
|
162
|
+
const existing = readLock(planId);
|
|
163
|
+
|
|
164
|
+
if (!existing) {
|
|
165
|
+
return { locked: false, stale: false };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const running = isProcessRunning(existing.pid);
|
|
169
|
+
const isOrrery = running && isOrreryProcess(existing.pid);
|
|
170
|
+
|
|
171
|
+
const status = {
|
|
172
|
+
locked: isOrrery,
|
|
173
|
+
pid: existing.pid,
|
|
174
|
+
startedAt: existing.startedAt,
|
|
175
|
+
stale: !isOrrery
|
|
176
|
+
};
|
|
177
|
+
if (existing.planId) status.planId = existing.planId;
|
|
178
|
+
if (existing.worktreePath) status.worktreePath = existing.worktreePath;
|
|
179
|
+
return status;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* List all per-plan locks in the work directory.
|
|
184
|
+
* @returns {Array<{planId: string, pid: number, startedAt: string, active: boolean, stale: boolean, worktreePath?: string}>}
|
|
185
|
+
*/
|
|
186
|
+
function listPlanLocks() {
|
|
187
|
+
const workDir = getWorkDir();
|
|
188
|
+
const results = [];
|
|
189
|
+
|
|
190
|
+
let entries;
|
|
191
|
+
try {
|
|
192
|
+
entries = fs.readdirSync(workDir);
|
|
193
|
+
} catch {
|
|
194
|
+
return results;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
const match = entry.match(/^exec-(.+)\.lock$/);
|
|
199
|
+
if (!match) continue;
|
|
200
|
+
|
|
201
|
+
const planId = match[1];
|
|
202
|
+
const lockPath = path.join(workDir, entry);
|
|
203
|
+
let lockData;
|
|
204
|
+
try {
|
|
205
|
+
lockData = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
206
|
+
} catch {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const running = isProcessRunning(lockData.pid);
|
|
211
|
+
const active = running && isOrreryProcess(lockData.pid);
|
|
212
|
+
|
|
213
|
+
const info = {
|
|
214
|
+
planId,
|
|
215
|
+
pid: lockData.pid,
|
|
216
|
+
startedAt: lockData.startedAt,
|
|
217
|
+
active,
|
|
218
|
+
stale: !active
|
|
219
|
+
};
|
|
220
|
+
if (lockData.worktreePath) info.worktreePath = lockData.worktreePath;
|
|
221
|
+
results.push(info);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return results;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = {
|
|
228
|
+
acquireLock,
|
|
229
|
+
releaseLock,
|
|
230
|
+
getLockStatus,
|
|
231
|
+
listPlanLocks,
|
|
232
|
+
readLock,
|
|
233
|
+
isProcessRunning,
|
|
234
|
+
isOrreryProcess
|
|
235
|
+
};
|
package/lib/utils/paths.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
const crypto = require("crypto");
|
|
1
2
|
const fs = require("fs");
|
|
2
3
|
const path = require("path");
|
|
3
4
|
|
|
4
5
|
const WORK_DIR_ENV = "ORRERY_WORK_DIR";
|
|
6
|
+
const REPO_ROOT_ENV = "ORRERY_REPO_ROOT";
|
|
5
7
|
|
|
6
8
|
function ensureDir(dirPath) {
|
|
7
9
|
if (!fs.existsSync(dirPath)) {
|
|
@@ -10,12 +12,45 @@ function ensureDir(dirPath) {
|
|
|
10
12
|
return dirPath;
|
|
11
13
|
}
|
|
12
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Get the effective repo root for path resolution.
|
|
17
|
+
* Uses ORRERY_REPO_ROOT if set (when running inside a worktree), otherwise process.cwd().
|
|
18
|
+
* @returns {string} - Resolved repo root path
|
|
19
|
+
*/
|
|
20
|
+
function getEffectiveRoot() {
|
|
21
|
+
const envRoot = process.env[REPO_ROOT_ENV];
|
|
22
|
+
if (envRoot && envRoot.trim()) {
|
|
23
|
+
return path.resolve(envRoot.trim());
|
|
24
|
+
}
|
|
25
|
+
return path.resolve(process.cwd());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Generate a deterministic project identifier from the current working directory.
|
|
30
|
+
* Format: <sanitized-basename>-<hash8>
|
|
31
|
+
* When ORRERY_REPO_ROOT is set (inside a worktree), uses that instead of cwd
|
|
32
|
+
* so the project ID stays consistent.
|
|
33
|
+
* @returns {string} - Project identifier
|
|
34
|
+
*/
|
|
35
|
+
function getProjectId() {
|
|
36
|
+
const cwd = getEffectiveRoot();
|
|
37
|
+
const basename = path.basename(cwd) || "root";
|
|
38
|
+
const sanitized = basename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
39
|
+
const hash = crypto
|
|
40
|
+
.createHash("sha256")
|
|
41
|
+
.update(cwd)
|
|
42
|
+
.digest("hex")
|
|
43
|
+
.slice(0, 8);
|
|
44
|
+
return `${sanitized}-${hash}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
13
47
|
function getWorkDir() {
|
|
14
48
|
const envDir = process.env[WORK_DIR_ENV];
|
|
15
49
|
if (envDir && envDir.trim()) {
|
|
16
|
-
|
|
50
|
+
const projectScoped = path.join(envDir.trim(), getProjectId());
|
|
51
|
+
return ensureDir(projectScoped);
|
|
17
52
|
}
|
|
18
|
-
return ensureDir(path.join(
|
|
53
|
+
return ensureDir(path.join(getEffectiveRoot(), ".agent-work"));
|
|
19
54
|
}
|
|
20
55
|
|
|
21
56
|
function getPlansDir() {
|
|
@@ -34,10 +69,18 @@ function getTempDir() {
|
|
|
34
69
|
return ensureDir(path.join(getWorkDir(), "temp"));
|
|
35
70
|
}
|
|
36
71
|
|
|
72
|
+
function isWorkDirExternal() {
|
|
73
|
+
const workDir = path.resolve(getWorkDir());
|
|
74
|
+
const root = getEffectiveRoot();
|
|
75
|
+
return !workDir.startsWith(root + path.sep) && workDir !== root;
|
|
76
|
+
}
|
|
77
|
+
|
|
37
78
|
module.exports = {
|
|
38
79
|
getWorkDir,
|
|
39
80
|
getPlansDir,
|
|
40
81
|
getCompletedDir,
|
|
41
82
|
getReportsDir,
|
|
42
|
-
getTempDir
|
|
83
|
+
getTempDir,
|
|
84
|
+
getProjectId,
|
|
85
|
+
isWorkDirExternal
|
|
43
86
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@caseyharalson/orrery",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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
|
}
|