@devwithdavid/ledger 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.
@@ -0,0 +1,10 @@
1
+ import { migration0001Init } from "./0001_init.js";
2
+ import { migration0002ProjectHerdrWorkspace } from "./0002_project_herdr_workspace.js";
3
+ import { migration0003AgentAuthorizationBasis } from "./0003_agent_authorization_basis.js";
4
+ import { migration0004RoadmapPriority } from "./0004_roadmap_priority.js";
5
+ export const migrations = [
6
+ migration0001Init,
7
+ migration0002ProjectHerdrWorkspace,
8
+ migration0003AgentAuthorizationBasis,
9
+ migration0004RoadmapPriority,
10
+ ].sort((a, b) => a.version - b.version);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ export const ROADMAP_PRIORITIES = [
2
+ "high",
3
+ "normal",
4
+ "low",
5
+ ];
6
+ export const AUTHORIZATION_BASES = [
7
+ "user-explicit",
8
+ "pre-authorized",
9
+ ];
10
+ // Mirrors `herdr agent start --kind`'s accepted values.
11
+ export const CODING_AGENT_KINDS = [
12
+ "pi",
13
+ "claude",
14
+ "codex",
15
+ "gemini",
16
+ "cursor",
17
+ "devin",
18
+ "agy",
19
+ "cline",
20
+ "omp",
21
+ "mastracode",
22
+ "opencode",
23
+ "copilot",
24
+ "kimi",
25
+ "kiro",
26
+ "droid",
27
+ "amp",
28
+ "grok",
29
+ "hermes",
30
+ "kilo",
31
+ "qodercli",
32
+ "maki",
33
+ ];
@@ -0,0 +1,61 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdirSync } from "node:fs";
3
+ function resolveDefaultBranch(cwd) {
4
+ return execFileSync("git", ["symbolic-ref", "--short", "HEAD"], {
5
+ cwd,
6
+ encoding: "utf8",
7
+ }).trim();
8
+ }
9
+ /**
10
+ * Clones `source` (a remote URL or a local filesystem path) into
11
+ * `destPath`. A local path clones via git's own local-clone fast path
12
+ * (hardlinks where possible) and picks up every local commit/branch,
13
+ * including unpushed ones — the dirty working tree itself is the one
14
+ * thing that can never be captured, by design (see DESIGN.md).
15
+ */
16
+ export function cloneProject(source, destPath) {
17
+ if (existsSync(destPath)) {
18
+ throw new Error(`clone destination already exists: ${destPath}`);
19
+ }
20
+ execFileSync("git", ["clone", source, destPath], { encoding: "utf8" });
21
+ return { defaultBranch: resolveDefaultBranch(destPath) };
22
+ }
23
+ /** True if `cwd`'s git repo already has a remote named `name`. */
24
+ export function hasRemote(cwd, name) {
25
+ return getRemoteUrl(cwd, name) !== null;
26
+ }
27
+ /** The URL of `cwd`'s git remote `name`, or null if it doesn't exist. */
28
+ export function getRemoteUrl(cwd, name) {
29
+ try {
30
+ // "No such remote" is an expected, routine outcome here (not an
31
+ // error to surface) — suppress git's own stderr for it specifically.
32
+ return execFileSync("git", ["remote", "get-url", name], {
33
+ cwd,
34
+ encoding: "utf8",
35
+ stdio: ["ignore", "pipe", "ignore"],
36
+ }).trim();
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ /** Adds a remote to `cwd`'s git repo. Caller should check `hasRemote` first. */
43
+ export function addRemote(cwd, name, url) {
44
+ execFileSync("git", ["remote", "add", name, url], { cwd, encoding: "utf8" });
45
+ }
46
+ /**
47
+ * Creates a brand-new project from scratch at `destPath` — for work that
48
+ * doesn't exist anywhere yet (no repo to clone, local or remote). An empty
49
+ * initial commit gives treehouse a real ref to lease worktrees from;
50
+ * deliberately no scaffolding beyond that — what the project actually
51
+ * becomes is a dispatched agent's job, not ledger's.
52
+ */
53
+ export function initProject(destPath) {
54
+ if (existsSync(destPath)) {
55
+ throw new Error(`init destination already exists: ${destPath}`);
56
+ }
57
+ mkdirSync(destPath, { recursive: true });
58
+ execFileSync("git", ["init", "--quiet", destPath], { encoding: "utf8" });
59
+ execFileSync("git", ["commit", "--allow-empty", "-m", "Initial commit (ledger project init)"], { cwd: destPath, encoding: "utf8" });
60
+ return { defaultBranch: resolveDefaultBranch(destPath) };
61
+ }
@@ -0,0 +1,321 @@
1
+ import { execFileSync } from "node:child_process";
2
+ export class HerdrError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ this.name = "HerdrError";
8
+ }
9
+ }
10
+ /** Throws HerdrError (or a generic Error) from a failed herdr invocation. */
11
+ function throwHerdrFailure(args, err) {
12
+ const e = err;
13
+ // herdr's JSON error envelope can land on stdout or stderr depending on
14
+ // the command — confirmed live: `workspace get` on a missing id writes
15
+ // it to stderr, unlike every other failure observed so far (stdout).
16
+ // Check both rather than assuming one.
17
+ for (const text of [e.stdout, e.stderr]) {
18
+ if (!text)
19
+ continue;
20
+ const parsed = tryParseEnvelope(text);
21
+ if (parsed?.error) {
22
+ throw new HerdrError(parsed.error.code, parsed.error.message);
23
+ }
24
+ }
25
+ throw new Error(`herdr ${args.join(" ")} failed: ${(e.stderr ?? e.message).trim()}`);
26
+ }
27
+ function runHerdr(args, opts) {
28
+ let stdout;
29
+ try {
30
+ stdout = execFileSync("herdr", args, {
31
+ encoding: "utf8",
32
+ // Explicit "pipe" (not the default) captures stderr for
33
+ // throwHerdrFailure to parse WITHOUT echoing it to the terminal —
34
+ // "ignore" would lose it entirely, breaking error-code detection
35
+ // for errors that land on stderr (see DECISIONS.md).
36
+ ...(opts?.quiet ? { stdio: ["ignore", "pipe", "pipe"] } : {}),
37
+ });
38
+ }
39
+ catch (err) {
40
+ throwHerdrFailure(args, err);
41
+ }
42
+ const parsed = tryParseEnvelope(stdout);
43
+ if (!parsed) {
44
+ throw new Error(`herdr ${args.join(" ")}: could not parse JSON output`);
45
+ }
46
+ if (parsed.error) {
47
+ throw new HerdrError(parsed.error.code, parsed.error.message);
48
+ }
49
+ if (parsed.result === undefined) {
50
+ throw new Error(`herdr ${args.join(" ")}: response had no result`);
51
+ }
52
+ return parsed.result;
53
+ }
54
+ /**
55
+ * Like `runHerdr`, but for commands confirmed live to return empty stdout
56
+ * on success rather than herdr's usual JSON envelope (`pane send-keys` —
57
+ * see DECISIONS.md). A non-throwing exit is success regardless of stdout
58
+ * content; a thrown error is still parsed the normal way.
59
+ */
60
+ function runHerdrAction(args) {
61
+ try {
62
+ execFileSync("herdr", args, { encoding: "utf8" });
63
+ }
64
+ catch (err) {
65
+ throwHerdrFailure(args, err);
66
+ }
67
+ }
68
+ /**
69
+ * Like `runHerdr`, but for `pane read`, which (confirmed live) returns the
70
+ * pane's raw rendered terminal text on stdout, not herdr's usual JSON
71
+ * envelope.
72
+ */
73
+ function runHerdrText(args, opts) {
74
+ try {
75
+ return execFileSync("herdr", args, {
76
+ encoding: "utf8",
77
+ // execFileSync leaks stderr straight to the parent's terminal by
78
+ // default even though it's also captured for throwHerdrFailure to
79
+ // parse (confirmed live) — same rationale as runHerdr's `quiet`:
80
+ // pass it when a failure is routine/handled, not worth echoing raw.
81
+ ...(opts?.quiet ? { stdio: ["ignore", "pipe", "pipe"] } : {}),
82
+ });
83
+ }
84
+ catch (err) {
85
+ throwHerdrFailure(args, err);
86
+ }
87
+ }
88
+ function tryParseEnvelope(text) {
89
+ try {
90
+ return JSON.parse(text.trim());
91
+ }
92
+ catch {
93
+ return undefined;
94
+ }
95
+ }
96
+ /** Creates a new workspace (with its own root tab + root pane) at `cwd`. */
97
+ export function createWorkspace(opts) {
98
+ const args = ["workspace", "create", "--cwd", opts.cwd, "--label", opts.label];
99
+ args.push(opts.focus ? "--focus" : "--no-focus");
100
+ return runHerdr(args);
101
+ }
102
+ export function closeWorkspace(workspaceId) {
103
+ runHerdr(["workspace", "close", workspaceId]);
104
+ }
105
+ /**
106
+ * Fetches a workspace's info (including its label). Throws HerdrError
107
+ * (e.g. `workspace_not_found`) when the id doesn't exist. Pass `quiet`
108
+ * when a missing workspace is an expected, handled outcome (same rationale
109
+ * as `workspaceExists`): it suppresses herdr's raw error envelope being
110
+ * echoed to the terminal, while `throwHerdrFailure` still parses it from
111
+ * the piped stderr.
112
+ */
113
+ export function getWorkspace(workspaceId, opts) {
114
+ const result = runHerdr(["workspace", "get", workspaceId], opts);
115
+ return result.workspace;
116
+ }
117
+ export function renameWorkspace(workspaceId, label) {
118
+ runHerdr(["workspace", "rename", workspaceId, label]);
119
+ }
120
+ /** True if `workspaceId` still exists (wasn't closed, e.g. by the user). */
121
+ export function workspaceExists(workspaceId) {
122
+ try {
123
+ // quiet: this is a routine "is it still there?" check, run on every
124
+ // dispatch — a missing workspace is an expected, handled outcome, not
125
+ // noise worth printing to the terminal every time.
126
+ runHerdr(["workspace", "get", workspaceId], { quiet: true });
127
+ return true;
128
+ }
129
+ catch (err) {
130
+ if (err instanceof HerdrError && err.code === "workspace_not_found") {
131
+ return false;
132
+ }
133
+ throw err;
134
+ }
135
+ }
136
+ /** Adds a new tab (with its own root pane) to an existing workspace at `cwd`. */
137
+ export function createTab(opts) {
138
+ const args = [
139
+ "tab",
140
+ "create",
141
+ "--workspace",
142
+ opts.workspace,
143
+ "--cwd",
144
+ opts.cwd,
145
+ "--label",
146
+ opts.label,
147
+ ];
148
+ args.push(opts.focus ? "--focus" : "--no-focus");
149
+ return runHerdr(args);
150
+ }
151
+ export function closeTab(tabId) {
152
+ runHerdr(["tab", "close", tabId]);
153
+ }
154
+ export function renameTab(tabId, label) {
155
+ runHerdr(["tab", "rename", tabId, label]);
156
+ }
157
+ const AGENT_START_READY_RETRY_BUDGET_MS = 10_000;
158
+ const AGENT_START_READY_RETRY_INTERVAL_MS = 300;
159
+ const CLAUDE_TRUST_DIALOG_KEY_SETTLE_MS = 300;
160
+ const CLAUDE_TRUST_DIALOG_READY_TIMEOUT_MS = 15_000;
161
+ // Text markers from Claude Code's one-time "do you trust this folder?"
162
+ // dialog, confirmed live off a real `herdr pane read --format text` (see
163
+ // DECISIONS.md). Matched literally, not as a prefix/suffix regex, since
164
+ // the surrounding box-drawing/whitespace varies but this wording doesn't.
165
+ const CLAUDE_TRUST_OPTION_TEXT = "Yes, I trust this folder";
166
+ const CLAUDE_DECLINE_OPTION_TEXT = "No, exit";
167
+ export function sleepSync(ms) {
168
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
169
+ }
170
+ /**
171
+ * Reads a pane's rendered terminal text — the general-purpose entry point
172
+ * (catch-up's idle-agent pane tails; anything else that needs to look at
173
+ * what a pane last showed). Throws HerdrError (e.g. `pane_not_found`) for a
174
+ * gone pane, or a generic Error for something more fundamental (socket
175
+ * unreachable) — callers that need to tell those apart use `instanceof
176
+ * HerdrError`, same as every other herdr-client call in this file.
177
+ */
178
+ export function readPane(paneId, opts) {
179
+ const args = ["pane", "read", paneId, "--source", opts?.source ?? "recent", "--format", "text"];
180
+ if (opts?.lines !== undefined) {
181
+ args.push("--lines", String(opts.lines));
182
+ }
183
+ return runHerdrText(args, opts?.quiet !== undefined ? { quiet: opts.quiet } : undefined);
184
+ }
185
+ function readPaneText(paneId) {
186
+ return readPane(paneId);
187
+ }
188
+ function looksLikeClaudeTrustDialog(paneText) {
189
+ return (paneText.includes(CLAUDE_TRUST_OPTION_TEXT) &&
190
+ paneText.includes(CLAUDE_DECLINE_OPTION_TEXT));
191
+ }
192
+ /** True if the pane's rendered text shows the "trust" option as the currently-highlighted (❯) one. */
193
+ function isTrustOptionHighlighted(paneText) {
194
+ return paneText
195
+ .split("\n")
196
+ .some((line) => /^❯\s*/.test(line.trim()) && line.includes(CLAUDE_TRUST_OPTION_TEXT));
197
+ }
198
+ /**
199
+ * Dismisses Claude Code's one-time "do you trust this folder?" dialog,
200
+ * choosing "Yes, I trust this folder" specifically — confirmed live (see
201
+ * DECISIONS.md) that the dialog's default-highlighted option is NOT
202
+ * reliable: one real run defaulted to "Yes, I trust this folder", another
203
+ * (same Claude Code version) defaulted to "No, exit". A blind Enter risks
204
+ * declining trust instead of accepting it, so this reads the pane's actual
205
+ * rendered text first and only sends keys once it recognizes exactly what's
206
+ * on screen — an unrecognized stuck state fails loud instead of guessing.
207
+ */
208
+ function dismissClaudeTrustDialog(paneId) {
209
+ const text = readPaneText(paneId);
210
+ if (!looksLikeClaudeTrustDialog(text)) {
211
+ throw new Error(`herdr reported the agent on pane ${paneId} as not ready, but its screen ` +
212
+ `doesn't show Claude Code's known "trust this folder?" dialog — refusing ` +
213
+ `to send keystrokes to an unrecognized stuck state. Pane text:\n${text}`);
214
+ }
215
+ if (!isTrustOptionHighlighted(text)) {
216
+ sendKeys(paneId, "down");
217
+ sleepSync(CLAUDE_TRUST_DIALOG_KEY_SETTLE_MS);
218
+ const afterDown = readPaneText(paneId);
219
+ if (!isTrustOptionHighlighted(afterDown)) {
220
+ throw new Error(`sent Down to move the Claude Code trust dialog's selection to ` +
221
+ `"${CLAUDE_TRUST_OPTION_TEXT}" on pane ${paneId}, but it still isn't ` +
222
+ `highlighted — refusing to guess further. Pane text:\n${afterDown}`);
223
+ }
224
+ }
225
+ sendKeys(paneId, "enter");
226
+ }
227
+ /**
228
+ * Starts a supported interactive coding agent in an existing pane at its
229
+ * shell prompt. A pane created moments earlier (e.g. right after
230
+ * `createWorkspace`, called with zero delay in `agent dispatch`) is often
231
+ * not yet at rest — confirmed live (see DECISIONS.md): `herdr agent start`
232
+ * fails immediately with `agent_pane_busy: "... is not an available
233
+ * shell"` in that window. `--timeout` does *not* cover this — that only
234
+ * governs a later readiness wait, and passing it made no difference in
235
+ * testing. What actually resolves it is real elapsed wall-clock time, so
236
+ * retry specifically on `agent_pane_busy` with a short synchronous
237
+ * backoff; any other error fails immediately, not retried.
238
+ *
239
+ * For `kind: "claude"`, a second real blocker (confirmed live, see
240
+ * DECISIONS.md): Claude Code's one-time "do you trust this folder?" dialog
241
+ * blocks herdr's own readiness detection, so `agent start` throws
242
+ * `agent_not_ready` while it's showing — success never comes for it to be
243
+ * dismissed after the fact. On that specific error, dismiss the dialog
244
+ * directly (see `dismissClaudeTrustDialog`) and then wait for readiness via
245
+ * `agent wait` on the *pane*, not by re-invoking `agent start` with the
246
+ * same name: once herdr has detected and named the agent on this pane
247
+ * (which happens even though the first `agent start` call threw), a second
248
+ * `agent start` call fails with `agent_name_taken` — confirmed live, even
249
+ * though the error payload's own `status` field shows the agent as Idle
250
+ * (ready) at that point.
251
+ */
252
+ export function startAgent(opts) {
253
+ const args = ["agent", "start", opts.name, "--kind", opts.kind, "--pane", opts.pane];
254
+ if (opts.extraArgs && opts.extraArgs.length > 0) {
255
+ args.push("--", ...opts.extraArgs);
256
+ }
257
+ const deadline = Date.now() + AGENT_START_READY_RETRY_BUDGET_MS;
258
+ for (;;) {
259
+ try {
260
+ runHerdr(args);
261
+ return;
262
+ }
263
+ catch (err) {
264
+ const isPaneBusy = err instanceof HerdrError && err.code === "agent_pane_busy";
265
+ if (isPaneBusy && Date.now() < deadline) {
266
+ sleepSync(AGENT_START_READY_RETRY_INTERVAL_MS);
267
+ continue;
268
+ }
269
+ const isNotReady = err instanceof HerdrError && err.code === "agent_not_ready";
270
+ if (opts.kind === "claude" && isNotReady) {
271
+ dismissClaudeTrustDialog(opts.pane);
272
+ // --until idle specifically: confirmed live, right after dismissal
273
+ // Claude Code passes through a transient "blocked" status before
274
+ // settling into "idle" — `agent wait`'s default (idle/done/blocked)
275
+ // matches that transient blocked state and returns too early, so a
276
+ // caller that then immediately prompts the agent hits herdr's own
277
+ // agent_blocked error. Only "idle" actually means ready for prompts
278
+ // here.
279
+ runHerdr([
280
+ "agent",
281
+ "wait",
282
+ opts.pane,
283
+ "--until",
284
+ "idle",
285
+ "--timeout",
286
+ String(CLAUDE_TRUST_DIALOG_READY_TIMEOUT_MS),
287
+ ]);
288
+ return;
289
+ }
290
+ throw err;
291
+ }
292
+ }
293
+ }
294
+ export function sendKeys(paneId, ...keys) {
295
+ runHerdrAction(["pane", "send-keys", paneId, ...keys]);
296
+ }
297
+ /**
298
+ * Submits the initial task instruction to a running agent. Confirmed live
299
+ * (see DECISIONS.md): for text long/multi-line enough that Claude Code's
300
+ * TUI collapses it into a "[Pasted text #N +M lines]" placeholder — which
301
+ * every real dispatch hits, since `buildTaskPrompt` always appends a
302
+ * multi-paragraph reporting contract — `herdr agent prompt` pastes the
303
+ * text into the input box but does not submit it; the agent sits idle
304
+ * until something sends Enter. Always follow up with an explicit Enter
305
+ * keypress to guarantee submission regardless of text length.
306
+ *
307
+ * Never pass `--wait` to the `agent prompt` call itself: it would block
308
+ * waiting for a state change that can't happen until *after* the
309
+ * follow-up Enter below is sent (a deadlock/stall). If waiting is
310
+ * requested, do it as its own step afterward via `agent wait`.
311
+ */
312
+ export function promptAgent(opts) {
313
+ runHerdr(["agent", "prompt", opts.target, opts.text]);
314
+ sendKeys(opts.target, "enter");
315
+ if (opts.wait) {
316
+ const waitArgs = ["agent", "wait", opts.target];
317
+ if (opts.timeoutMs)
318
+ waitArgs.push("--timeout", String(opts.timeoutMs));
319
+ runHerdr(waitArgs);
320
+ }
321
+ }
@@ -0,0 +1,23 @@
1
+ import { execFileSync } from "node:child_process";
2
+ /**
3
+ * Durably leases a worktree from the pool rooted at `repoCwd` (treehouse
4
+ * discovers the repo from cwd) and returns its absolute path. Never opens a
5
+ * subshell (`--lease`), never removed by prune until explicitly returned.
6
+ */
7
+ export function leaseWorktree(opts) {
8
+ const stdout = execFileSync("treehouse", ["get", "--lease", "--lease-holder", opts.leaseHolder], { cwd: opts.repoCwd, encoding: "utf8" });
9
+ const path = stdout.trim();
10
+ if (!path) {
11
+ throw new Error("treehouse get --lease returned no path");
12
+ }
13
+ return path;
14
+ }
15
+ /**
16
+ * Returns a leased worktree to the pool. Uses --force (clean, reset,
17
+ * return without prompting) since ledger's own CLI is non-interactive.
18
+ */
19
+ export function returnWorktree(path) {
20
+ execFileSync("treehouse", ["return", path, "--force"], {
21
+ encoding: "utf8",
22
+ });
23
+ }
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * herdr event-hook entrypoint, registered in herdr-plugin.toml against the
4
+ * `pane.agent_status_changed` event hook. Not a daemon: herdr invokes this
5
+ * once per event and it exits. If herdr isn't running, this never fires —
6
+ * which is correct, since no agents can be running either (see DESIGN.md).
7
+ *
8
+ * Payload shape confirmed empirically (not documented): herdr wraps the
9
+ * event as `{ event: "pane_agent_status_changed", data: {...} }` — an
10
+ * underscored `event` name even though the manifest's `on` field is dotted
11
+ * (`pane.agent_status_changed`), with the actual fields nested under `data`,
12
+ * not flat. See DECISIONS.md.
13
+ */
14
+ import { getDb } from "../db/client.js";
15
+ function main() {
16
+ const raw = process.env["HERDR_PLUGIN_EVENT_JSON"];
17
+ if (!raw) {
18
+ // Not invoked as an event hook (e.g. run manually without the env var).
19
+ process.exit(0);
20
+ }
21
+ const envelope = JSON.parse(raw);
22
+ const event = envelope.data;
23
+ if (!event?.pane_id || !event.agent_status) {
24
+ process.exit(0);
25
+ }
26
+ const db = getDb();
27
+ const agentRow = db
28
+ .prepare("SELECT * FROM agents WHERE herdr_pane = ? ORDER BY id DESC LIMIT 1")
29
+ .get(event.pane_id);
30
+ if (!agentRow) {
31
+ // This pane isn't one ledger dispatched (e.g. the clerk's own pane).
32
+ process.exit(0);
33
+ }
34
+ db.prepare(`INSERT INTO events (agent_id, event_type, payload) VALUES (?, 'state_change', ?)`).run(agentRow.id, JSON.stringify(event));
35
+ // herdr's AgentStatus includes "unknown", which ledger's durable status
36
+ // field does not — an unknown reading is usually transient detection
37
+ // noise, so it's logged above but doesn't overwrite the last known status.
38
+ //
39
+ // `done` and `blocked` are terminal for this row while the row sits in
40
+ // them. Confirmed live (see DECISIONS.md): herdr keeps reporting pane
41
+ // activity after a self-reported `done` — e.g. the `agent update`
42
+ // command itself finishing causes the pane to settle back to `idle` a
43
+ // moment later — which would otherwise silently clobber a just-recorded
44
+ // completion back to `idle`. A self-reported `blocked` needs the same
45
+ // protection (A3): the agent is meaningfully waiting on a decision, and
46
+ // a late blip — often the very `agent update` that recorded the block
47
+ // settling the pane — must not decay it to `idle` on the board. A
48
+ // dispatch's row/pane/tab is never reused for a different task, so
49
+ // there's no real "back to working" transition this could be losing:
50
+ // leaving either state is always an explicit act (a fresh `agent
51
+ // update`), never a watcher blip.
52
+ const terminal = ["done", "blocked"];
53
+ if (event.agent_status !== "unknown" && !terminal.includes(agentRow.status)) {
54
+ db.prepare(`UPDATE agents SET status = ?, updated_at = datetime('now') WHERE id = ?`).run(event.agent_status, agentRow.id);
55
+ }
56
+ }
57
+ try {
58
+ main();
59
+ }
60
+ catch (err) {
61
+ console.error(`ledger watcher error: ${err.message}`);
62
+ process.exit(1);
63
+ }
@@ -0,0 +1,16 @@
1
+ # The ledger watcher: not a daemon, just an event hook. herdr invokes
2
+ # `node dist/plugin/watcher.js` once per pane_agent_status_changed event; the
3
+ # process reads HERDR_PLUGIN_EVENT_JSON, updates the ledger, and exits.
4
+ # See DESIGN.md ("The watcher: a herdr plugin, not a daemon").
5
+ #
6
+ # Local development: `herdr plugin link .` from this repo (after `npm run build`).
7
+
8
+ id = "ledger"
9
+ name = "ledger"
10
+ version = "0.1.0"
11
+ min_herdr_version = "0.7.0"
12
+ description = "Watcher for the ledger agent-orchestration state store: keeps agents.status and the events audit trail in sync with herdr pane state, with zero standing process."
13
+
14
+ [[events]]
15
+ on = "pane.agent_status_changed"
16
+ command = ["node", "dist/plugin/watcher.js"]
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@devwithdavid/ledger",
3
+ "version": "0.1.0",
4
+ "description": "Personal agent-orchestration ledger: SQLite state store, CLI, and herdr watcher plugin.",
5
+ "type": "module",
6
+ "bin": {
7
+ "ledger": "dist/cli/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "LEDGER.md",
12
+ "README.md",
13
+ "herdr-plugin.toml"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.json",
17
+ "watch": "tsc -p tsconfig.json --watch",
18
+ "dev": "tsc -p tsconfig.json && node dist/cli/index.js",
19
+ "prepare": "tsc -p tsconfig.json"
20
+ },
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "dependencies": {
25
+ "better-sqlite3": "^11.10.0",
26
+ "commander": "^12.1.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/better-sqlite3": "^7.6.11",
30
+ "@types/node": "^22.10.2",
31
+ "typescript": "^5.7.2"
32
+ }
33
+ }