leopold-driver 0.5.0 → 0.7.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.7.0
@@ -4,5 +4,6 @@
4
4
  "summary": "Garry Tan's planning + QA skill suite that Leopold conducts (/spec, /autoplan, /plan-*-review).",
5
5
  "homepage": "https://github.com/garrytan/gstack",
6
6
  "license": "MIT",
7
- "order": 20
7
+ "order": 20,
8
+ "capabilities": ["filesystem.home", "network"]
8
9
  }
@@ -4,5 +4,6 @@
4
4
  "summary": "The autonomous orchestration harness itself (skills + Stop/PreToolUse hooks).",
5
5
  "homepage": "https://github.com/Jonhvmp/leopold",
6
6
  "license": "MIT",
7
- "order": 10
7
+ "order": 10,
8
+ "capabilities": ["settings.write", "filesystem.home"]
8
9
  }
@@ -5,6 +5,7 @@
5
5
  "homepage": "https://github.com/Jonhvmp/leopold",
6
6
  "license": "MIT",
7
7
  "order": 30,
8
+ "capabilities": ["network", "settings.write", "filesystem.home", "package.install", "process.spawn"],
8
9
  "dashboard": {
9
10
  "label": "Memory",
10
11
  "module": "~/.claude/ovmem/dashboard.py",
@@ -4,5 +4,6 @@
4
4
  "summary": "LSP-backed code intelligence (MCP). Symbol-level retrieval + editing instead of grep/whole-file reads — sharper edits, far fewer tokens. Mandatory for quality.",
5
5
  "homepage": "https://github.com/oraios/serena",
6
6
  "license": "MIT",
7
- "order": 15
7
+ "order": 15,
8
+ "capabilities": ["mcp.register", "settings.write", "package.install", "network"]
8
9
  }
@@ -112,6 +112,10 @@ case "$tool" in
112
112
  # normalize: newlines/tabs -> space, collapse runs (defeats whitespace/tab evasion).
113
113
  norm="$(printf '%s' "$cmd" | tr '\n\t' ' ' | tr -s ' ')"
114
114
 
115
+ # secret vault / master key are off-limits to the worker (secrets arrive as env vars)
116
+ matches "$norm" 'secrets\.(key|env)' && \
117
+ deny "Leopold guard: touching the secret vault/key via shell is forbidden. Secrets are pre-loaded as environment variables."
118
+
115
119
  # Opt-in deny-by-default (LEOPOLD_PARANOID=1): only a small allowlist of
116
120
  # read/build/test/lint commands passes; everything else is denied. Best-effort
117
121
  # (it keys off the first command word), kept off by default in favor of the
@@ -149,8 +153,14 @@ case "$tool" in
149
153
  deny "Leopold guard: 'git reset --hard' is forbidden in autonomous mode." ;;
150
154
  clean) matches "$norm" '(--force|(^|[[:space:]])-[a-z]*f)' && \
151
155
  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." ;;
156
+ branch)
157
+ if matches "$norm" '(^|[[:space:]])-D([[:space:]]|$)'; then
158
+ # Exception: Leopold's own throwaway run-worktree branches are deletable
159
+ # (cleanup of `leopold/run-*`); every other forced branch delete stays denied.
160
+ matches "$norm" 'leopold/run-' || \
161
+ deny "Leopold guard: 'git branch -D' is forbidden in autonomous mode."
162
+ fi ;;
163
+ worktree) : ;; # allowed: Leopold isolates a run in a dedicated git worktree
154
164
  push)
155
165
  matches "$norm" '(--force|--force-with-lease|(^|[[:space:]])-f([[:space:]]|$))' && \
156
166
  deny "Leopold guard: force-push is forbidden in autonomous mode."
@@ -178,6 +188,7 @@ case "$tool" in
178
188
  */GUARDRAILS.md) deny "Leopold guard: GUARDRAILS.md is immutable during an autonomous run." ;;
179
189
  */settings.json|*/settings.local.json) deny "Leopold guard: editing Claude Code settings is forbidden in autonomous mode." ;;
180
190
  */leopold/hooks/*|*/.leopold/state.json) deny "Leopold guard: the guardrail hooks and run state are immutable during an autonomous run." ;;
