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 CHANGED
@@ -1 +1 @@
1
- 0.5.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) matches "$norm" '(^|[[:space:]])-D([[:space:]]|$)' && \
153
- deny "Leopold guard: 'git branch -D' is forbidden in autonomous mode." ;;
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
- Otherwise wait for the other run, or `/leopold-stop` it first. A run idle for
69
- over 10 minutes is treated as stale and may be taken over.
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
- fs.writeFileSync(path.join(leoDir, "state.json"), JSON.stringify(state, null, 2));
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
@@ -32,7 +32,7 @@ export async function runItem(opts) {
32
32
  const q = query({
33
33
  prompt: channel,
34
34
  options: {
35
- cwd: brief.root,
35
+ cwd: brief.worktreeRoot ?? brief.root,
36
36
  maxTurns: cfg.maxTurnsPerItem,
37
37
  permissionMode: "default",
38
38
  canUseTool: guard,
@@ -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.5.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 --experimental-strip-types --test test/protocol.test.ts test/guard.test.ts",
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"