@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,457 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { getDb } from "../../db/client.js";
|
|
4
|
+
import { AUTHORIZATION_BASES, CODING_AGENT_KINDS } from "../../db/types.js";
|
|
5
|
+
import * as herdr from "../../lib/herdr.js";
|
|
6
|
+
import { leaseWorktree, returnWorktree } from "../../lib/treehouse.js";
|
|
7
|
+
import { printJson, printTable } from "../format.js";
|
|
8
|
+
import { getProjectByName } from "./projects.js";
|
|
9
|
+
const VALID_STATUSES = ["blocked", "working", "done", "idle"];
|
|
10
|
+
// Dispatched Claude agents run unattended — nobody is present in the pane
|
|
11
|
+
// to answer a permission prompt, so "auto" mode (Claude Code's default,
|
|
12
|
+
// which still prompts for some actions) would just stall. bypassPermissions
|
|
13
|
+
// skips all of them. Per the user: scoped to claude specifically, not
|
|
14
|
+
// every coding-agent kind.
|
|
15
|
+
const CLAUDE_BYPASS_ARGS = ["--permission-mode", "bypassPermissions"];
|
|
16
|
+
export function registerAgentCommands(program) {
|
|
17
|
+
const agent = program.command("agent").description("manage dispatched agents");
|
|
18
|
+
agent
|
|
19
|
+
.command("dispatch")
|
|
20
|
+
.description("lease a treehouse worktree, open a herdr pane in it, start a coding " +
|
|
21
|
+
"agent, submit the task, and record the agents row")
|
|
22
|
+
.requiredOption("--project <name>", "project name")
|
|
23
|
+
.requiredOption("--task <description>", "task instruction for the agent")
|
|
24
|
+
.requiredOption("--authorization <basis>", "who authorized this dispatch (user-explicit | pre-authorized) - " +
|
|
25
|
+
"clerk-attested, recorded on the agent row for audit (C6)")
|
|
26
|
+
.option("--roadmap-item <id>", "roadmap item this dispatch implements", parseIntOpt)
|
|
27
|
+
.option("--confirm-duplicate", "explicit user authorization to run a second live agent on a roadmap " +
|
|
28
|
+
"item that already has one (C6)")
|
|
29
|
+
.option("--kind <kind>", "coding agent to run (claude, pi, ...)", "claude")
|
|
30
|
+
.option("--label <label>", "short label for the herdr workspace/pane")
|
|
31
|
+
.option("--spawned-by <agentId>", "id of the agent that spawned this one (omit if spawned by the clerk directly)", parseIntOpt)
|
|
32
|
+
.option("--wait", "wait for the agent to leave 'working' after the initial prompt")
|
|
33
|
+
.action((opts) => {
|
|
34
|
+
const project = getProjectByName(opts.project);
|
|
35
|
+
const db = getDb();
|
|
36
|
+
if (!AUTHORIZATION_BASES.includes(opts.authorization)) {
|
|
37
|
+
throw new Error(`--authorization must be one of: ${AUTHORIZATION_BASES.join(" | ")}`);
|
|
38
|
+
}
|
|
39
|
+
const authorization = opts.authorization;
|
|
40
|
+
if (opts.roadmapItem !== undefined) {
|
|
41
|
+
const item = db
|
|
42
|
+
.prepare("SELECT id, project_id FROM roadmap WHERE id = ?")
|
|
43
|
+
.get(opts.roadmapItem);
|
|
44
|
+
if (!item)
|
|
45
|
+
throw new Error(`no roadmap item #${opts.roadmapItem}`);
|
|
46
|
+
if (item.project_id !== project.id) {
|
|
47
|
+
throw new Error(`roadmap item #${opts.roadmapItem} belongs to a different project`);
|
|
48
|
+
}
|
|
49
|
+
// C6: refuse a second live agent on an item that already has one
|
|
50
|
+
// without explicit confirmation. done/idle rows are terminal or
|
|
51
|
+
// dormant and don't count as live.
|
|
52
|
+
const live = db
|
|
53
|
+
.prepare(`SELECT id FROM agents
|
|
54
|
+
WHERE roadmap_item_id = ? AND status IN ('working', 'blocked')`)
|
|
55
|
+
.all(opts.roadmapItem);
|
|
56
|
+
if (live.length > 0 && !opts.confirmDuplicate) {
|
|
57
|
+
throw new Error(`roadmap item #${opts.roadmapItem} already has a live agent ` +
|
|
58
|
+
`(${live.map((a) => `#${a.id}`).join(", ")}) — a second dispatch ` +
|
|
59
|
+
`needs the user's explicit word; pass --confirm-duplicate only ` +
|
|
60
|
+
`if they gave it (C6)`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (opts.spawnedBy !== undefined) {
|
|
64
|
+
const spawner = db
|
|
65
|
+
.prepare("SELECT id FROM agents WHERE id = ?")
|
|
66
|
+
.get(opts.spawnedBy);
|
|
67
|
+
if (!spawner)
|
|
68
|
+
throw new Error(`no agent #${opts.spawnedBy}`);
|
|
69
|
+
}
|
|
70
|
+
if (!CODING_AGENT_KINDS.includes(opts.kind)) {
|
|
71
|
+
throw new Error(`--kind must be one of: ${CODING_AGENT_KINDS.join(", ")}`);
|
|
72
|
+
}
|
|
73
|
+
const kind = opts.kind;
|
|
74
|
+
const label = opts.label ?? deriveLabel(opts.task);
|
|
75
|
+
const worktreePath = leaseWorktree({
|
|
76
|
+
repoCwd: project.local_clone_path,
|
|
77
|
+
leaseHolder: `ledger:${project.name}`,
|
|
78
|
+
});
|
|
79
|
+
let pane;
|
|
80
|
+
try {
|
|
81
|
+
pane = openDispatchPane(project, worktreePath, label);
|
|
82
|
+
// For kind === "claude", herdr.startAgent itself detects and
|
|
83
|
+
// dismisses Claude Code's one-time "do you trust this folder?"
|
|
84
|
+
// dialog when it blocks readiness (see its docstring in
|
|
85
|
+
// herdr.ts and DECISIONS.md) — a blind post-hoc Enter here
|
|
86
|
+
// cannot work, since `startAgent` only returns successfully
|
|
87
|
+
// once the agent is actually ready, and that success never
|
|
88
|
+
// comes while the dialog is still up.
|
|
89
|
+
herdr.startAgent({
|
|
90
|
+
name: label,
|
|
91
|
+
kind,
|
|
92
|
+
pane: pane.paneId,
|
|
93
|
+
...(kind === "claude" ? { extraArgs: CLAUDE_BYPASS_ARGS } : {}),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
// Best-effort cleanup: don't leave a dangling herdr pane pointed
|
|
98
|
+
// at a worktree that's already back in the treehouse pool, and
|
|
99
|
+
// don't record an agents row for a dispatch that never actually
|
|
100
|
+
// started. Only close the whole workspace if this dispatch just
|
|
101
|
+
// created it — if it reused the project's existing workspace,
|
|
102
|
+
// other tabs/agents may be live in it, so close just this tab.
|
|
103
|
+
if (pane) {
|
|
104
|
+
try {
|
|
105
|
+
if (pane.createdNewWorkspace)
|
|
106
|
+
herdr.closeWorkspace(pane.workspaceId);
|
|
107
|
+
else
|
|
108
|
+
herdr.closeTab(pane.tabId);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// best-effort
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
returnWorktree(worktreePath);
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
// Only now record the project's workspace — a failed first-dispatch
|
|
118
|
+
// attempt above shouldn't leave the project pointing at a workspace
|
|
119
|
+
// that was just closed as part of that failure's cleanup.
|
|
120
|
+
if (pane.createdNewWorkspace) {
|
|
121
|
+
db.prepare(`UPDATE projects SET herdr_workspace = ? WHERE id = ?`).run(pane.workspaceId, project.id);
|
|
122
|
+
}
|
|
123
|
+
// The coding agent process is now running. Record it *before*
|
|
124
|
+
// sending the task, so the task text itself can tell the agent its
|
|
125
|
+
// own row id — that's what lets the reporting contract below
|
|
126
|
+
// reference a real `ledger agent update <id> ...` command.
|
|
127
|
+
const row = db
|
|
128
|
+
.prepare(`INSERT INTO agents (
|
|
129
|
+
project_id, roadmap_item_id, task_description, worktree_path,
|
|
130
|
+
herdr_workspace, herdr_tab, herdr_pane, coding_agent, authorization_basis, spawned_by
|
|
131
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
132
|
+
RETURNING *`)
|
|
133
|
+
.get(project.id, opts.roadmapItem ?? null, opts.task, worktreePath, pane.workspaceId, pane.tabId, pane.paneId, kind, authorization, opts.spawnedBy ?? null);
|
|
134
|
+
db.prepare(`INSERT INTO events (agent_id, event_type, payload) VALUES (?, 'dispatched', ?)`).run(row.id, JSON.stringify({ task: opts.task, kind, authorization }));
|
|
135
|
+
try {
|
|
136
|
+
herdr.promptAgent({
|
|
137
|
+
target: pane.paneId,
|
|
138
|
+
text: buildTaskPrompt(row.id, opts.task, project),
|
|
139
|
+
wait: opts.wait ?? false,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
// The agent process exists and is now tracked — don't tear any
|
|
144
|
+
// of that down (the workspace/lease teardown above is only for
|
|
145
|
+
// "never actually started" failures). Leave the row for the
|
|
146
|
+
// clerk to investigate; `agent release` can reclaim it manually.
|
|
147
|
+
db.prepare(`INSERT INTO events (agent_id, event_type, payload) VALUES (?, 'dispatch_prompt_failed', ?)`).run(row.id, JSON.stringify({ error: err.message }));
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
printJson(row);
|
|
151
|
+
});
|
|
152
|
+
agent
|
|
153
|
+
.command("list")
|
|
154
|
+
.description("list dispatched agents")
|
|
155
|
+
.option("--status <status>", `filter by status (${VALID_STATUSES.join("|")})`)
|
|
156
|
+
.option("--project <name>", "filter by project name")
|
|
157
|
+
.option("--json", "output as JSON")
|
|
158
|
+
.action((opts) => {
|
|
159
|
+
const db = getDb();
|
|
160
|
+
let sql = "SELECT * FROM agents WHERE 1=1";
|
|
161
|
+
const params = [];
|
|
162
|
+
if (opts.status) {
|
|
163
|
+
assertValidStatus(opts.status);
|
|
164
|
+
sql += " AND status = ?";
|
|
165
|
+
params.push(opts.status);
|
|
166
|
+
}
|
|
167
|
+
if (opts.project) {
|
|
168
|
+
const project = getProjectByName(opts.project);
|
|
169
|
+
sql += " AND project_id = ?";
|
|
170
|
+
params.push(project.id);
|
|
171
|
+
}
|
|
172
|
+
sql += " ORDER BY id DESC";
|
|
173
|
+
const rows = db.prepare(sql).all(...params);
|
|
174
|
+
if (opts.json)
|
|
175
|
+
printJson(rows);
|
|
176
|
+
else
|
|
177
|
+
printTable(rows);
|
|
178
|
+
});
|
|
179
|
+
agent
|
|
180
|
+
.command("get <id>")
|
|
181
|
+
.description("show an agent by id")
|
|
182
|
+
.action((id) => {
|
|
183
|
+
printJson(getAgentById(Number(id)));
|
|
184
|
+
});
|
|
185
|
+
agent
|
|
186
|
+
.command("update <id>")
|
|
187
|
+
.description("update an agent's status and/or outcome")
|
|
188
|
+
.option("--status <status>", `new status (${VALID_STATUSES.join("|")})`)
|
|
189
|
+
.option("--outcome <text>", "pr_url | branch name | report path")
|
|
190
|
+
.action((id, opts) => {
|
|
191
|
+
if (opts.status)
|
|
192
|
+
assertValidStatus(opts.status);
|
|
193
|
+
const db = getDb();
|
|
194
|
+
const existing = getAgentById(Number(id));
|
|
195
|
+
const row = db
|
|
196
|
+
.prepare(`UPDATE agents
|
|
197
|
+
SET status = ?, outcome = ?, updated_at = datetime('now')
|
|
198
|
+
WHERE id = ?
|
|
199
|
+
RETURNING *`)
|
|
200
|
+
.get(opts.status ?? existing.status, opts.outcome ?? existing.outcome, Number(id));
|
|
201
|
+
if (opts.status || opts.outcome) {
|
|
202
|
+
db.prepare(`INSERT INTO events (agent_id, event_type, payload) VALUES (?, 'manual_update', ?)`).run(row.id, JSON.stringify({ status: opts.status, outcome: opts.outcome }));
|
|
203
|
+
}
|
|
204
|
+
printJson(row);
|
|
205
|
+
});
|
|
206
|
+
agent
|
|
207
|
+
.command("release <id>")
|
|
208
|
+
.description("return the agent's leased worktree to the treehouse pool and close its " +
|
|
209
|
+
"herdr tab. Refuses unless the work in the worktree is proven durable " +
|
|
210
|
+
"(C3 in DECISIONS.md); --force is the user's explicit authorization " +
|
|
211
|
+
"to discard. Does not change agents.status — do that separately with " +
|
|
212
|
+
"'agent update' if appropriate.")
|
|
213
|
+
.option("--force", "explicit user authorization to discard whatever is in the worktree — " +
|
|
214
|
+
"bypasses the survival proof; never a repair path for a bad proof (C3)")
|
|
215
|
+
.action((id, opts) => {
|
|
216
|
+
const db = getDb();
|
|
217
|
+
const row = getAgentById(Number(id));
|
|
218
|
+
const proof = survivalProof(row.worktree_path);
|
|
219
|
+
if (proof.state !== "durable" && !opts.force) {
|
|
220
|
+
// Fail closed: record the refusal and hand the decision to the
|
|
221
|
+
// clerk→user channel. Never auto-discard work we couldn't prove
|
|
222
|
+
// survives somewhere durable.
|
|
223
|
+
db.prepare(`INSERT INTO events (agent_id, event_type, payload) VALUES (?, 'release_refused', ?)`).run(row.id, JSON.stringify({ survival: proof }));
|
|
224
|
+
const what = proof.state === "at-risk"
|
|
225
|
+
? `${proof.uncommittedChanges ?? 0} uncommitted change(s) and/or ` +
|
|
226
|
+
`${proof.unpushedCommits ?? 0} commit(s) on branch ` +
|
|
227
|
+
`'${proof.branch}' not on any remote`
|
|
228
|
+
: (proof.reason ?? "unknown");
|
|
229
|
+
throw new Error(`refusing to release agent #${row.id}: its work is ${proof.state} — ` +
|
|
230
|
+
`${what}. Escalate to the user before discarding; pass --force ` +
|
|
231
|
+
`only with the user's explicit authorization to discard this ` +
|
|
232
|
+
`work (C3).`);
|
|
233
|
+
}
|
|
234
|
+
returnWorktree(row.worktree_path);
|
|
235
|
+
try {
|
|
236
|
+
// Close just this agent's own tab, never the whole workspace —
|
|
237
|
+
// other agents working the same project may have live tabs in it
|
|
238
|
+
// (see "one herdr workspace per project" in DECISIONS.md).
|
|
239
|
+
herdr.closeTab(row.herdr_tab);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// Tab may already be closed (e.g. user closed the pane manually)
|
|
243
|
+
// — releasing the worktree lease is what matters.
|
|
244
|
+
}
|
|
245
|
+
db.prepare(`INSERT INTO events (agent_id, event_type, payload) VALUES (?, 'released', ?)`).run(row.id, JSON.stringify({ survival: proof, forced: opts.force ?? false }));
|
|
246
|
+
printJson({
|
|
247
|
+
released: true,
|
|
248
|
+
agent_id: row.id,
|
|
249
|
+
worktree_path: row.worktree_path,
|
|
250
|
+
survival: proof,
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
export function getAgentById(id) {
|
|
255
|
+
const row = getDb().prepare("SELECT * FROM agents WHERE id = ?").get(id);
|
|
256
|
+
if (!row)
|
|
257
|
+
throw new Error(`no agent #${id}`);
|
|
258
|
+
return row;
|
|
259
|
+
}
|
|
260
|
+
function assertValidStatus(status) {
|
|
261
|
+
if (!VALID_STATUSES.includes(status)) {
|
|
262
|
+
throw new Error(`--status must be one of: ${VALID_STATUSES.join(", ")}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function parseIntOpt(value) {
|
|
266
|
+
const n = Number.parseInt(value, 10);
|
|
267
|
+
if (Number.isNaN(n))
|
|
268
|
+
throw new Error(`not a valid integer: ${value}`);
|
|
269
|
+
return n;
|
|
270
|
+
}
|
|
271
|
+
function gitIn(cwd, args) {
|
|
272
|
+
return execFileSync("git", args, { cwd, encoding: "utf8" });
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Clerk gate C3 (DECISIONS.md, 2026-08-23): before `agent release` returns a
|
|
276
|
+
* worktree that may hold work, establish a three-state proof of where that work stands:
|
|
277
|
+
*
|
|
278
|
+
* durable — nothing uncommitted, and every commit on this worktree's
|
|
279
|
+
* checked-out branch is already on a remote (pushed or merged)
|
|
280
|
+
* → the work survives the worktree being reset away; safe to
|
|
281
|
+
* auto-return.
|
|
282
|
+
* at-risk — uncommitted changes and/or commits on a local ref that no
|
|
283
|
+
* remote has → returning the worktree (treehouse `return
|
|
284
|
+
* --force` resets it) would destroy them.
|
|
285
|
+
* unprovable— git failed, or the worktree can't be inspected → fail
|
|
286
|
+
* closed, treated like at-risk.
|
|
287
|
+
*
|
|
288
|
+
* Only the checked-out branch is inspected: all worktrees of one repo share
|
|
289
|
+
* refs, so counting every local branch would misattribute other agents'
|
|
290
|
+
* unpushed work to this release.
|
|
291
|
+
*/
|
|
292
|
+
function survivalProof(worktreePath) {
|
|
293
|
+
if (!existsSync(worktreePath)) {
|
|
294
|
+
return {
|
|
295
|
+
state: "durable",
|
|
296
|
+
reason: "worktree path no longer exists — nothing to lose",
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
let status;
|
|
300
|
+
let branch;
|
|
301
|
+
try {
|
|
302
|
+
status = gitIn(worktreePath, ["status", "--porcelain"]);
|
|
303
|
+
branch = gitIn(worktreePath, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
return {
|
|
307
|
+
state: "unprovable",
|
|
308
|
+
reason: `git failed in ${worktreePath}: ${err.message}`,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
let unpushed;
|
|
312
|
+
try {
|
|
313
|
+
// `--remotes` expands to local remote-tracking refs only (no network
|
|
314
|
+
// call). A commit counts as durable iff some remote already has it —
|
|
315
|
+
// including the merged case, where a remote branch contains it. With
|
|
316
|
+
// no remotes at all (a from-scratch local-only project) every commit
|
|
317
|
+
// counts as unpushed, which is right: there is nowhere for it to be
|
|
318
|
+
// durable yet.
|
|
319
|
+
unpushed = Number.parseInt(gitIn(worktreePath, ["rev-list", "HEAD", "--not", "--remotes", "--count"]).trim(), 10);
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
return {
|
|
323
|
+
state: "unprovable",
|
|
324
|
+
reason: `git failed in ${worktreePath}: ${err.message}`,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
const uncommitted = status.trim() === "" ? 0 : status.trim().split("\n").length;
|
|
328
|
+
if (uncommitted === 0 && unpushed === 0) {
|
|
329
|
+
return { state: "durable", branch, uncommittedChanges: 0, unpushedCommits: 0 };
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
state: "at-risk",
|
|
333
|
+
branch,
|
|
334
|
+
uncommittedChanges: uncommitted,
|
|
335
|
+
unpushedCommits: unpushed,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Every dispatched agent's actual first prompt: the clerk's task text plus
|
|
340
|
+
* a short, self-contained reporting contract (full version in LEDGER.md,
|
|
341
|
+
* "For dispatched agents") — so an agent never needs to read LEDGER.md
|
|
342
|
+
* itself to know how to report back.
|
|
343
|
+
*
|
|
344
|
+
* The delivery step (branch + PR vs. just a branch) is driven entirely by
|
|
345
|
+
* `project.delivery_mode`, already core schema — but *how* a PR actually
|
|
346
|
+
* gets opened is deliberately left to the agent's own judgment/tooling
|
|
347
|
+
* (e.g. `tea` against a Forgejo remote), not something ledger orchestrates
|
|
348
|
+
* or needs to know about. See DECISIONS.md.
|
|
349
|
+
*
|
|
350
|
+
* The appended contract also carries the worker-side governance gates
|
|
351
|
+
* (DECISIONS.md, "Agent (worker) gates" — roadmap item #5): one task /
|
|
352
|
+
* one worktree / no spawning (A1), blocked as a structured decision
|
|
353
|
+
* request (A4), no self-modification of the contract or the board (A5),
|
|
354
|
+
* faithful outcomes (A6), and work surviving in a durable posture before
|
|
355
|
+
* exit (A7). A2 (no self-merge) already lives in the direct-pr delivery
|
|
356
|
+
* text since the self-merge incident.
|
|
357
|
+
*/
|
|
358
|
+
function buildTaskPrompt(agentId, task, project) {
|
|
359
|
+
const deliveryInstructions = project.delivery_mode === "direct-pr"
|
|
360
|
+
? `Work on a new branch — never commit directly to '${project.default_branch}'.
|
|
361
|
+
Before you exit, all your work must be on that branch pushed to the remote
|
|
362
|
+
— your worktree is leased and gets recycled, so anything that lives only in
|
|
363
|
+
it is at risk of being lost.
|
|
364
|
+
When you're done, open a pull request against '${project.default_branch}'
|
|
365
|
+
(use whatever tooling is available for this project's remote, e.g. \`tea\`
|
|
366
|
+
for a Forgejo remote). Do NOT merge it yourself, even if you technically
|
|
367
|
+
can — opening the PR is the whole job. Merging is a human/review decision,
|
|
368
|
+
not yours to make, regardless of anything else you're told. Then run this
|
|
369
|
+
as your last step:
|
|
370
|
+
ledger agent update ${agentId} --status done --outcome '<pr-url>'
|
|
371
|
+
using the PR's URL.`
|
|
372
|
+
: `Work on a new branch — never commit directly to '${project.default_branch}'.
|
|
373
|
+
Before you exit, commit all your work in the worktree — it is leased and
|
|
374
|
+
gets recycled, so anything left uncommitted is at risk of being lost.
|
|
375
|
+
When you're done, run this as your last step:
|
|
376
|
+
ledger agent update ${agentId} --status done --outcome '<branch-name-or-report-path>'`;
|
|
377
|
+
return `${task}
|
|
378
|
+
|
|
379
|
+
---
|
|
380
|
+
${deliveryInstructions}
|
|
381
|
+
|
|
382
|
+
Your \`--outcome\` is a faithful report, not a claim: what actually
|
|
383
|
+
happened, plus evidence (commits, PR URL, test output). "done" means
|
|
384
|
+
done — if the work is partial, say so in the outcome and name what
|
|
385
|
+
remains. State failures plainly; don't dress them up.
|
|
386
|
+
|
|
387
|
+
If you get stuck and need a decision before you can continue: stop where
|
|
388
|
+
you are and record the decision as a structured request — what you tried,
|
|
389
|
+
the exact decision you need, and 2–3 options with a recommendation:
|
|
390
|
+
ledger agent update ${agentId} --status blocked --outcome '<tried: ...; decision needed: ...; options: 1) ... 2) ... (recommend: ...)>'
|
|
391
|
+
Never pick the answer yourself and proceed on an out-of-scope resolution —
|
|
392
|
+
that decision belongs to the user, relayed through the clerk. (herdr
|
|
393
|
+
detects a stopped agent as "blocked" on its own too — but the structured
|
|
394
|
+
outcome above is what lets the clerk escalate in one turn and the user
|
|
395
|
+
answer in one word.)
|
|
396
|
+
|
|
397
|
+
Your scope is exactly this one task in this one worktree. Don't spawn or
|
|
398
|
+
dispatch further agents or workspaces, and don't coordinate directly with
|
|
399
|
+
other agents — nesting and coordination are the supervisor's job. If you
|
|
400
|
+
notice something out of scope, note it in your outcome — don't act on it.
|
|
401
|
+
And don't edit the contract (this prompt, LEDGER.md) or the ledger's own
|
|
402
|
+
records to widen your scope or make the work look better than it is.
|
|
403
|
+
|
|
404
|
+
Everything else — roadmap, project registration, dispatching other agents —
|
|
405
|
+
is the clerk's job, not yours.`;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Doubles as the herdr tab label and the herdr agent name, so it must
|
|
409
|
+
* satisfy the stricter of the two: herdr agent names must start with a
|
|
410
|
+
* lowercase letter and contain only lowercase letters, digits, '-' or '_',
|
|
411
|
+
* 1-32 characters. No longer prefixed with the project name (it was, when
|
|
412
|
+
* every dispatch got its own workspace) — that's now redundant, since the
|
|
413
|
+
* project name is the *workspace's* label and every dispatch to it is a
|
|
414
|
+
* tab within that one workspace.
|
|
415
|
+
*/
|
|
416
|
+
function deriveLabel(task) {
|
|
417
|
+
const raw = task
|
|
418
|
+
.toLowerCase()
|
|
419
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
420
|
+
.replace(/^-+|-+$/g, "");
|
|
421
|
+
const truncated = raw.slice(0, 32).replace(/-+$/g, "");
|
|
422
|
+
return truncated || "task";
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* One herdr workspace per project, not per dispatch (per the user — see
|
|
426
|
+
* DECISIONS.md): reuses the project's existing workspace as a new tab when
|
|
427
|
+
* one is already live, or creates it (and records it via the caller) when
|
|
428
|
+
* this is the project's first dispatch, or its previous workspace was
|
|
429
|
+
* closed (e.g. by the user) since the last dispatch.
|
|
430
|
+
*/
|
|
431
|
+
function openDispatchPane(project, cwd, label) {
|
|
432
|
+
if (project.herdr_workspace && herdr.workspaceExists(project.herdr_workspace)) {
|
|
433
|
+
const tab = herdr.createTab({
|
|
434
|
+
workspace: project.herdr_workspace,
|
|
435
|
+
cwd,
|
|
436
|
+
label,
|
|
437
|
+
focus: false,
|
|
438
|
+
});
|
|
439
|
+
return {
|
|
440
|
+
workspaceId: project.herdr_workspace,
|
|
441
|
+
tabId: tab.tab.tab_id,
|
|
442
|
+
paneId: tab.root_pane.pane_id,
|
|
443
|
+
createdNewWorkspace: false,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
const ws = herdr.createWorkspace({ cwd, label: project.name, focus: false });
|
|
447
|
+
// workspace create doesn't take a separate tab label — its root tab
|
|
448
|
+
// gets herdr's own default ("1"); rename it to match every later tab's
|
|
449
|
+
// task-based labeling for consistency.
|
|
450
|
+
herdr.renameTab(ws.tab.tab_id, label);
|
|
451
|
+
return {
|
|
452
|
+
workspaceId: ws.workspace.workspace_id,
|
|
453
|
+
tabId: ws.tab.tab_id,
|
|
454
|
+
paneId: ws.root_pane.pane_id,
|
|
455
|
+
createdNewWorkspace: true,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { getDb } from "../../db/client.js";
|
|
2
|
+
import { HerdrError, readPane } from "../../lib/herdr.js";
|
|
3
|
+
import { printJson } from "../format.js";
|
|
4
|
+
import { getProjectByName } from "./projects.js";
|
|
5
|
+
/** Default tail length for each idle agent's pane read (A8 liveness triage). */
|
|
6
|
+
const IDLE_PANE_TAIL_DEFAULT_LINES = 25;
|
|
7
|
+
/**
|
|
8
|
+
* Reads the tail of every idle agent's pane, in one shot. Never throws —
|
|
9
|
+
* per C7, an observation failure is not evidence, so a pane read failing
|
|
10
|
+
* must never fail catch-up itself. A structured HerdrError (pane/tab gone)
|
|
11
|
+
* is per-agent: recorded on that entry only. Anything else (couldn't even
|
|
12
|
+
* get a herdr response — socket down) is systemic: recorded once as a
|
|
13
|
+
* global note, and no further pane reads are attempted (they'd all fail the
|
|
14
|
+
* same way) — every remaining idle agent gets that same note instead of
|
|
15
|
+
* repeating the failure per pane.
|
|
16
|
+
*/
|
|
17
|
+
function readIdlePanes(agents, lines) {
|
|
18
|
+
const entries = [];
|
|
19
|
+
let globalNote = null;
|
|
20
|
+
for (const agent of agents) {
|
|
21
|
+
if (globalNote) {
|
|
22
|
+
entries.push({ agent, pane_tail: null, pane_read_error: globalNote });
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const tail = readPane(agent.herdr_pane, { source: "recent", lines, quiet: true });
|
|
27
|
+
entries.push({ agent, pane_tail: tail, pane_read_error: null });
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
if (err instanceof HerdrError) {
|
|
31
|
+
entries.push({
|
|
32
|
+
agent,
|
|
33
|
+
pane_tail: null,
|
|
34
|
+
pane_read_error: `${err.code}: ${err.message}`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
// Not even a structured herdr error — treat as the whole socket
|
|
39
|
+
// being unreachable rather than this one pane being gone.
|
|
40
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
41
|
+
globalNote = `herdr pane reads unavailable: ${message}`;
|
|
42
|
+
entries.push({ agent, pane_tail: null, pane_read_error: globalNote });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { entries, globalNote };
|
|
47
|
+
}
|
|
48
|
+
const IDLE_LABEL_MAX_LENGTH = 80;
|
|
49
|
+
/** First line of a (possibly long, multi-paragraph) task description, truncated. */
|
|
50
|
+
function shortLabel(taskDescription) {
|
|
51
|
+
const firstLine = taskDescription.split("\n")[0] ?? "";
|
|
52
|
+
return firstLine.length > IDLE_LABEL_MAX_LENGTH
|
|
53
|
+
? `${firstLine.slice(0, IDLE_LABEL_MAX_LENGTH - 1)}…`
|
|
54
|
+
: firstLine;
|
|
55
|
+
}
|
|
56
|
+
function parseIdlePaneLinesOpt(value) {
|
|
57
|
+
const n = Number.parseInt(value, 10);
|
|
58
|
+
if (Number.isNaN(n) || n < 0) {
|
|
59
|
+
throw new Error(`--idle-pane-lines must be a non-negative integer: ${value}`);
|
|
60
|
+
}
|
|
61
|
+
return n;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The entire "what's going on" operation from DESIGN.md's session-start
|
|
65
|
+
* flow: blocked agents needing a decision, events since the last time a
|
|
66
|
+
* clerk looked, and the shape of what's still active on the roadmap.
|
|
67
|
+
* Costs a handful of rows, not a document to re-parse.
|
|
68
|
+
*/
|
|
69
|
+
export function registerCatchupCommand(program) {
|
|
70
|
+
program
|
|
71
|
+
.command("catchup")
|
|
72
|
+
.description("session-start summary: blocked agents, new events, active roadmap")
|
|
73
|
+
.option("--project <name>", "scope roadmap (and optionally agents) to one project")
|
|
74
|
+
.option("--idle-pane-lines <n>", "lines of pane tail to show per idle agent (A8 liveness triage); 0 disables pane reads", parseIdlePaneLinesOpt, IDLE_PANE_TAIL_DEFAULT_LINES)
|
|
75
|
+
.option("--json", "output as JSON (default: human-readable)")
|
|
76
|
+
.action((opts) => {
|
|
77
|
+
const db = getDb();
|
|
78
|
+
const project = opts.project ? getProjectByName(opts.project) : undefined;
|
|
79
|
+
const firstClerk = db
|
|
80
|
+
.prepare("SELECT * FROM first_clerk WHERE id = 1")
|
|
81
|
+
.get();
|
|
82
|
+
const since = firstClerk?.last_seen ?? firstClerk?.claimed_at ?? null;
|
|
83
|
+
let blockedSql = "SELECT * FROM agents WHERE status = 'blocked'";
|
|
84
|
+
const blockedParams = [];
|
|
85
|
+
if (project) {
|
|
86
|
+
blockedSql += " AND project_id = ?";
|
|
87
|
+
blockedParams.push(project.id);
|
|
88
|
+
}
|
|
89
|
+
const blocked = db.prepare(blockedSql).all(...blockedParams);
|
|
90
|
+
// A8 (DECISIONS.md): an idle agent with unfinished work is suspect —
|
|
91
|
+
// it may have died on a usage limit. Surface every idle agent plus
|
|
92
|
+
// the tail of what it last showed, so that check doesn't require
|
|
93
|
+
// going to read the pane by hand.
|
|
94
|
+
let idleSql = "SELECT * FROM agents WHERE status = 'idle'";
|
|
95
|
+
const idleParams = [];
|
|
96
|
+
if (project) {
|
|
97
|
+
idleSql += " AND project_id = ?";
|
|
98
|
+
idleParams.push(project.id);
|
|
99
|
+
}
|
|
100
|
+
const idleAgents = db.prepare(idleSql).all(...idleParams);
|
|
101
|
+
const { entries: idle, globalNote: idlePaneGlobalNote } = opts.idlePaneLines > 0
|
|
102
|
+
? readIdlePanes(idleAgents, opts.idlePaneLines)
|
|
103
|
+
: {
|
|
104
|
+
entries: idleAgents.map((agent) => ({
|
|
105
|
+
agent,
|
|
106
|
+
pane_tail: null,
|
|
107
|
+
pane_read_error: null,
|
|
108
|
+
})),
|
|
109
|
+
globalNote: null,
|
|
110
|
+
};
|
|
111
|
+
let events = [];
|
|
112
|
+
if (since) {
|
|
113
|
+
let eventsSql = "SELECT * FROM events WHERE created_at > ?";
|
|
114
|
+
const eventsParams = [since];
|
|
115
|
+
if (project) {
|
|
116
|
+
eventsSql +=
|
|
117
|
+
" AND agent_id IN (SELECT id FROM agents WHERE project_id = ?)";
|
|
118
|
+
eventsParams.push(project.id);
|
|
119
|
+
}
|
|
120
|
+
eventsSql += " ORDER BY created_at";
|
|
121
|
+
events = db.prepare(eventsSql).all(...eventsParams);
|
|
122
|
+
}
|
|
123
|
+
let roadmapSql = "SELECT * FROM roadmap WHERE status NOT IN ('done', 'dropped')";
|
|
124
|
+
const roadmapParams = [];
|
|
125
|
+
if (project) {
|
|
126
|
+
roadmapSql += " AND project_id = ?";
|
|
127
|
+
roadmapParams.push(project.id);
|
|
128
|
+
}
|
|
129
|
+
// Coarse triage order (DECISIONS.md, 2026-08-23): priority
|
|
130
|
+
// high -> normal -> low, ties by id. Stale priorities mis-sort
|
|
131
|
+
// this list (visible here) without blocking anything.
|
|
132
|
+
roadmapSql +=
|
|
133
|
+
" ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, id";
|
|
134
|
+
const roadmap = db.prepare(roadmapSql).all(...roadmapParams);
|
|
135
|
+
if (firstClerk) {
|
|
136
|
+
db.prepare("UPDATE first_clerk SET last_seen = datetime('now') WHERE id = 1").run();
|
|
137
|
+
}
|
|
138
|
+
const summary = { since, blocked, idle, idle_pane_read_note: idlePaneGlobalNote, events, roadmap };
|
|
139
|
+
if (opts.json) {
|
|
140
|
+
printJson(summary);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
console.log(`Since: ${since ?? "(no prior catchup — showing full roadmap only)"}`);
|
|
144
|
+
console.log(`\nBlocked (${blocked.length}):`);
|
|
145
|
+
for (const a of blocked) {
|
|
146
|
+
console.log(` #${a.id} [${a.project_id}] ${a.task_description}`);
|
|
147
|
+
}
|
|
148
|
+
console.log(`\nIdle (${idle.length}):`);
|
|
149
|
+
if (idlePaneGlobalNote) {
|
|
150
|
+
console.log(` (${idlePaneGlobalNote} — pane tails skipped below)`);
|
|
151
|
+
}
|
|
152
|
+
for (const { agent, pane_tail, pane_read_error } of idle) {
|
|
153
|
+
console.log(` #${agent.id} [${agent.project_id}] pane=${agent.herdr_pane} ` +
|
|
154
|
+
shortLabel(agent.task_description));
|
|
155
|
+
if (pane_tail !== null) {
|
|
156
|
+
for (const line of pane_tail.split("\n")) {
|
|
157
|
+
console.log(` ${line}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else if (pane_read_error && !idlePaneGlobalNote) {
|
|
161
|
+
console.log(` pane unreadable: ${pane_read_error}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
console.log(`\nEvents (${events.length}):`);
|
|
165
|
+
for (const e of events) {
|
|
166
|
+
console.log(` ${e.created_at} agent#${e.agent_id} ${e.event_type}`);
|
|
167
|
+
}
|
|
168
|
+
console.log(`\nRoadmap in flight (${roadmap.length}):`);
|
|
169
|
+
for (const r of roadmap) {
|
|
170
|
+
const indent = r.parent_id ? " " : " ";
|
|
171
|
+
console.log(`${indent}#${r.id} [${r.status}] ${r.title}`);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|