191
+ */secrets.key|*/.leopold/secrets.env) deny "Leopold guard: the secret vault and master key are off-limits. Secrets are pre-loaded as env vars." ;;
181
192
  esac
182
193
  ;;
183
194
  esac
@@ -47,6 +47,25 @@ ext_installed() { bash "$1/manage.sh" detect >/dev/null 2>&1; }
47
47
  ext_status() { bash "$1/manage.sh" status 2>/dev/null || true; }
48
48
  ext_run() { bash "$1/manage.sh" "$2"; }
49
49
 
50
+ ext_caps() { # extension.json -> space-separated capabilities (empty if none)
51
+ if command -v jq >/dev/null 2>&1; then
52
+ jq -r '(.capabilities // []) | join(" ")' "$1" 2>/dev/null
53
+ elif command -v python3 >/dev/null 2>&1; then
54
+ python3 -c "import json,sys;print(' '.join(json.load(open(sys.argv[1])).get('capabilities',[])))" "$1" 2>/dev/null
55
+ fi
56
+ }
57
+
58
+ # Show an extension's declared capabilities and require explicit consent before
59
+ # install/update grants them. No declaration -> nothing to gate, proceed.
60
+ ext_consent() { # dir -> 0 if the user consents
61
+ local caps; caps="$(ext_caps "$1/extension.json")"
62
+ [ -n "$caps" ] || return 0
63
+ printf "\n %sThis extension requests:%s %s%s%s\n" "$C_BOLD" "$C_RESET" "$C_YELLOW" "$caps" "$C_RESET"
64
+ printf " Install/update grants these. Proceed? [y/N] "
65
+ local a; read -r a || a=""
66
+ case "$a" in [yY]*) return 0 ;; *) echo " cancelled."; return 1 ;; esac
67
+ }
68
+
50
69
  pause() { printf "\n%spress Enter to continue%s " "$C_DIM" "$C_RESET"; read -r _ || true; }
51
70
 
52
71
  # ---- screens ----------------------------------------------------------------
@@ -90,6 +109,8 @@ component_menu() {
90
109
  ext_installed "$d" && st="installed${C_RESET} ${C_DIM}($(ext_status "$d"))"
91
110
  printf " %s%s%s\n status: %s%s\n\n" "$C_BOLD" "$title" "$C_RESET" "$C_GREEN" "$st"
92
111
  printf " %s%s%s\n\n" "$C_DIM" "$(_jget "$d/extension.json" summary)" "$C_RESET"
112
+ local caps; caps="$(ext_caps "$d/extension.json")"
113
+ [ -n "$caps" ] && printf " %scapabilities:%s %s\n\n" "$C_DIM" "$C_RESET" "$caps"
93
114
  local has_dash=""; [ -n "$(_jget "$d/extension.json" dashboard)" ] && has_dash=1
94
115
  if [ -n "$has_dash" ]; then
95
116
  printf " 1) Install 2) Update 3) Remove 4) Doctor w) Watch b) Back\n\n"
@@ -98,8 +119,8 @@ component_menu() {
98
119
  fi
99
120
  printf "select: "; read -r a || a="b"
100
121
  case "$a" in
101
- 1) ext_run "$d" install || echo "${C_YELLOW}install returned non-zero${C_RESET}"; pause ;;
102
- 2) ext_run "$d" update || echo "${C_YELLOW}update returned non-zero${C_RESET}"; pause ;;
122
+ 1) ext_consent "$d" && { ext_run "$d" install || echo "${C_YELLOW}install returned non-zero${C_RESET}"; }; pause ;;
123
+ 2) ext_consent "$d" && { ext_run "$d" update || echo "${C_YELLOW}update returned non-zero${C_RESET}"; }; pause ;;
103
124
  3) ext_run "$d" remove || echo "${C_YELLOW}remove returned non-zero${C_RESET}"; pause ;;
104
125
  4) ext_run "$d" doctor || true; pause ;;
105
126
  w|W) [ -n "$has_dash" ] && { ext_run "$d" watch || true; }; pause ;;
