leopold-driver 0.1.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/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # @leopold/driver
2
+
3
+ The external conductor. Where the in-session engine (skills + hooks) keeps one
4
+ Claude Code session going, the driver is a persistent process that **conducts
5
+ fresh Claude Code workers**, one per plan item, and holds your mission and
6
+ charter across the whole run. It is the tier that turns Leopold from "a better
7
+ loop" into a real harness you brief and walk away from.
8
+
9
+ ## What it does differently
10
+
11
+ - **Persistent conductor, fresh workers.** The driver (the "you") remembers the
12
+ mission, charter, and every decision for the entire run. Each plan item gets a
13
+ brand-new worker with clean context, so quality does not rot as the run grows.
14
+ This is the best of both worlds: Ralph's fresh-context-per-task plus a
15
+ conductor that Ralph does not have.
16
+ - **Real message exchange.** The worker closes each turn with a structured
17
+ status block. The conductor reads it, decides from your charter, and either
18
+ pushes a concrete instruction back into the same worker session or ends the
19
+ item. That is a genuine back-and-forth between two agents, not a blind
20
+ "continue".
21
+ - **It decides, then notifies.** Reversible or charter-clear forks are decided
22
+ and logged. Only a genuinely irreversible-and-ambiguous fork stops the run and
23
+ pings you. You get a notification on completion or escalation, not a screen to
24
+ babysit.
25
+ - **Git stays locked.** The worker runs under a `canUseTool` guard with the same
26
+ policy as the in-session hook: commit, push, force-push, `rm -rf`, PR/release,
27
+ and publish are blocked unless you drop an explicit opt-in token.
28
+
29
+ ## Auth: it uses YOUR Claude Code, not a separate API key
30
+
31
+ Both the worker and the conductor run through the Claude Agent SDK, which uses
32
+ your existing Claude Code login (your subscription). There is **no separate API
33
+ key and no split billing**. `ANTHROPIC_API_KEY` is only needed in a headless
34
+ environment with no Claude Code auth configured.
35
+
36
+ ## The conductor <-> worker protocol
37
+
38
+ ```
39
+ worker -> works on the item, closes the turn with:
40
+ ```leopold-status
41
+ STATUS: done | needs-decision | blocked
42
+ ITEM / SUMMARY / DECISION-NEEDED / NEXT / EVIDENCE
43
+ ```
44
+ driver -> parses the status, the conductor (your charter as system prompt)
45
+ returns a verdict: answer | finish | escalate
46
+ - answer -> push a concrete instruction into the worker session
47
+ - finish -> mark the plan item done, start the next item (fresh worker)
48
+ - escalate -> notify you, pause the run
49
+ ```
50
+
51
+ ## Install and build
52
+
53
+ ```bash
54
+ cd packages/driver
55
+ npm install
56
+ npm run build
57
+ ```
58
+
59
+ ## Usage
60
+
61
+ From any project that already has a `.leopold/` brief (written by `/leopold-brief`),
62
+ and with Claude Code logged in:
63
+
64
+ ```bash
65
+ node /path/to/leopold/packages/driver/dist/index.js # run
66
+ node /path/to/leopold/packages/driver/dist/index.js --dry-run # load brief, show the plan, do nothing
67
+ ```
68
+
69
+ ### Environment
70
+
71
+ | Var | Default | Purpose |
72
+ |---|---|---|
73
+ | `LEOPOLD_CONDUCTOR_MODEL` | your Claude Code default | the conductor's model |
74
+ | `LEOPOLD_WORKER_MODEL` | your Claude Code default | the worker's model |
75
+ | `LEOPOLD_MAX_TURNS_PER_ITEM` | `40` | worker turn budget per item |
76
+ | `LEOPOLD_WEBHOOK` | none | URL for JSON POST notifications (Slack/Discord/etc.) |
77
+ | `ANTHROPIC_API_KEY` | none | only for headless environments without Claude Code auth |
78
+
79
+ Stop conditions (plan complete, kill switch via `.leopold/STOP`, repeated
80
+ failures, iteration budget) come from `.leopold/GUARDRAILS.md`, same as the
81
+ in-session engine.
82
+
83
+ ## Status and known limits
84
+
85
+ Alpha. Verified: compiles against `@anthropic-ai/claude-agent-sdk`, the CLI and
86
+ dry-run work, the status parser and git guard are tested. Not yet built: a
87
+ watchdog for a worker that ends a turn without emitting a status block (today the
88
+ worker is strongly instructed to always emit one), parallel multi-worker waves,
89
+ and the live dashboard. See the repo roadmap.
@@ -0,0 +1,49 @@
1
+ // A driver-controlled async iterable of worker input messages.
2
+ //
3
+ // The Claude Agent SDK consumes the worker's prompt as an AsyncIterable of user
4
+ // messages (streaming input mode). We drive it by hand: the conductor pushes a
5
+ // reply only after it has read and judged the worker's previous turn, which is
6
+ // what makes the exchange a real back-and-forth instead of a blind loop.
7
+ export class InputChannel {
8
+ queue = [];
9
+ waiting = [];
10
+ closed = false;
11
+ wrap(text) {
12
+ return {
13
+ type: "user",
14
+ session_id: "",
15
+ parent_tool_use_id: null,
16
+ message: { role: "user", content: [{ type: "text", text }] },
17
+ };
18
+ }
19
+ /** Send one user message into the worker session. */
20
+ push(text) {
21
+ if (this.closed)
22
+ return;
23
+ const msg = this.wrap(text);
24
+ const w = this.waiting.shift();
25
+ if (w)
26
+ w({ value: msg, done: false });
27
+ else
28
+ this.queue.push(msg);
29
+ }
30
+ /** Signal end of input; the worker session winds down. */
31
+ close() {
32
+ this.closed = true;
33
+ let w;
34
+ while ((w = this.waiting.shift()))
35
+ w({ value: undefined, done: true });
36
+ }
37
+ [Symbol.asyncIterator]() {
38
+ return {
39
+ next: () => {
40
+ const queued = this.queue.shift();
41
+ if (queued)
42
+ return Promise.resolve({ value: queued, done: false });
43
+ if (this.closed)
44
+ return Promise.resolve({ value: undefined, done: true });
45
+ return new Promise((resolve) => this.waiting.push(resolve));
46
+ },
47
+ };
48
+ }
49
+ }
@@ -0,0 +1,88 @@
1
+ // The conductor: the persistent "you". It reads a worker's status and decides
2
+ // what to tell it, grounded in the mission and charter.
3
+ //
4
+ // It runs through the SAME Agent SDK as the worker, so it uses your existing
5
+ // Claude Code auth (your subscription). No separate API key, no split billing.
6
+ // It is a one-shot reasoning call with NO tools that returns a JSON verdict.
7
+ import { query } from "@anthropic-ai/claude-agent-sdk";
8
+ function system(brief) {
9
+ return `You are Leopold, the conductor. You sit in the user's seat and make the calls they would make so a Claude Code worker can keep building without stopping to ask a human. You are NOT the coder; you read the worker's status and decide what to tell it.
10
+
11
+ Protocol for every worker status:
12
+ - Classify the fork: reversibility (cheap to undo = reversible) and charter clarity (does the charter/mission answer it?).
13
+ - Reversible OR charter-clear -> action "answer": give a crisp, concrete instruction, and fill logTitle/logWhy/reversal so it is auditable.
14
+ - Irreversible AND ambiguous, OR a charter contradiction, OR a sign the mission premise is wrong -> action "escalate"; do NOT guess. Set escalationReason.
15
+ - Worker STATUS done -> action "finish" (item complete, no reply). If evidence looks weak (no tests/build run), instead "answer" telling it to verify first.
16
+ - Worker STATUS blocked -> "answer" with a remediation if the charter/principles make it clear, else "escalate".
17
+
18
+ When the charter is silent, decide with these ordered principles: completeness, boil-lakes-not-oceans, pragmatic, DRY, explicit-over-clever, bias-toward-action. Keep replies short and actionable. git commit/push/publish are locked; never instruct the worker to commit or push.
19
+
20
+ Respond with ONLY a single JSON object, no prose, no code fence, shaped exactly:
21
+ {"action":"answer|finish|escalate","classification":"reversible|irreversible|n/a","charterBasis":"...","reply":"...","logTitle":"...","logWhy":"...","reversal":"...","escalationReason":"..."}
22
+ Omit reply for finish/escalate. Omit logTitle for mechanical calls. Omit escalationReason unless escalating.
23
+
24
+ === MISSION ===
25
+ ${brief.mission}
26
+
27
+ === CHARTER (this is how the user decides; follow it) ===
28
+ ${brief.charter}`;
29
+ }
30
+ function parseVerdict(text) {
31
+ let obj = {};
32
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
33
+ const raw = fenced ? fenced[1] : text.match(/\{[\s\S]*\}/)?.[0] ?? "";
34
+ try {
35
+ obj = JSON.parse(raw);
36
+ }
37
+ catch {
38
+ obj = {};
39
+ }
40
+ return {
41
+ action: obj.action ?? "escalate",
42
+ classification: obj.classification ?? "n/a",
43
+ charterBasis: obj.charterBasis ?? "",
44
+ reply: obj.reply,
45
+ logTitle: obj.logTitle,
46
+ logWhy: obj.logWhy,
47
+ reversal: obj.reversal,
48
+ escalationReason: obj.escalationReason ?? "Conductor produced no parseable decision; escalating to the human.",
49
+ };
50
+ }
51
+ export async function decide(cfg, brief, status, recentDecisions) {
52
+ const userText = `The worker reported this for item "${status.item}":
53
+
54
+ STATUS: ${status.kind}
55
+ SUMMARY: ${status.summary}
56
+ DECISION-NEEDED: ${status.decisionNeeded ?? "(none)"}
57
+ NEXT: ${status.next ?? "(none)"}
58
+ EVIDENCE: ${status.evidence ?? "(none)"}
59
+
60
+ Recent decisions you already made this run (do not contradict them):
61
+ ${recentDecisions || "(none yet)"}
62
+
63
+ Return the JSON verdict now.`;
64
+ const q = query({
65
+ prompt: userText,
66
+ options: {
67
+ ...(cfg.conductorModel ? { model: cfg.conductorModel } : {}),
68
+ systemPrompt: system(brief),
69
+ allowedTools: [],
70
+ settingSources: [],
71
+ maxTurns: 1,
72
+ permissionMode: "default",
73
+ },
74
+ });
75
+ let text = "";
76
+ for await (const msg of q) {
77
+ if (msg.type === "assistant") {
78
+ for (const b of msg.message?.content ?? [])
79
+ if (b.type === "text" && b.text)
80
+ text += b.text;
81
+ }
82
+ else if (msg.type === "result") {
83
+ if (!text && typeof msg.result === "string")
84
+ text = msg.result;
85
+ }
86
+ }
87
+ return parseVerdict(text);
88
+ }
package/dist/config.js ADDED
@@ -0,0 +1,74 @@
1
+ // Load the brief and run state from .leopold/, and the driver config from env.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ export function findLeoDir(cwd) {
5
+ let dir = path.resolve(cwd);
6
+ for (;;) {
7
+ const leo = path.join(dir, ".leopold");
8
+ if (fs.existsSync(leo))
9
+ return leo;
10
+ const parent = path.dirname(dir);
11
+ if (parent === dir)
12
+ break;
13
+ dir = parent;
14
+ }
15
+ throw new Error("No .leopold/ found here. Run /leopold-brief first to write the brief.");
16
+ }
17
+ function read(leoDir, name) {
18
+ const p = path.join(leoDir, name);
19
+ if (!fs.existsSync(p))
20
+ throw new Error(`Missing ${name} in .leopold/. Run /leopold-brief first.`);
21
+ return fs.readFileSync(p, "utf8");
22
+ }
23
+ export function loadBrief(cwd) {
24
+ const leoDir = findLeoDir(cwd);
25
+ return {
26
+ mission: read(leoDir, "MISSION.md"),
27
+ charter: read(leoDir, "CHARTER.md"),
28
+ guardrails: read(leoDir, "GUARDRAILS.md"),
29
+ planPath: path.join(leoDir, "PLAN.md"),
30
+ root: path.dirname(leoDir),
31
+ leoDir,
32
+ };
33
+ }
34
+ function intFrom(text, key, fallback) {
35
+ const m = text.match(new RegExp(`${key}\\s*:\\s*(\\d+)`, "i"));
36
+ return m ? parseInt(m[1], 10) : fallback;
37
+ }
38
+ export function initState(brief) {
39
+ const state = {
40
+ active: true,
41
+ iteration: 0,
42
+ max_iterations: intFrom(brief.guardrails, "max_iterations", 50),
43
+ consecutive_failures: 0,
44
+ max_failures: intFrom(brief.guardrails, "max_failures", 3),
45
+ started_at: new Date().toISOString(),
46
+ };
47
+ writeState(brief.leoDir, state);
48
+ return state;
49
+ }
50
+ export function writeState(leoDir, state) {
51
+ fs.writeFileSync(path.join(leoDir, "state.json"), JSON.stringify(state, null, 2));
52
+ }
53
+ export function killSwitch(leoDir) {
54
+ return fs.existsSync(path.join(leoDir, "STOP"));
55
+ }
56
+ /** Safety hygiene on stop: clear the kill switch and per-session git opt-in
57
+ * tokens so the next run re-locks git and does not halt on a stale STOP. */
58
+ export function clearRunTokens(leoDir) {
59
+ for (const t of ["STOP", "ALLOW_GIT", "ALLOW_PUSH", "ALLOW_PUBLISH"]) {
60
+ try {
61
+ fs.rmSync(path.join(leoDir, t), { force: true });
62
+ }
63
+ catch { /* ignore */ }
64
+ }
65
+ }
66
+ export function loadConfig(argv) {
67
+ return {
68
+ conductorModel: process.env.LEOPOLD_CONDUCTOR_MODEL || undefined,
69
+ workerModel: process.env.LEOPOLD_WORKER_MODEL || undefined,
70
+ maxTurnsPerItem: parseInt(process.env.LEOPOLD_MAX_TURNS_PER_ITEM ?? "40", 10),
71
+ webhookUrl: process.env.LEOPOLD_WEBHOOK || undefined,
72
+ dryRun: argv.includes("--dry-run"),
73
+ };
74
+ }
package/dist/guard.js ADDED
@@ -0,0 +1,47 @@
1
+ // The git lock, in driver form. Used as the worker's canUseTool callback so the
2
+ // same policy as the in-session hook holds when the worker runs under the SDK.
3
+ // It only adds denials; it never loosens anything.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ const DESTRUCTIVE_RM = /rm\s+-[a-z]*(rf|fr)/i;
7
+ const DESTRUCTIVE_GIT = /git\s+(reset\s+--hard|clean\s+-[a-z]*f|branch\s+-D)/;
8
+ const FORCE_PUSH = /git\s+push\s+.*(--force|--force-with-lease|-f(\s|$))/;
9
+ const GIT_COMMIT = /git\s+(-[^\s]+\s+)*commit/;
10
+ const GIT_PUSH = /git\s+push/;
11
+ const GH_PR = /gh\s+(pr\s+(create|merge)|release\s+create)/;
12
+ const PUBLISH = /(npm|pnpm|yarn)\s+publish|cargo\s+publish|twine\s+upload|pip\s+.*upload/;
13
+ const PROTECTED_PATH = /(GUARDRAILS\.md|settings\.json|settings\.local\.json|leopold\/hooks\/)/;
14
+ function hasToken(leoDir, name) {
15
+ return fs.existsSync(path.join(leoDir, name));
16
+ }
17
+ export function makeGuard(leoDir, onBlock) {
18
+ return async function canUseTool(toolName, input) {
19
+ const deny = (message) => {
20
+ onBlock(toolName, message);
21
+ return { behavior: "deny", message };
22
+ };
23
+ if (toolName === "Bash") {
24
+ const cmd = String(input.command ?? "");
25
+ if (DESTRUCTIVE_RM.test(cmd))
26
+ return deny("Leopold guard: 'rm -rf' style deletion is forbidden in autonomous mode.");
27
+ if (DESTRUCTIVE_GIT.test(cmd))
28
+ return deny("Leopold guard: destructive git op (reset --hard / clean -f / branch -D) is forbidden.");
29
+ if (FORCE_PUSH.test(cmd))
30
+ return deny("Leopold guard: force-push is forbidden in autonomous mode.");
31
+ if (GIT_COMMIT.test(cmd) && !hasToken(leoDir, "ALLOW_GIT"))
32
+ return deny("Leopold guard: git commit is locked. Stage and report; the user commits. (touch .leopold/ALLOW_GIT to allow commits this run.)");
33
+ if (GIT_PUSH.test(cmd) && !hasToken(leoDir, "ALLOW_PUSH"))
34
+ return deny("Leopold guard: git push is locked. Report readiness instead.");
35
+ if (GH_PR.test(cmd) && !hasToken(leoDir, "ALLOW_PUSH"))
36
+ return deny("Leopold guard: opening or merging PRs and creating releases is locked.");
37
+ if (PUBLISH.test(cmd) && !hasToken(leoDir, "ALLOW_PUBLISH"))
38
+ return deny("Leopold guard: publishing packages is locked in autonomous mode.");
39
+ }
40
+ if (toolName === "Edit" || toolName === "Write" || toolName === "MultiEdit" || toolName === "NotebookEdit") {
41
+ const p = String(input.file_path ?? input.path ?? "");
42
+ if (PROTECTED_PATH.test(p))
43
+ return deny("Leopold guard: editing guardrails, settings, or hooks is forbidden in autonomous mode.");
44
+ }
45
+ return { behavior: "allow", updatedInput: input };
46
+ };
47
+ }
package/dist/index.js ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ // leopold-driver: the external conductor. Reads the .leopold brief from the cwd
3
+ // and orchestrates Claude Code workers through the plan, git locked.
4
+ import { runDriver } from "./loop.js";
5
+ const arg = process.argv[2] ?? "run";
6
+ if (arg === "--help" || arg === "-h" || arg === "help") {
7
+ process.stdout.write(`leopold-driver - conduct Claude Code through the .leopold brief.
8
+
9
+ Usage:
10
+ leopold-driver [run] [--dry-run]
11
+
12
+ Reads .leopold/ (MISSION, CHARTER, GUARDRAILS, PLAN) from the current project.
13
+
14
+ Auth:
15
+ Uses your existing Claude Code login (your subscription) for BOTH the worker
16
+ and the conductor. No API key needed. ANTHROPIC_API_KEY is only required in a
17
+ headless environment that has no Claude Code auth configured.
18
+
19
+ Env:
20
+ LEOPOLD_CONDUCTOR_MODEL conductor model (default: your Claude Code default)
21
+ LEOPOLD_WORKER_MODEL worker model (default: your Claude Code default)
22
+ LEOPOLD_MAX_TURNS_PER_ITEM max worker turns per item (default: 40)
23
+ LEOPOLD_WEBHOOK optional URL for JSON POST notifications
24
+ `);
25
+ process.exit(0);
26
+ }
27
+ runDriver(process.cwd(), process.argv.slice(2)).catch((err) => {
28
+ const msg = err instanceof Error ? err.message : String(err);
29
+ console.error("leopold-driver error:", msg);
30
+ process.exit(1);
31
+ });
package/dist/log.js ADDED
@@ -0,0 +1,58 @@
1
+ // Observability: the decision trail (DECISIONS.md), the event stream
2
+ // (events.jsonl), and plan bookkeeping (PLAN.md checkboxes).
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ export function logEvent(leoDir, event) {
6
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...event });
7
+ fs.appendFileSync(path.join(leoDir, "events.jsonl"), line + "\n");
8
+ }
9
+ let decisionCounter = 0;
10
+ export function logDecision(leoDir, iteration, status, v) {
11
+ if (!v.logTitle)
12
+ return; // mechanical calls are not logged as blocks
13
+ decisionCounter += 1;
14
+ const block = [
15
+ "",
16
+ `## D${decisionCounter} — ${v.logTitle} (turn ${iteration}, ${new Date().toISOString()})`,
17
+ `Fork: ${status.decisionNeeded ?? status.summary}`,
18
+ `Class: ${v.classification}`,
19
+ `Charter: ${v.charterBasis}`,
20
+ `Decision: ${v.reply ?? "(finish item)"}`,
21
+ `Why: ${v.logWhy ?? ""}`,
22
+ `Reversal: ${v.reversal ?? ""}`,
23
+ "",
24
+ ].join("\n") + "\n";
25
+ fs.appendFileSync(path.join(leoDir, "DECISIONS.md"), block);
26
+ }
27
+ export function openItems(planPath) {
28
+ const text = fs.readFileSync(planPath, "utf8");
29
+ return (text.match(/^[ \t]*- \[ \]/gm) ?? []).length;
30
+ }
31
+ export function nextOpenItem(planPath) {
32
+ const text = fs.readFileSync(planPath, "utf8");
33
+ const m = text.match(/^[ \t]*- \[ \] (.*)$/m);
34
+ return m ? m[1].trim() : null;
35
+ }
36
+ /** Mark the open item whose text matches `itemText` as done; fall back to the
37
+ * first open item. Returns the number of open items remaining. */
38
+ export function markItemDone(planPath, itemText) {
39
+ const lines = fs.readFileSync(planPath, "utf8").split("\n");
40
+ const needle = itemText.trim().slice(0, 40);
41
+ let marked = false;
42
+ for (let i = 0; i < lines.length && !marked; i++) {
43
+ if (/^[ \t]*- \[ \]/.test(lines[i]) && (needle === "" || lines[i].includes(needle))) {
44
+ lines[i] = lines[i].replace("- [ ]", "- [x]");
45
+ marked = true;
46
+ }
47
+ }
48
+ if (!marked) {
49
+ for (let i = 0; i < lines.length && !marked; i++) {
50
+ if (/^[ \t]*- \[ \]/.test(lines[i])) {
51
+ lines[i] = lines[i].replace("- [ ]", "- [x]");
52
+ marked = true;
53
+ }
54
+ }
55
+ }
56
+ fs.writeFileSync(planPath, lines.join("\n"));
57
+ return openItems(planPath);
58
+ }
package/dist/loop.js ADDED
@@ -0,0 +1,103 @@
1
+ // The orchestration loop: the conductor burns down the plan, one fresh worker
2
+ // per item, deciding from the charter, with git locked, until the plan is done
3
+ // or a stop condition fires. It notifies the human on completion or escalation.
4
+ import { loadBrief, initState, writeState, killSwitch, loadConfig, clearRunTokens } from "./config.js";
5
+ import { runItem } from "./worker.js";
6
+ import { decide } from "./conductor.js";
7
+ import { logEvent, logDecision, markItemDone, openItems, nextOpenItem } from "./log.js";
8
+ import { notify } from "./notify.js";
9
+ export async function runDriver(cwd, argv) {
10
+ const cfg = loadConfig(argv);
11
+ const brief = loadBrief(cwd);
12
+ if (cfg.dryRun) {
13
+ console.log("DRY RUN — brief loaded, no workers will run.\n");
14
+ console.log("Mission:\n" + brief.mission.split("\n").slice(0, 10).join("\n"));
15
+ console.log(`\nOpen plan items: ${openItems(brief.planPath)}`);
16
+ console.log("Next item: " + (nextOpenItem(brief.planPath) ?? "(none)"));
17
+ return;
18
+ }
19
+ const state = initState(brief);
20
+ const recent = [];
21
+ logEvent(brief.leoDir, { event: "run_start", conductor: cfg.conductorModel });
22
+ console.log(`Leopold is conducting "${brief.root}". Git is locked. touch .leopold/STOP to halt.\n`);
23
+ const stop = (reason) => {
24
+ state.active = false;
25
+ state.stopped_reason = reason;
26
+ writeState(brief.leoDir, state);
27
+ clearRunTokens(brief.leoDir);
28
+ logEvent(brief.leoDir, { event: "stop", reason });
29
+ };
30
+ for (;;) {
31
+ if (killSwitch(brief.leoDir)) {
32
+ stop("kill_switch");
33
+ await notify(brief.leoDir, cfg.webhookUrl, "Leopold stopped", "Kill switch hit.");
34
+ return;
35
+ }
36
+ if (state.iteration >= state.max_iterations) {
37
+ stop("iteration_budget");
38
+ await notify(brief.leoDir, cfg.webhookUrl, "Leopold stopped", "Iteration budget reached.");
39
+ return;
40
+ }
41
+ if (state.consecutive_failures >= state.max_failures) {
42
+ stop("repeated_failure");
43
+ await notify(brief.leoDir, cfg.webhookUrl, "Leopold needs you", "Too many consecutive failures; stopping.");
44
+ return;
45
+ }
46
+ const item = nextOpenItem(brief.planPath);
47
+ if (!item) {
48
+ stop("plan_complete");
49
+ await notify(brief.leoDir, cfg.webhookUrl, "Leopold finished", `Plan complete for ${brief.root}. Everything is staged for your review; nothing was committed.`);
50
+ return;
51
+ }
52
+ state.iteration += 1;
53
+ writeState(brief.leoDir, state);
54
+ logEvent(brief.leoDir, { event: "item_start", iteration: state.iteration, item });
55
+ console.log(`\n--- turn ${state.iteration}: ${item} ---`);
56
+ const workerPrompt = `Work on this plan item now:\n\n${item}\n\n` +
57
+ `Do it completely and verify it (build, lint, tests). Decide reversible or charter-clear forks yourself per the mission and charter. When the item is done, or if you hit a fork only the conductor can settle, close your turn with the leopold-status block.`;
58
+ let escalated = false;
59
+ let itemDone = false;
60
+ await runItem({
61
+ brief,
62
+ cfg,
63
+ item,
64
+ workerPrompt,
65
+ onBlock: (tool, reason) => logEvent(brief.leoDir, { event: "guard_block", tool, reason }),
66
+ onTurn: async (status) => {
67
+ logEvent(brief.leoDir, { event: "worker_turn", kind: status.kind, item: status.item || item });
68
+ const verdict = await decide(cfg, brief, status, recent.slice(-5).join("\n"));
69
+ logDecision(brief.leoDir, state.iteration, status, verdict);
70
+ if (verdict.logTitle)
71
+ recent.push(`${verdict.logTitle}: ${verdict.reply ?? "(finish)"}`);
72
+ if (verdict.action === "finish") {
73
+ itemDone = true;
74
+ return null;
75
+ }
76
+ if (verdict.action === "escalate") {
77
+ escalated = true;
78
+ await notify(brief.leoDir, cfg.webhookUrl, "Leopold needs a call from you", `Item: ${item}\nWorker: ${status.decisionNeeded ?? status.summary}\nWhy escalated: ${verdict.escalationReason}\n\n` +
79
+ `The run is paused. Make the call, adjust PLAN.md or CHARTER.md if needed, then re-run leopold-driver.`);
80
+ return null;
81
+ }
82
+ console.log(` conductor -> ${verdict.reply}`);
83
+ return verdict.reply ?? "Proceed using your best judgment per the charter, then report status.";
84
+ },
85
+ });
86
+ if (escalated) {
87
+ stop("escalation");
88
+ return;
89
+ }
90
+ if (itemDone) {
91
+ const left = markItemDone(brief.planPath, item);
92
+ state.consecutive_failures = 0;
93
+ writeState(brief.leoDir, state);
94
+ logEvent(brief.leoDir, { event: "item_done", item, open_left: left });
95
+ console.log(` done. ${left} items left.`);
96
+ }
97
+ else {
98
+ state.consecutive_failures += 1;
99
+ writeState(brief.leoDir, state);
100
+ logEvent(brief.leoDir, { event: "item_incomplete", item, fails: state.consecutive_failures });
101
+ }
102
+ }
103
+ }
package/dist/notify.js ADDED
@@ -0,0 +1,19 @@
1
+ // Notify the human on completion or escalation. Best-effort: terminal bell plus
2
+ // an optional webhook (Slack/Discord/whatever accepts a JSON POST).
3
+ import { logEvent } from "./log.js";
4
+ export async function notify(leoDir, webhookUrl, title, body) {
5
+ process.stdout.write(`\x07\n=== ${title} ===\n${body}\n\n`);
6
+ logEvent(leoDir, { event: "notify", title, body });
7
+ if (!webhookUrl)
8
+ return;
9
+ try {
10
+ await fetch(webhookUrl, {
11
+ method: "POST",
12
+ headers: { "content-type": "application/json" },
13
+ body: JSON.stringify({ title, body, source: "leopold" }),
14
+ });
15
+ }
16
+ catch {
17
+ /* best-effort; a failed notification must not break the run */
18
+ }
19
+ }
@@ -0,0 +1,50 @@
1
+ // Parser for the worker's end-of-cycle STATUS contract.
2
+ //
3
+ // The worker closes each turn with a fenced block:
4
+ //
5
+ // ```leopold-status
6
+ // STATUS: done | needs-decision | blocked | working
7
+ // ITEM: <plan item>
8
+ // SUMMARY: <what happened>
9
+ // DECISION-NEEDED: <question + options, or empty>
10
+ // NEXT: <what it thinks comes next>
11
+ // EVIDENCE: <tests/build result>
12
+ // ```
13
+ //
14
+ // Parsing is deterministic; the conductor then reasons over the parsed result.
15
+ const KINDS = ["done", "needs-decision", "blocked", "working"];
16
+ function field(body, name) {
17
+ const re = new RegExp(`^\\s*${name}\\s*:\\s*(.*)$`, "im");
18
+ const m = body.match(re);
19
+ return m ? m[1].trim() : "";
20
+ }
21
+ /** Extract the last STATUS block from accumulated assistant text, if any. */
22
+ export function parseStatus(text) {
23
+ // Prefer a fenced ```leopold-status block; fall back to a bare STATUS: line.
24
+ const fenceRe = /```leopold-status\s*([\s\S]*?)```/gi;
25
+ let body = null;
26
+ let m;
27
+ while ((m = fenceRe.exec(text)) !== null)
28
+ body = m[1]; // keep the last one
29
+ if (body === null) {
30
+ const idx = text.toUpperCase().lastIndexOf("STATUS:");
31
+ if (idx === -1)
32
+ return null;
33
+ body = text.slice(idx);
34
+ }
35
+ const rawKind = field(body, "STATUS").toLowerCase();
36
+ const kind = (KINDS.find((k) => rawKind.startsWith(k)) ?? "working");
37
+ return {
38
+ kind,
39
+ item: field(body, "ITEM"),
40
+ summary: field(body, "SUMMARY"),
41
+ decisionNeeded: field(body, "DECISION-NEEDED") || undefined,
42
+ next: field(body, "NEXT") || undefined,
43
+ evidence: field(body, "EVIDENCE") || undefined,
44
+ raw: body.trim(),
45
+ };
46
+ }
47
+ /** True when a turn looks complete (it emitted a terminal status). */
48
+ export function isTurnComplete(status) {
49
+ return !!status && status.kind !== "working";
50
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ // Core types shared across the driver.
2
+ export {};
package/dist/worker.js ADDED
@@ -0,0 +1,75 @@
1
+ // Runs one plan item as a back-and-forth with a FRESH Claude Code worker.
2
+ //
3
+ // Fresh context per item (the good idea Ralph has). Within an item, the worker
4
+ // and the conductor exchange real messages through the input channel (the part
5
+ // Ralph lacks): the worker closes a turn with a status block, the conductor
6
+ // reads and judges it, and either pushes the next instruction or ends the item.
7
+ import { query } from "@anthropic-ai/claude-agent-sdk";
8
+ import { InputChannel } from "./channel.js";
9
+ import { parseStatus, isTurnComplete } from "./protocol.js";
10
+ import { makeGuard } from "./guard.js";
11
+ const WORKER_APPEND = `You are a Leopold worker, conducted by an autonomous orchestrator. No human is watching live. Rules for this session:
12
+ - Do NOT ask the human anything. Decide reversible or charter-clear calls yourself and keep going.
13
+ - Spawned mode: if you invoke gstack skills, auto-pick the recommended option; never prompt.
14
+ - git commit/push/publish are LOCKED by a guard. Never attempt them. Stage with "git add" and report instead.
15
+ - Close EVERY turn with a fenced status block, then stop and wait for the conductor's reply:
16
+
17
+ \`\`\`leopold-status
18
+ STATUS: done | needs-decision | blocked
19
+ ITEM: <the item you are on>
20
+ SUMMARY: <what you did this turn, 1-3 lines>
21
+ DECISION-NEEDED: <if needs-decision: the exact question + options A/B + the tradeoff; else empty>
22
+ NEXT: <what you think comes next>
23
+ EVIDENCE: <build/lint/test result if relevant>
24
+ \`\`\`
25
+
26
+ Use needs-decision only for a fork you genuinely cannot resolve from the task and your own judgment. Use done only when the item is fully complete and verified.`;
27
+ export async function runItem(opts) {
28
+ const { brief, cfg, item, workerPrompt, onBlock, onTurn } = opts;
29
+ const channel = new InputChannel();
30
+ channel.push(workerPrompt);
31
+ const guard = makeGuard(brief.leoDir, onBlock);
32
+ const q = query({
33
+ prompt: channel,
34
+ options: {
35
+ cwd: brief.root,
36
+ maxTurns: cfg.maxTurnsPerItem,
37
+ permissionMode: "default",
38
+ canUseTool: guard,
39
+ settingSources: ["user", "project"],
40
+ ...(cfg.workerModel ? { model: cfg.workerModel } : {}),
41
+ systemPrompt: { type: "preset", preset: "claude_code", append: WORKER_APPEND },
42
+ },
43
+ });
44
+ let turnText = "";
45
+ for await (const msg of q) {
46
+ if (msg.type === "assistant") {
47
+ for (const b of msg.message?.content ?? []) {
48
+ if (b.type === "text" && b.text)
49
+ turnText += b.text;
50
+ }
51
+ const status = parseStatus(turnText);
52
+ if (isTurnComplete(status)) {
53
+ const captured = turnText;
54
+ turnText = "";
55
+ const next = await onTurn(status, captured);
56
+ if (next === null) {
57
+ channel.close();
58
+ break;
59
+ }
60
+ channel.push(next);
61
+ }
62
+ }
63
+ else if (msg.type === "result") {
64
+ // Session ended (channel closed, or the worker stopped on its own). Flush
65
+ // whatever we have so the conductor can make a final call.
66
+ if (turnText.trim()) {
67
+ const status = parseStatus(turnText) ?? {
68
+ kind: "blocked", item, summary: turnText.slice(-500), raw: turnText.slice(-500),
69
+ };
70
+ await onTurn(status, turnText);
71
+ }
72
+ break;
73
+ }
74
+ }
75
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "leopold-driver",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "bin": { "leopold-driver": "dist/index.js" },
7
+ "main": "dist/index.js",
8
+ "files": ["dist", "README.md"],
9
+ "engines": { "node": ">=18" },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/Jonhvmp/leopold.git",
13
+ "directory": "packages/driver"
14
+ },
15
+ "homepage": "https://github.com/Jonhvmp/leopold/tree/main/packages/driver#readme",
16
+ "bugs": { "url": "https://github.com/Jonhvmp/leopold/issues" },
17
+ "keywords": [
18
+ "claude-code",
19
+ "claude",
20
+ "ai-agents",
21
+ "agent-harness",
22
+ "autonomous-agents",
23
+ "orchestration",
24
+ "anthropic"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsc -p tsconfig.json",
28
+ "typecheck": "tsc -p tsconfig.json --noEmit",
29
+ "dev": "tsx src/index.ts",
30
+ "start": "node dist/index.js",
31
+ "prepublishOnly": "npm run build"
32
+ },
33
+ "dependencies": {
34
+ "@anthropic-ai/claude-agent-sdk": "^0.3.179"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^22.0.0",
38
+ "tsx": "^4.19.0",
39
+ "typescript": "^5.6.0"
40
+ },
41
+ "license": "MIT",
42
+ "author": "Jonhvmp"
43
+ }