@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.
- package/LEDGER.md +459 -0
- package/README.md +140 -0
- package/dist/cli/commands/agents.js +457 -0
- package/dist/cli/commands/catchup.js +174 -0
- package/dist/cli/commands/clerk.js +90 -0
- package/dist/cli/commands/docs.js +29 -0
- package/dist/cli/commands/events.js +59 -0
- package/dist/cli/commands/projects.js +134 -0
- package/dist/cli/commands/roadmap.js +120 -0
- package/dist/cli/format.js +19 -0
- package/dist/cli/index.js +33 -0
- package/dist/db/client.js +48 -0
- package/dist/db/migrations/0001_init.js +72 -0
- package/dist/db/migrations/0002_project_herdr_workspace.js +13 -0
- package/dist/db/migrations/0003_agent_authorization_basis.js +18 -0
- package/dist/db/migrations/0004_roadmap_priority.js +19 -0
- package/dist/db/migrations/index.js +10 -0
- package/dist/db/migrations/types.js +1 -0
- package/dist/db/types.js +33 -0
- package/dist/lib/git.js +61 -0
- package/dist/lib/herdr.js +321 -0
- package/dist/lib/treehouse.js +23 -0
- package/dist/plugin/watcher.js +63 -0
- package/herdr-plugin.toml +16 -0
- package/package.json +33 -0
|
@@ -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 {};
|
package/dist/db/types.js
ADDED
|
@@ -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
|
+
];
|
package/dist/lib/git.js
ADDED
|
@@ -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
|
+
}
|