@@ -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/budget.js ADDED
@@ -0,0 +1,16 @@
1
+ // USD budget hard-stop for the SDK driver. The Claude Code CLI already reports
2
+ // `total_cost_usd` per session, so we never need a model price map: accumulate the
3
+ // real cost per item and stop the run when it crosses the cap. Two checks, like
4
+ // paperclip's evaluateCostEvent + getInvocationBlock: preventive (before an item)
5
+ // and reactive (after each item's cost lands).
6
+ /** Parse a budget value (CLI flag / env) into a positive USD number, or undefined. */
7
+ export function parseBudgetUsd(raw) {
8
+ if (raw === undefined || raw === "")
9
+ return undefined;
10
+ const n = Number(raw);
11
+ return Number.isFinite(n) && n > 0 ? n : undefined;
12
+ }
13
+ /** True when spend has reached or passed the cap. A missing cap never trips. */
14
+ export function overBudget(spentUsd, capUsd) {
15
+ return capUsd !== undefined && spentUsd >= capUsd;
16
+ }
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Load the brief and run state from .leopold/, and the driver config from env.
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { parseBudgetUsd } from "./budget.js";
4
5
  export function findLeoDir(cwd) {
5
6
  let dir = path.resolve(cwd);
6
7
  for (;;) {
@@ -43,12 +44,24 @@ export function initState(brief) {
43
44
  consecutive_failures: 0,
44
45
  max_failures: intFrom(brief.guardrails, "max_failures", 3),
45
46
  started_at: new Date().toISOString(),
47
+ orchestrator_pid: process.pid,
46
48
  };
47
49
  writeState(brief.leoDir, state);
48
50
  return state;
49
51
  }
52
+ /** Persist run state by MERGING over what's already on disk. The bash skill and
53
+ * Stop-hook write fields the driver's RunState doesn't model (session_id,
54
+ * max_subagents, …); a full overwrite would drop them (and they'd drop ours).
55
+ * Read-merge-write keeps both writers' fields intact. */
50
56
  export function writeState(leoDir, state) {
51
- fs.writeFileSync(path.join(leoDir, "state.json"), JSON.stringify(state, null, 2));
57
+ const p = path.join(leoDir, "state.json");
58
+ let onDisk = {};
59
+ try {
60
+ if (fs.existsSync(p))
61
+ onDisk = JSON.parse(fs.readFileSync(p, "utf8"));
62
+ }
63
+ catch { /* corrupt/absent — fall back to a clean write */ }
64
+ fs.writeFileSync(p, JSON.stringify({ ...onDisk, ...state }, null, 2));
52
65
  }
53
66
  export function killSwitch(leoDir) {
54
67
  return fs.existsSync(path.join(leoDir, "STOP"));
@@ -63,6 +76,11 @@ export function clearRunTokens(leoDir) {
63
76
  catch { /* ignore */ }
64
77
  }
65
78
  }
79
+ /** Read `--flag value` from argv (the value is the next token). */
80
+ function flagValue(argv, name) {
81
+ const i = argv.indexOf(name);
82
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
83
+ }
66
84
  export function loadConfig(argv) {
67
85
  return {
68
86
  conductorModel: process.env.LEOPOLD_CONDUCTOR_MODEL || undefined,
@@ -70,5 +88,7 @@ export function loadConfig(argv) {
70
88
  maxTurnsPerItem: parseInt(process.env.LEOPOLD_MAX_TURNS_PER_ITEM ?? "40", 10),
71
89
  webhookUrl: process.env.LEOPOLD_WEBHOOK || undefined,
72
90
  dryRun: argv.includes("--dry-run"),
91
+ worktree: argv.includes("--worktree") || process.env.LEOPOLD_WORKTREE === "1",
92
+ budgetUsd: parseBudgetUsd(flagValue(argv, "--budget-usd") ?? process.env.LEOPOLD_BUDGET_USD),
73
93
  };
74
94
  }
package/dist/guard.js CHANGED
@@ -6,7 +6,10 @@ import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  const GH_PR = /(^|[^\w-])gh(\s.*)?\s(pr\s+(create|merge)|release\s+create)/i;
8
8
  const PUBLISH = /(npm|pnpm|yarn)\s+publish|cargo\s+publish|twine\s+upload|pip\s+.*upload/i;
9
- const PROTECTED_PATH = /(GUARDRAILS\.md|settings\.json|settings\.local\.json|leopold\/hooks\/|\.leopold\/state\.json)/;
9
+ const PROTECTED_PATH = /(GUARDRAILS\.md|settings\.json|settings\.local\.json|leopold\/hooks\/|\.leopold\/state\.json|secrets\.key|secrets\.env)/;
10
+ // The secret vault (.leopold/secrets.env) and master key (~/.claude/leopold/secrets.key)
11
+ // are off-limits to the worker — secrets reach it only as $NAME env vars.
12
+ const SECRET_FILE = /secrets\.key|secrets\.env/;
10
13
  // git global options that consume the following token as their value.
11
14
  const GIT_VALUE_OPTS = new Set([
12
15
  "-c", "-C", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--config-env",
@@ -61,8 +64,15 @@ export function makeGuard(leoDir, onBlock) {
61
64
  onBlock(toolName, message);
62
65
  return { behavior: "deny", message };
63
66
  };
67
+ if (toolName === "Read") {
68
+ const p = String(input.file_path ?? "");
69
+ if (SECRET_FILE.test(p))
70
+ return deny("Leopold guard: reading the secret vault or master key is forbidden. Secrets are pre-loaded as $NAME env vars.");
71
+ }
64
72
  if (toolName === "Bash") {
65
73
  const c = norm(String(input.command ?? ""));
74
+ if (SECRET_FILE.test(c))
75
+ return deny("Leopold guard: touching the secret vault/key via shell is forbidden. Secrets are pre-loaded as $NAME env vars.");
66
76
  if (isRecursiveForceRm(c))
67
77
  return deny("Leopold guard: recursive+forced rm is forbidden in autonomous mode.");
68
78
  if (isFindDelete(c))
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // into the package at build time; subcommands run them.
5
5
  import { runDriver } from "./loop.js";
6
6
  import { runInstall, runMenu, runWatch, runExt, runDoctor } from "./harness.js";
7
+ import { runSecrets } from "./secrets.js";
7
8
  const sub = process.argv[2];
8
9
  const rest = process.argv.slice(3);
9
10
  function help() {
@@ -16,13 +17,17 @@ Usage:
16
17
  leopold-driver serena [install|doctor] manage an extension (also: gstack, ovmem)
17
18
  leopold-driver doctor run every extension's doctor
18
19
  leopold-driver update reinstall from this package
19
- leopold-driver run [--dry-run] conduct the .leopold run (the SDK driver)
20
+ leopold-driver run [--worktree] [--budget-usd N] [--dry-run]
21
+ conduct the .leopold run (the SDK driver)
22
+ leopold-driver secrets set|list [NAME] manage the run's encrypted secret vault
20
23
 
21
24
  Most commands run the bundled harness — no repo clone, no make. 'watch' needs Python 3.
22
25
  Newer version: npm i -g leopold-driver@latest.
23
26
 
24
27
  Conducting a run uses your existing Claude Code login (ANTHROPIC_API_KEY only in headless).
25
- Env: LEOPOLD_CONDUCTOR_MODEL, LEOPOLD_WORKER_MODEL, LEOPOLD_MAX_TURNS_PER_ITEM, LEOPOLD_WEBHOOK
28
+ --worktree isolates the run in a git worktree; --budget-usd stops it at a USD cap.
29
+ Env: LEOPOLD_CONDUCTOR_MODEL, LEOPOLD_WORKER_MODEL, LEOPOLD_MAX_TURNS_PER_ITEM, LEOPOLD_WEBHOOK,
30
+ LEOPOLD_WORKTREE, LEOPOLD_BUDGET_USD
26
31
  `);
27
32
  }
28
33
  function conduct() {
@@ -47,6 +52,8 @@ switch (sub) {
47
52
  process.exit(runExt(sub, rest));
48
53
  case "doctor":
49
54
  process.exit(runDoctor());
55
+ case "secrets":
56
+ process.exit(runSecrets(rest));
50
57
  case "--help":
51
58
  case "-h":
52
59
  case "help":
package/dist/loop.js CHANGED
@@ -1,11 +1,15 @@
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";
12
+ import { overBudget } from "./budget.js";
9
13
  export async function runDriver(cwd, argv) {
10
14
  const cfg = loadConfig(argv);
11
15
  const brief = loadBrief(cwd);
@@ -16,9 +20,30 @@ export async function runDriver(cwd, argv) {
16
20
  console.log("Next item: " + (nextOpenItem(brief.planPath) ?? "(none)"));
17
21
  return;
18
22
  }
23
+ // Preflight: reap a prior run that crashed leaving state.active === true.
24
+ reapOrphan(brief.root, brief.leoDir);
25
+ // Optional isolation: run inside a dedicated git worktree (the worker's cwd).
26
+ let worktree = null;
27
+ if (cfg.worktree) {
28
+ worktree = createWorktree(brief.root, brief.leoDir, randomUUID().slice(0, 8));
29
+ if (worktree) {
30
+ brief.worktreeRoot = worktree.path;
31
+ console.log(`Isolated in worktree: ${worktree.path} (branch ${worktree.branch})`);
32
+ }
33
+ }
19
34
  const state = initState(brief);
35
+ state.budget_usd = cfg.budgetUsd;
36
+ state.spent_usd = 0;
37
+ if (worktree) {
38
+ state.worktree_path = worktree.path;
39
+ state.worktree_branch = worktree.branch;
40
+ }
41
+ writeState(brief.leoDir, state);
20
42
  const recent = [];
21
- logEvent(brief.leoDir, { event: "run_start", conductor: cfg.conductorModel });
43
+ logEvent(brief.leoDir, {
44
+ event: "run_start", conductor: cfg.conductorModel,
45
+ worktree: worktree?.path ?? null, budget_usd: cfg.budgetUsd ?? null,
46
+ });
22
47
  console.log(`Leopold is conducting "${brief.root}". Git is locked. touch .leopold/STOP to halt.\n`);
23
48
  const stop = (reason) => {
24
49
  state.active = false;
@@ -26,6 +51,8 @@ export async function runDriver(cwd, argv) {
26
51
  writeState(brief.leoDir, state);
27
52
  clearRunTokens(brief.leoDir);
28
53
  logEvent(brief.leoDir, { event: "stop", reason });
54
+ if (worktree)
55
+ cleanupWorktree(brief.root, worktree, brief.leoDir);
29
56
  };
30
57
  for (;;) {
31
58
  if (killSwitch(brief.leoDir)) {
@@ -33,6 +60,11 @@ export async function runDriver(cwd, argv) {
33
60
  await notify(brief.leoDir, cfg.webhookUrl, "Leopold stopped", "Kill switch hit.");
34
61
  return;
35
62
  }
63
+ if (overBudget(state.spent_usd ?? 0, cfg.budgetUsd)) {
64
+ stop("budget_exceeded");
65
+ await notify(brief.leoDir, cfg.webhookUrl, "Leopold stopped", `Budget reached: $${(state.spent_usd ?? 0).toFixed(2)} of $${cfg.budgetUsd?.toFixed(2)}. Work so far is staged for your review.`);
66
+ return;
67
+ }
36
68
  if (state.iteration >= state.max_iterations) {
37
69
  stop("iteration_budget");
38
70
  await notify(brief.leoDir, cfg.webhookUrl, "Leopold stopped", "Iteration budget reached.");
@@ -63,6 +95,10 @@ export async function runDriver(cwd, argv) {
63
95
  item,
64
96
  workerPrompt,
65
97
  onBlock: (tool, reason) => logEvent(brief.leoDir, { event: "guard_block", tool, reason }),
98
+ onCost: (usd) => {
99
+ state.spent_usd = (state.spent_usd ?? 0) + usd;
100
+ logEvent(brief.leoDir, { event: "cost", item, usd, spent_usd: state.spent_usd });
101
+ },
66
102
  onTurn: async (status) => {
67
103
  logEvent(brief.leoDir, { event: "worker_turn", kind: status.kind, item: status.item || item });
68
104
  const verdict = await decide(cfg, brief, status, recent.slice(-5).join("\n"));
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
+ }
@@ -0,0 +1,142 @@
1
+ // Encrypted secret vault for a run. Secrets the work needs are injected into the
2
+ // worker as environment variables (resolved at run time), so they reach the worker's
3
+ // Bash tool as $NAME but never land in the prompt/transcript the model sees.
4
+ //
5
+ // At rest: AES-256-GCM. The 32-byte master key lives at ~/.claude/leopold/secrets.key
6
+ // (mode 0600, generated on demand); the vault is .leopold/secrets.env (the encrypted
7
+ // blob). Mirrors paperclip's local-encrypted-provider, file edition — no DB.
8
+ import crypto from "node:crypto";
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { findLeoDir } from "./config.js";
13
+ const ALGO = "aes-256-gcm";
14
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
15
+ export function keyPath() {
16
+ const base = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
17
+ return path.join(base, "leopold", "secrets.key");
18
+ }
19
+ export function vaultPath(leoDir) {
20
+ return path.join(leoDir, "secrets.env");
21
+ }
22
+ /** Paths the guard must protect from the worker (the vault and the master key). */
23
+ export function secretFilePaths(leoDir) {
24
+ return [vaultPath(leoDir), keyPath()];
25
+ }
26
+ function loadOrCreateKey() {
27
+ const kp = keyPath();
28
+ if (fs.existsSync(kp))
29
+ return Buffer.from(fs.readFileSync(kp, "utf8").trim(), "base64");
30
+ const key = crypto.randomBytes(32);
31
+ fs.mkdirSync(path.dirname(kp), { recursive: true });
32
+ fs.writeFileSync(kp, key.toString("base64"), { mode: 0o600 });
33
+ try {
34
+ fs.chmodSync(kp, 0o600);
35
+ }
36
+ catch { /* best effort on platforms without chmod */ }
37
+ return key;
38
+ }
39
+ function encrypt(key, plaintext) {
40
+ const iv = crypto.randomBytes(12);
41
+ const cipher = crypto.createCipheriv(ALGO, key, iv);
42
+ const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
43
+ return JSON.stringify({
44
+ v: 1, iv: iv.toString("base64"),
45
+ tag: cipher.getAuthTag().toString("base64"), data: data.toString("base64"),
46
+ });
47
+ }
48
+ function decrypt(key, blob) {
49
+ const o = JSON.parse(blob);
50
+ const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(o.iv, "base64"));
51
+ decipher.setAuthTag(Buffer.from(o.tag, "base64"));
52
+ return Buffer.concat([decipher.update(Buffer.from(o.data, "base64")), decipher.final()]).toString("utf8");
53
+ }
54
+ function readVault(leoDir) {
55
+ const vp = vaultPath(leoDir);
56
+ if (!fs.existsSync(vp))
57
+ return {};
58
+ try {
59
+ return JSON.parse(decrypt(loadOrCreateKey(), fs.readFileSync(vp, "utf8")));
60
+ }
61
+ catch {
62
+ return {}; // wrong/rotated key or corrupt vault — fail closed (no secrets)
63
+ }
64
+ }
65
+ function writeVault(leoDir, secrets) {
66
+ fs.writeFileSync(vaultPath(leoDir), encrypt(loadOrCreateKey(), JSON.stringify(secrets)), { mode: 0o600 });
67
+ }
68
+ export function isValidName(name) {
69
+ return NAME_RE.test(name);
70
+ }
71
+ export function setSecret(leoDir, name, value) {
72
+ if (!isValidName(name))
73
+ throw new Error(`invalid secret name: ${name}`);
74
+ const s = readVault(leoDir);
75
+ s[name] = value;
76
+ writeVault(leoDir, s);
77
+ }
78
+ /** Decrypt the vault to {NAME: value} for env injection. */
79
+ export function loadSecrets(leoDir) {
80
+ return readVault(leoDir);
81
+ }
82
+ /** Set the run's secrets into process.env for the duration of one item; returns a
83
+ * restore() that puts the previous environment back (so secrets don't outlive the item). */
84
+ export function applySecretsEnv(leoDir) {
85
+ const secrets = loadSecrets(leoDir);
86
+ const prev = [];
87
+ for (const [k, v] of Object.entries(secrets)) {
88
+ prev.push([k, process.env[k]]);
89
+ process.env[k] = v;
90
+ }
91
+ return {
92
+ restore() {
93
+ for (const [k, p] of prev) {
94
+ if (p === undefined)
95
+ delete process.env[k];
96
+ else
97
+ process.env[k] = p;
98
+ }
99
+ },
100
+ };
101
+ }
102
+ export function listSecretNames(leoDir) {
103
+ return Object.keys(readVault(leoDir)).sort();
104
+ }
105
+ /** `leopold-driver secrets set NAME | list` — the value for `set` is read from
106
+ * stdin so it never appears in shell history. */
107
+ export function runSecrets(argv) {
108
+ const sub = argv[0];
109
+ let leoDir;
110
+ try {
111
+ leoDir = findLeoDir(process.cwd());
112
+ }
113
+ catch {
114
+ console.error("leopold-driver secrets: no .leopold/ here. Run /leopold-brief first.");
115
+ return 1;
116
+ }
117
+ if (sub === "list") {
118
+ const names = listSecretNames(leoDir);
119
+ process.stdout.write(names.length ? names.join("\n") + "\n" : "(no secrets)\n");
120
+ return 0;
121
+ }
122
+ if (sub === "set") {
123
+ const name = argv[1];
124
+ if (!name || !isValidName(name)) {
125
+ console.error("usage: leopold-driver secrets set NAME (NAME = a valid env var name)");
126
+ return 2;
127
+ }
128
+ let value;
129
+ try {
130
+ value = fs.readFileSync(0, "utf8").replace(/\r?\n$/, ""); // stdin, strip one trailing newline
131
+ }
132
+ catch {
133
+ console.error("could not read the secret value from stdin");
134
+ return 1;
135
+ }
136
+ setSecret(leoDir, name, value);
137
+ console.log(`secret '${name}' stored (encrypted) in .leopold/secrets.env`);
138
+ return 0;
139
+ }
140
+ console.error("usage: leopold-driver secrets set NAME | list");
141
+ return 2;
142
+ }
package/dist/worker.js CHANGED
@@ -8,10 +8,12 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
8
8
  import { InputChannel } from "./channel.js";
9
9
  import { parseStatus, isTurnComplete } from "./protocol.js";
10
10
  import { makeGuard } from "./guard.js";
11
+ import { applySecretsEnv } from "./secrets.js";
11
12
  const WORKER_APPEND = `You are a Leopold worker, conducted by an autonomous orchestrator. No human is watching live. Rules for this session:
12
13
  - Do NOT ask the human anything. Decide reversible or charter-clear calls yourself and keep going.
13
14
  - Spawned mode: if you invoke gstack skills, auto-pick the recommended option; never prompt.
14
15
  - git commit/push/publish are LOCKED by a guard. Never attempt them. Stage with "git add" and report instead.
16
+ - Secrets you may need are pre-loaded as environment variables; use them as $NAME, and never ask for, echo, or print their values.
15
17
  - Close EVERY turn with a fenced status block, then stop and wait for the conductor's reply:
16
18
 
17
19
  \`\`\`leopold-status
@@ -29,10 +31,15 @@ export async function runItem(opts) {
29
31
  const channel = new InputChannel();
30
32
  channel.push(workerPrompt);
31
33
  const guard = makeGuard(brief.leoDir, onBlock);
34
+ // Inject the run's secrets as env vars for this item: they reach the worker's Bash
35
+ // tool as $NAME but never enter the prompt. Restored after the loop (runs are
36
+ // sequential, so there is no env overlap between items).
37
+ const { restore: restoreSecrets } = applySecretsEnv(brief.leoDir);
32
38
  const q = query({
33
39
  prompt: channel,
34
40
  options: {
35
- cwd: brief.root,
41
+ cwd: brief.worktreeRoot ?? brief.root,
42
+ env: { ...process.env },
36
43
  maxTurns: cfg.maxTurnsPerItem,
37
44
  permissionMode: "default",
38
45
  canUseTool: guard,
@@ -61,6 +68,10 @@ export async function runItem(opts) {
61
68
  }
62
69
  }
63
70
  else if (msg.type === "result") {
71
+ // The CLI reports the item's real cost here — accumulate it for the budget.
72
+ const cost = msg.total_cost_usd;
73
+ if (typeof cost === "number" && Number.isFinite(cost))
74
+ opts.onCost?.(cost);
64
75
  // Session ended (channel closed, or the worker stopped on its own). Flush
65
76
  // whatever we have so the conductor can make a final call.
66
77
  if (turnText.trim()) {
@@ -72,4 +83,5 @@ export async function runItem(opts) {
72
83
  break;
73
84
  }
74
85
  }
86
+ restoreSecrets();
75
87
  }
@@ -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.7.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 test/budget.test.ts test/secrets.test.ts",
41
41
  "dev": "tsx src/index.ts",
42
42
  "start": "node dist/index.js",
43
43
  "prepublishOnly": "npm run build"