leopold-driver 0.5.0 → 0.6.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/assets/VERSION +1 -1
- package/assets/hooks/guard-irreversible.sh +8 -2
- package/assets/skills/leopold-run/SKILL.md +5 -2
- package/dist/config.js +14 -1
- package/dist/loop.js +22 -1
- package/dist/reaper.js +59 -0
- package/dist/worker.js +1 -1
- package/dist/worktree.js +87 -0
- package/package.json +2 -2
package/assets/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.6.0
|
|
@@ -149,8 +149,14 @@ case "$tool" in
|
|
|
149
149
|
deny "Leopold guard: 'git reset --hard' is forbidden in autonomous mode." ;;
|
|
150
150
|
clean) matches "$norm" '(--force|(^|[[:space:]])-[a-z]*f)' && \
|
|
151
151
|
deny "Leopold guard: 'git clean -f' is forbidden in autonomous mode." ;;
|
|
152
|
-
branch)
|
|
153
|
-
|
|
152
|
+
branch)
|
|
153
|
+
if matches "$norm" '(^|[[:space:]])-D([[:space:]]|$)'; then
|
|
154
|
+
# Exception: Leopold's own throwaway run-worktree branches are deletable
|
|
155
|
+
# (cleanup of `leopold/run-*`); every other forced branch delete stays denied.
|
|
156
|
+
matches "$norm" 'leopold/run-' || \
|
|
157
|
+
deny "Leopold guard: 'git branch -D' is forbidden in autonomous mode."
|
|
158
|
+
fi ;;
|
|
159
|
+
worktree) : ;; # allowed: Leopold isolates a run in a dedicated git worktree
|
|
154
160
|
push)
|
|
155
161
|
matches "$norm" '(--force|--force-with-lease|(^|[[:space:]])-f([[:space:]]|$))' && \
|
|
156
162
|
deny "Leopold guard: force-push is forbidden in autonomous mode."
|
|
@@ -65,8 +65,11 @@ in **parallel**, use a separate git worktree (one run per worktree):
|
|
|
65
65
|
|
|
66
66
|
git worktree add ../<proj>-leopold-2 && cd ../<proj>-leopold-2
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
The SDK driver automates this: `leopold-driver run --worktree` isolates the run in
|
|
69
|
+
its own `leopold/run-<id>` worktree and, on the next start, reaps an orphaned prior
|
|
70
|
+
run (a dead process that left `active:true`) and prunes its leftover worktree.
|
|
71
|
+
Otherwise wait for the other run, or `/leopold-stop` it first. A run idle for over
|
|
72
|
+
10 minutes is treated as stale and may be taken over.
|
|
70
73
|
|
|
71
74
|
## Step 1 — Activate the run
|
|
72
75
|
|
package/dist/config.js
CHANGED
|
@@ -43,12 +43,24 @@ export function initState(brief) {
|
|
|
43
43
|
consecutive_failures: 0,
|
|
44
44
|
max_failures: intFrom(brief.guardrails, "max_failures", 3),
|
|
45
45
|
started_at: new Date().toISOString(),
|
|
46
|
+
orchestrator_pid: process.pid,
|
|
46
47
|
};
|
|
47
48
|
writeState(brief.leoDir, state);
|
|
48
49
|
return state;
|
|
49
50
|
}
|
|
51
|
+
/** Persist run state by MERGING over what's already on disk. The bash skill and
|
|
52
|
+
* Stop-hook write fields the driver's RunState doesn't model (session_id,
|
|
53
|
+
* max_subagents, …); a full overwrite would drop them (and they'd drop ours).
|
|
54
|
+
* Read-merge-write keeps both writers' fields intact. */
|
|
50
55
|
export function writeState(leoDir, state) {
|
|
51
|
-
|
|
56
|
+
const p = path.join(leoDir, "state.json");
|
|
57
|
+
let onDisk = {};
|
|
58
|
+
try {
|
|
59
|
+
if (fs.existsSync(p))
|
|
60
|
+
onDisk = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
61
|
+
}
|
|
62
|
+
catch { /* corrupt/absent — fall back to a clean write */ }
|
|
63
|
+
fs.writeFileSync(p, JSON.stringify({ ...onDisk, ...state }, null, 2));
|
|
52
64
|
}
|
|
53
65
|
export function killSwitch(leoDir) {
|
|
54
66
|
return fs.existsSync(path.join(leoDir, "STOP"));
|
|
@@ -70,5 +82,6 @@ export function loadConfig(argv) {
|
|
|
70
82
|
maxTurnsPerItem: parseInt(process.env.LEOPOLD_MAX_TURNS_PER_ITEM ?? "40", 10),
|
|
71
83
|
webhookUrl: process.env.LEOPOLD_WEBHOOK || undefined,
|
|
72
84
|
dryRun: argv.includes("--dry-run"),
|
|
85
|
+
worktree: argv.includes("--worktree") || process.env.LEOPOLD_WORKTREE === "1",
|
|
73
86
|
};
|
|
74
87
|
}
|
package/dist/loop.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// The orchestration loop: the conductor burns down the plan, one fresh worker
|
|
2
2
|
// per item, deciding from the charter, with git locked, until the plan is done
|
|
3
3
|
// or a stop condition fires. It notifies the human on completion or escalation.
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { loadBrief, initState, writeState, killSwitch, loadConfig, clearRunTokens } from "./config.js";
|
|
5
6
|
import { runItem } from "./worker.js";
|
|
6
7
|
import { decide } from "./conductor.js";
|
|
7
8
|
import { logEvent, logDecision, markItemDone, openItems, nextOpenItem } from "./log.js";
|
|
8
9
|
import { notify } from "./notify.js";
|
|
10
|
+
import { createWorktree, cleanupWorktree } from "./worktree.js";
|
|
11
|
+
import { reapOrphan } from "./reaper.js";
|
|
9
12
|
export async function runDriver(cwd, argv) {
|
|
10
13
|
const cfg = loadConfig(argv);
|
|
11
14
|
const brief = loadBrief(cwd);
|
|
@@ -16,9 +19,25 @@ export async function runDriver(cwd, argv) {
|
|
|
16
19
|
console.log("Next item: " + (nextOpenItem(brief.planPath) ?? "(none)"));
|
|
17
20
|
return;
|
|
18
21
|
}
|
|
22
|
+
// Preflight: reap a prior run that crashed leaving state.active === true.
|
|
23
|
+
reapOrphan(brief.root, brief.leoDir);
|
|
24
|
+
// Optional isolation: run inside a dedicated git worktree (the worker's cwd).
|
|
25
|
+
let worktree = null;
|
|
26
|
+
if (cfg.worktree) {
|
|
27
|
+
worktree = createWorktree(brief.root, brief.leoDir, randomUUID().slice(0, 8));
|
|
28
|
+
if (worktree) {
|
|
29
|
+
brief.worktreeRoot = worktree.path;
|
|
30
|
+
console.log(`Isolated in worktree: ${worktree.path} (branch ${worktree.branch})`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
19
33
|
const state = initState(brief);
|
|
34
|
+
if (worktree) {
|
|
35
|
+
state.worktree_path = worktree.path;
|
|
36
|
+
state.worktree_branch = worktree.branch;
|
|
37
|
+
writeState(brief.leoDir, state);
|
|
38
|
+
}
|
|
20
39
|
const recent = [];
|
|
21
|
-
logEvent(brief.leoDir, { event: "run_start", conductor: cfg.conductorModel });
|
|
40
|
+
logEvent(brief.leoDir, { event: "run_start", conductor: cfg.conductorModel, worktree: worktree?.path ?? null });
|
|
22
41
|
console.log(`Leopold is conducting "${brief.root}". Git is locked. touch .leopold/STOP to halt.\n`);
|
|
23
42
|
const stop = (reason) => {
|
|
24
43
|
state.active = false;
|
|
@@ -26,6 +45,8 @@ export async function runDriver(cwd, argv) {
|
|
|
26
45
|
writeState(brief.leoDir, state);
|
|
27
46
|
clearRunTokens(brief.leoDir);
|
|
28
47
|
logEvent(brief.leoDir, { event: "stop", reason });
|
|
48
|
+
if (worktree)
|
|
49
|
+
cleanupWorktree(brief.root, worktree, brief.leoDir);
|
|
29
50
|
};
|
|
30
51
|
for (;;) {
|
|
31
52
|
if (killSwitch(brief.leoDir)) {
|
package/dist/reaper.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Orphan reaper: detect a prior run that crashed leaving state.active === true,
|
|
2
|
+
// using a PID-liveness probe (the file's "active" flag is not proof of life).
|
|
3
|
+
// Ported from paperclip's isZombieRun/reapOrphanedRuns, file-state edition.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { logEvent } from "./log.js";
|
|
7
|
+
import { clearRunTokens } from "./config.js";
|
|
8
|
+
import { cleanupWorktree } from "./worktree.js";
|
|
9
|
+
/** True if a process with this pid is alive. `process.kill(pid, 0)` sends no
|
|
10
|
+
* signal: it throws ESRCH if the pid is dead, EPERM if it's alive but not ours. */
|
|
11
|
+
export function isProcessAlive(pid) {
|
|
12
|
+
try {
|
|
13
|
+
process.kill(pid, 0);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
return e.code === "EPERM";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Best-effort preflight before a new run starts: if the previous run is still
|
|
21
|
+
* flagged active but its orchestrator pid is dead, declare it orphaned — flip
|
|
22
|
+
* inactive, log, clean its (clean) worktree, and clear stale run tokens.
|
|
23
|
+
*
|
|
24
|
+
* Conservative on purpose: we only reap when there IS a pid AND it is dead.
|
|
25
|
+
* An active state with no pid (e.g. a live in-session /leopold-run, which does
|
|
26
|
+
* not persist orchestrator_pid) is left untouched — never clobber a live run. */
|
|
27
|
+
export function reapOrphan(repoRoot, leoDir) {
|
|
28
|
+
const p = path.join(leoDir, "state.json");
|
|
29
|
+
if (!fs.existsSync(p))
|
|
30
|
+
return;
|
|
31
|
+
let prev;
|
|
32
|
+
try {
|
|
33
|
+
prev = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (prev.active !== true)
|
|
39
|
+
return;
|
|
40
|
+
const pid = typeof prev.orchestrator_pid === "number" ? prev.orchestrator_pid : undefined;
|
|
41
|
+
if (pid === undefined || isProcessAlive(pid))
|
|
42
|
+
return;
|
|
43
|
+
prev.active = false;
|
|
44
|
+
prev.stopped_reason = "reaped_orphan";
|
|
45
|
+
try {
|
|
46
|
+
fs.writeFileSync(p, JSON.stringify(prev, null, 2));
|
|
47
|
+
}
|
|
48
|
+
catch { /* ignore */ }
|
|
49
|
+
logEvent(leoDir, {
|
|
50
|
+
event: "run_reaped",
|
|
51
|
+
prior_pid: pid,
|
|
52
|
+
prior_started: prev.started_at ?? null,
|
|
53
|
+
});
|
|
54
|
+
const wtPath = typeof prev.worktree_path === "string" ? prev.worktree_path : undefined;
|
|
55
|
+
const wtBranch = typeof prev.worktree_branch === "string" ? prev.worktree_branch : undefined;
|
|
56
|
+
if (wtPath && wtBranch)
|
|
57
|
+
cleanupWorktree(repoRoot, { path: wtPath, branch: wtBranch }, leoDir);
|
|
58
|
+
clearRunTokens(leoDir);
|
|
59
|
+
}
|
package/dist/worker.js
CHANGED
package/dist/worktree.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Git worktree isolation for a run (Path A / SDK driver). The orchestrator runs
|
|
2
|
+
// git directly here — NOT through the worker's Bash tool — so the worker's git
|
|
3
|
+
// lock is unaffected (the lock constrains the worker, not the orchestrator).
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { logEvent } from "./log.js";
|
|
8
|
+
function git(cwd, args) {
|
|
9
|
+
return execFileSync("git", args, {
|
|
10
|
+
cwd,
|
|
11
|
+
encoding: "utf8",
|
|
12
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
13
|
+
}).trim();
|
|
14
|
+
}
|
|
15
|
+
export function isGitRepo(dir) {
|
|
16
|
+
try {
|
|
17
|
+
return git(dir, ["rev-parse", "--is-inside-work-tree"]) === "true";
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** A worktree is "dirty" if it has staged, unstaged, or untracked changes.
|
|
24
|
+
* Git is locked, so a run stages (git add) but never commits — dirty means
|
|
25
|
+
* "there is work here the user should review", which we never destroy. */
|
|
26
|
+
export function isDirty(worktreePath) {
|
|
27
|
+
try {
|
|
28
|
+
return git(worktreePath, ["status", "--porcelain"]).length > 0;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Provision an isolated worktree on a throwaway branch `leopold/run-<id>`,
|
|
35
|
+
* as a sibling of the repo (matches the manual flow in docs/guardrails.md).
|
|
36
|
+
* Returns null if the project is not a git repo — caller falls back to root. */
|
|
37
|
+
export function createWorktree(repoRoot, leoDir, runId) {
|
|
38
|
+
if (!isGitRepo(repoRoot)) {
|
|
39
|
+
logEvent(leoDir, { event: "worktree_skipped", reason: "not_a_git_repo" });
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const branch = `leopold/run-${runId}`;
|
|
43
|
+
// sibling of the repo, one level (matches `../<proj>-leopold-2` in the docs)
|
|
44
|
+
const dir = path.join(path.dirname(repoRoot), `${path.basename(repoRoot)}-leopold-${runId}`);
|
|
45
|
+
try {
|
|
46
|
+
git(repoRoot, ["worktree", "add", "-b", branch, dir, "HEAD"]);
|
|
47
|
+
logEvent(leoDir, { event: "worktree_created", path: dir, branch });
|
|
48
|
+
return { path: dir, branch };
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
logEvent(leoDir, { event: "worktree_create_failed", error: String(e.message ?? e) });
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Remove a run's worktree — but ONLY if it's clean. A worktree with work is
|
|
56
|
+
* preserved (and logged) for the user to review/merge, since git is locked and
|
|
57
|
+
* the run never committed. A clean worktree (or one already gone) is pruned. */
|
|
58
|
+
export function cleanupWorktree(repoRoot, wt, leoDir) {
|
|
59
|
+
if (!fs.existsSync(wt.path)) {
|
|
60
|
+
try {
|
|
61
|
+
git(repoRoot, ["worktree", "prune"]);
|
|
62
|
+
}
|
|
63
|
+
catch { /* ignore */ }
|
|
64
|
+
try {
|
|
65
|
+
git(repoRoot, ["branch", "-D", wt.branch]);
|
|
66
|
+
}
|
|
67
|
+
catch { /* ignore */ }
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (isDirty(wt.path)) {
|
|
71
|
+
logEvent(leoDir, {
|
|
72
|
+
event: "worktree_preserved",
|
|
73
|
+
path: wt.path,
|
|
74
|
+
branch: wt.branch,
|
|
75
|
+
reason: "uncommitted_changes",
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
git(repoRoot, ["worktree", "remove", "--force", wt.path]);
|
|
81
|
+
git(repoRoot, ["branch", "-D", wt.branch]);
|
|
82
|
+
logEvent(leoDir, { event: "worktree_removed", path: wt.path, branch: wt.branch });
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
logEvent(leoDir, { event: "worktree_remove_failed", path: wt.path, error: String(e.message ?? e) });
|
|
86
|
+
}
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leopold-driver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Leopold SDK driver: a persistent conductor that orchestrates fresh Claude Code workers per task, decides from your charter, and notifies you. Uses your Claude Code auth. Git stays locked.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "tsc -p tsconfig.json && node scripts/copy-runtime.mjs",
|
|
39
39
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
40
|
-
"test": "node --
|
|
40
|
+
"test": "node --import tsx --test test/protocol.test.ts test/guard.test.ts test/worktree.test.ts test/reaper.test.ts",
|
|
41
41
|
"dev": "tsx src/index.ts",
|
|
42
42
|
"start": "node dist/index.js",
|
|
43
43
|
"prepublishOnly": "npm run build"
|