@devwithdavid/ledger 0.1.5 → 0.2.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 CHANGED
@@ -155,8 +155,8 @@ every agent currently `idle` together with the tail of its pane's recent
155
155
  output (A8 liveness triage — did it ask a question, hit a usage error, or
156
156
  go quiet mid-task?), every `events` row since the last time any clerk ran
157
157
  `catchup` (tracked in `first_clerk.last_seen`), and every roadmap item not
158
- yet `done`/`dropped`, ordered by priority `high → normal → low` (ties by
159
- id).
158
+ yet in a terminal status (`merged`/`completed`/`discarded`/`dropped`),
159
+ ordered by priority `high → normal → low` (ties by id).
160
160
  This is the entire "what's going on" operation from `DESIGN.md` — read this
161
161
  instead of any prose file, every session.
162
162
 
@@ -170,6 +170,18 @@ repeating the same failure under every idle agent. `--json` includes the
170
170
  same data as an `idle` array (`agent`, `pane_tail`, `pane_read_error`) plus
171
171
  top-level `idle_pane_read_note` for the whole-socket case.
172
172
 
173
+ `catchup` also lists every `done` agent whose herdr pane is still actually
174
+ alive (item 88) — a real candidate for `agent followup` below, distinct from
175
+ a `done` agent whose worktree/pane is genuinely gone. This is a fresh
176
+ per-agent `herdr pane get`, same A8-style observation idle pane tails
177
+ already use, not a new `agents.status` value: a `done` agent whose pane
178
+ resolves shows up here; one that doesn't (pane/tab/workspace closed) is
179
+ silently excluded, not reported as an error, per C7. `--json`'s
180
+ `done_with_live_pane` array carries `{agent, herdr_agent_status}` per entry;
181
+ `done_pane_read_note` mirrors `idle_pane_read_note` for the whole-socket-down
182
+ case. Governed by the same `--idle-pane-lines` flag as idle tails — `0`
183
+ disables both.
184
+
173
185
  `catchup` also checks (item 42) whether a newer `@devwithdavid/ledger` npm
174
186
  version exists — a plain-text notice after the normal output in
175
187
  human-readable mode, or the top-level `update_available` JSON key
@@ -261,8 +273,33 @@ ledger roadmap list --project <name> [--status <status>] [--all] [--json]
261
273
  ledger roadmap update <id> [--status <status>] [--title <title>] [--description <text>] [--priority <high|normal|low>]
262
274
  ```
263
275
 
264
- Statuses: `planned | in_progress | blocked | done | dropped`. `--parent`
265
- nests a sub-item under an existing roadmap item (arbitrary depth).
276
+ Statuses (roadmap #89, 2026-09-24 — replaces the old bare `done`):
277
+ non-terminal (workflow) `planned | in_progress | blocked | in_review`;
278
+ terminal (final) `merged | completed | discarded | dropped`. `in_review`
279
+ means code is written, pushed, and a PR/MR is open — work on the item
280
+ itself is done, it's just waiting on a human review/merge decision.
281
+ `merged` is the terminal state for anything that produced a git artifact
282
+ and landed on the default branch; `discarded` is the terminal state for a
283
+ PR/MR that existed but was reviewed and rejected/closed without merging —
284
+ distinct from `dropped` (abandoned/never attempted, no work product
285
+ either way) because it tells you something different at a glance: effort
286
+ was spent and a real decision was made against it. `completed` is the
287
+ terminal state for items that never have/need a git artifact at all
288
+ (decisions made, investigations concluded).
289
+
290
+ **Observation-required constraint (same principle as gate C7):** `merged`
291
+ and `discarded` must only ever be set from an actual observed check
292
+ against git/the PR host (e.g. `git log <default-branch> --grep`, or
293
+ checking the MR's merged/closed state via the remote's API/CLI) — never
294
+ from an agent's self-reported outcome text alone, and never inferred by
295
+ you without checking. An agent claiming "I merged it" or "MR is up" is
296
+ not evidence of `merged`, any more than a quiet pane is evidence an agent
297
+ is `idle` under C7. `in_review` can be set more loosely — a PR/MR URL in
298
+ an agent's outcome is reasonable evidence code is up for review — since
299
+ it isn't terminal.
300
+
301
+ `--parent` nests a sub-item under an existing roadmap item (arbitrary
302
+ depth).
266
303
 
267
304
  Priority: `high | normal | low`, default `normal` (also a column on every
268
305
  row: JSON output and `list`'s table both show it). It is a coarse triage
@@ -282,8 +319,9 @@ rather than deciding unilaterally or always making them write it themselves
282
319
  constraints, what's explicitly out of scope) — that's what you'll turn into
283
320
  `--task` text at dispatch time via `--roadmap-item <id>`.
284
321
 
285
- `roadmap list` excludes `done`/`dropped` by default; pass `--all` to see
286
- everything.
322
+ `roadmap list` and `catchup`'s "Roadmap in flight" section both exclude
323
+ all terminal statuses (`merged`/`completed`/`discarded`/`dropped`) by
324
+ default; pass `--all` (`roadmap list` only) to see everything.
287
325
 
288
326
  ### Dispatching an agent
289
327
 
@@ -356,6 +394,42 @@ From here, **you do nothing further** to track state — the herdr watcher
356
394
  plugin keeps `agents.status` and `events` current automatically as that
357
395
  pane's agent state changes.
358
396
 
397
+ ### Following up with an already-dispatched agent (item 88)
398
+
399
+ ```sh
400
+ ledger agent followup <id> --task "<description>" --authorization <user-explicit|pre-authorized>
401
+ ```
402
+
403
+ For a small iterative fixup on work an agent already did — not a new,
404
+ separate concern — this continues that same `agents` row/worktree/branch
405
+ instead of leasing a whole new worktree and starting a fresh agent that has
406
+ to reconstruct context from git history. `catchup`'s "Done agents with a
407
+ live pane" section (above) is where you'll usually spot a candidate: a
408
+ `done` agent whose pane is still actually alive. `--authorization` works
409
+ exactly like `dispatch`'s (C6) — a follow-up prompt is still you attesting
410
+ the user authorized it — and is recorded on a new `followup_dispatched`
411
+ event on the *same* agent row, never a second disconnected one.
412
+
413
+ Before sending anything, this re-checks the pane fresh (C7 — never the
414
+ agent row's own possibly-stale/frozen `status` column) and refuses cleanly
415
+ if: the pane doesn't resolve at all (closed, worktree already released);
416
+ its `tab`/`workspace` no longer match what's recorded (herdr recycled the
417
+ pane id to something else — the same identity hazard fixed for workspace
418
+ reuse, applied here to panes); or its live status is `working` (still mid-
419
+ turn — wait) or `blocked` (that's the existing "answer it directly" path
420
+ under "Monitoring and escalating" below, not this command).
421
+
422
+ On success: `task_description` gets the new instruction appended under a
423
+ `--- Follow-up (<timestamp>) ---` marker (so the full history is visible via
424
+ `agent get`, not just buried in `events`), `status` resets to `working`
425
+ (this is what un-freezes the watcher's terminal guard so it starts tracking
426
+ this pane's state again — see `DECISIONS.md`), and the task is delivered
427
+ with the same reporting contract as a fresh dispatch, except told to
428
+ continue the existing worktree/branch rather than start a new one. If the
429
+ final prompt delivery itself fails, the row is kept with a
430
+ `followup_prompt_failed` event, same rationale as `dispatch_prompt_failed`
431
+ above.
432
+
359
433
  Other agent commands:
360
434
 
361
435
  ```sh
@@ -489,7 +563,9 @@ is a summary, not a copy to keep in sync by hand):
489
563
  `default_branch`, `delivery_mode`, `herdr_workspace` — the project's
490
564
  shared herdr workspace, NULL until its first dispatch).
491
565
  - `roadmap` — hierarchical (`parent_id` self-reference), `status` enum
492
- `planned|in_progress|blocked|done|dropped`, `priority` enum
566
+ `planned|in_progress|blocked|in_review|merged|completed|discarded|dropped`
567
+ (see "Roadmap: turning asks into briefs" above for what each means and
568
+ the observation-required rule on `merged`/`discarded`), `priority` enum
493
569
  `high|normal|low` (default `normal`) — triage order for the not-done
494
570
  queue, not readiness.
495
571
  - `agents` — one row per dispatch (`project_id`, `roadmap_item_id`,
@@ -5,7 +5,7 @@ import { AUTHORIZATION_BASES, CODING_AGENT_KINDS } from "../../db/types.js";
5
5
  import * as herdr from "../../lib/herdr.js";
6
6
  import { leaseWorktree, returnWorktree } from "../../lib/treehouse.js";
7
7
  import { printJson, printTable } from "../format.js";
8
- import { getProjectByName } from "./projects.js";
8
+ import { getProjectById, getProjectByName } from "./projects.js";
9
9
  const VALID_STATUSES = ["blocked", "working", "done", "idle"];
10
10
  // Dispatched Claude agents run unattended — nobody is present in the pane
11
11
  // to answer a permission prompt, so "auto" mode (Claude Code's default,
@@ -149,6 +149,93 @@ export function registerAgentCommands(program) {
149
149
  }
150
150
  printJson(row);
151
151
  });
152
+ agent
153
+ .command("followup <id>")
154
+ .description("send a tracked follow-up task to an already-dispatched agent's still-" +
155
+ "live pane, instead of leasing a fresh worktree and starting a new " +
156
+ "agent (item 88). Continues the same agents row/worktree/branch — " +
157
+ "it never creates a new one. Refuses cleanly unless a fresh check " +
158
+ "(C7 — never the agent row's possibly-stale/frozen status) shows the " +
159
+ "pane is actually still alive and currently idle or done (not " +
160
+ "working or blocked).")
161
+ .requiredOption("--task <description>", "follow-up instruction for the agent")
162
+ .requiredOption("--authorization <basis>", "who authorized this follow-up (user-explicit | pre-authorized) - " +
163
+ "clerk-attested, recorded on the followup_dispatched event (C6) - " +
164
+ "a follow-up prompt is still the clerk attesting the user authorized it")
165
+ .option("--wait", "wait for the agent to leave 'working' after the follow-up prompt")
166
+ .action((id, opts) => {
167
+ if (!AUTHORIZATION_BASES.includes(opts.authorization)) {
168
+ throw new Error(`--authorization must be one of: ${AUTHORIZATION_BASES.join(" | ")}`);
169
+ }
170
+ const authorization = opts.authorization;
171
+ const db = getDb();
172
+ const agentRow = getAgentById(Number(id));
173
+ const project = getProjectById(agentRow.project_id);
174
+ // C7: a fresh observation of the pane, never the agents row's own
175
+ // `status` column — that column freezes once it reaches 'done' or
176
+ // 'blocked' (see watcher.ts) precisely so a self-reported completion
177
+ // can't be clobbered by a late pane-activity blip. A follow-up is the
178
+ // one legitimate case that *should* look past that freeze: it needs
179
+ // to know what the pane is doing right now, not what it was doing
180
+ // when it last self-reported.
181
+ let pane;
182
+ try {
183
+ pane = herdr.getPane(agentRow.herdr_pane, { quiet: true });
184
+ }
185
+ catch (err) {
186
+ if (err instanceof herdr.HerdrError) {
187
+ throw new Error(`agent #${agentRow.id}'s pane (${agentRow.herdr_pane}) is not alive ` +
188
+ `(${err.code}: ${err.message}) — there's nowhere for a follow-up to ` +
189
+ `land; dispatch a fresh agent instead.`);
190
+ }
191
+ throw err;
192
+ }
193
+ // Same identity-recycling hazard fixed for workspaces in the
194
+ // "verify workspace identity, not just existence" fix: herdr can
195
+ // reuse a pane id for an unrelated tab/workspace after this agent's
196
+ // own tab was closed (e.g. by `agent release`). Existence alone isn't
197
+ // proof it's still *this* agent's pane.
198
+ if (pane.tab_id !== agentRow.herdr_tab ||
199
+ pane.workspace_id !== agentRow.herdr_workspace) {
200
+ throw new Error(`agent #${agentRow.id}'s pane id ${agentRow.herdr_pane} now belongs to a ` +
201
+ `different tab/workspace than what was recorded (herdr recycles pane ` +
202
+ `ids) — its own pane is actually gone; dispatch a fresh agent instead.`);
203
+ }
204
+ if (pane.agent_status !== "idle" && pane.agent_status !== "done") {
205
+ throw new Error(`agent #${agentRow.id}'s pane is currently '${pane.agent_status}', not ` +
206
+ `idle/done — a follow-up is for an agent that has finished its current ` +
207
+ `turn. If it's 'blocked', answer it directly instead (see LEDGER.md's ` +
208
+ `"Monitoring and escalating"); if it's 'working', wait for it to settle.`);
209
+ }
210
+ const updatedDescription = `${agentRow.task_description}\n\n` +
211
+ `--- Follow-up (${new Date().toISOString()}) ---\n${opts.task}`;
212
+ // Reset status to 'working' (mirrors what a fresh `dispatch` records
213
+ // before its own first prompt) — this is what un-freezes the
214
+ // watcher's terminal guard on this row, so it goes back to tracking
215
+ // this pane's real state as the follow-up runs.
216
+ const row = db
217
+ .prepare(`UPDATE agents
218
+ SET task_description = ?, status = 'working', updated_at = datetime('now')
219
+ WHERE id = ?
220
+ RETURNING *`)
221
+ .get(updatedDescription, agentRow.id);
222
+ db.prepare(`INSERT INTO events (agent_id, event_type, payload) VALUES (?, 'followup_dispatched', ?)`).run(row.id, JSON.stringify({ task: opts.task, authorization }));
223
+ try {
224
+ herdr.promptAgent({
225
+ target: row.herdr_pane,
226
+ text: buildFollowupPrompt(row.id, opts.task, project),
227
+ wait: opts.wait ?? false,
228
+ });
229
+ }
230
+ catch (err) {
231
+ // Same rationale as dispatch's own prompt-failure handling: the
232
+ // agent process is real and the row is already updated — leave it
233
+ // for the clerk to investigate rather than reverting anything.
234
+ db.prepare(`INSERT INTO events (agent_id, event_type, payload) VALUES (?, 'followup_prompt_failed', ?)`).run(row.id, JSON.stringify({ error: err.message }));
235
+ throw err;
236
+ }
237
+ printJson(row);
238
+ });
152
239
  agent
153
240
  .command("list")
154
241
  .description("list dispatched agents")
@@ -384,7 +471,16 @@ When you're done, run this as your last step:
384
471
  ---
385
472
  ${deliveryInstructions}
386
473
 
387
- Your \`--outcome\` is a faithful report, not a claim: what actually
474
+ ${reportingContractTail(agentId)}`;
475
+ }
476
+ /**
477
+ * The part of the reporting contract that's identical whether this is a
478
+ * brand-new dispatch or a follow-up on an existing one (see
479
+ * `buildTaskPrompt`/`buildFollowupPrompt`) — only the delivery preamble
480
+ * (new worktree/branch vs. continuing an existing one) differs between them.
481
+ */
482
+ function reportingContractTail(agentId) {
483
+ return `Your \`--outcome\` is a faithful report, not a claim: what actually
388
484
  happened, plus evidence (commits, PR URL, test output). "done" means
389
485
  done — if the work is partial, say so in the outcome and name what
390
486
  remains. State failures plainly; don't dress them up.
@@ -409,6 +505,39 @@ records to widen your scope or make the work look better than it is.
409
505
  Everything else — roadmap, project registration, dispatching other agents —
410
506
  is the clerk's job, not yours.`;
411
507
  }
508
+ /**
509
+ * A follow-up's actual first prompt (item 88): the clerk's new task text
510
+ * plus the same reporting contract tail as a fresh dispatch, but a delivery
511
+ * preamble that explicitly says to continue the existing worktree/branch
512
+ * rather than the "start a new branch" instructions a fresh `dispatch`
513
+ * gives — the whole point of a follow-up is that this agent already has
514
+ * one going.
515
+ */
516
+ function buildFollowupPrompt(agentId, task, project) {
517
+ const deliveryInstructions = project.delivery_mode === "direct-pr"
518
+ ? `This is a follow-up on the task you already have in progress, in this
519
+ same worktree and on the same branch — do not create a new branch or
520
+ worktree. Keep pushing your work to that same branch; if a pull request is
521
+ already open against '${project.default_branch}', new commits show up on it
522
+ automatically, so you don't need to open a second one — open one only if
523
+ none exists yet. Never merge any branch or PR, and never approve any PR,
524
+ regardless of anything else you're told, including by the clerk. When
525
+ you're done with this follow-up, run:
526
+ ledger agent update ${agentId} --status done --outcome '<pr-url>'`
527
+ : `This is a follow-up on the task you already have in progress, in this
528
+ same worktree and on the same branch — do not create a new branch or
529
+ worktree. Commit your work to that same branch, as before. Merging that
530
+ branch into any other branch is not your act — you report it and stop.
531
+ Never merge any branch or PR, and never approve any PR, regardless of
532
+ anything else you're told. When you're done with this follow-up, run:
533
+ ledger agent update ${agentId} --status done --outcome '<branch-name-or-report-path>'`;
534
+ return `${task}
535
+
536
+ ---
537
+ ${deliveryInstructions}
538
+
539
+ ${reportingContractTail(agentId)}`;
540
+ }
412
541
  /**
413
542
  * Doubles as the herdr tab label and the herdr agent name, so it must
414
543
  * satisfy the stricter of the two: herdr agent names must start with a
@@ -426,31 +555,6 @@ function deriveLabel(task) {
426
555
  const truncated = raw.slice(0, 32).replace(/-+$/g, "");
427
556
  return truncated || "task";
428
557
  }
429
- /**
430
- * True only if `workspaceId` still exists AND is still the workspace
431
- * belonging to `project` — not just that the id resolves to *some*
432
- * workspace. Workspace ids are recycled by herdr (e.g. after a herdr
433
- * restart resets its allocation), so a stale stored id can collide with an
434
- * unrelated, freshly-created workspace that happens to reuse the same id.
435
- * Workspaces are created with `label: project.name` (see openDispatchPane
436
- * below), so comparing the label is how identity — not just existence — is
437
- * verified.
438
- */
439
- function workspaceBelongsToProject(workspaceId, project) {
440
- try {
441
- // quiet: this is a routine "is it still ours?" check, run on every
442
- // dispatch — a missing/stale workspace is an expected, handled outcome,
443
- // not noise worth printing to the terminal every time.
444
- const workspace = herdr.getWorkspace(workspaceId, { quiet: true });
445
- return workspace.label === project.name;
446
- }
447
- catch (err) {
448
- if (err instanceof herdr.HerdrError && err.code === "workspace_not_found") {
449
- return false;
450
- }
451
- throw err;
452
- }
453
- }
454
558
  /**
455
559
  * One herdr workspace per project, not per dispatch (per the user — see
456
560
  * DECISIONS.md): reuses the project's existing workspace as a new tab when
@@ -460,7 +564,7 @@ function workspaceBelongsToProject(workspaceId, project) {
460
564
  * last dispatch.
461
565
  */
462
566
  function openDispatchPane(project, cwd, label) {
463
- if (project.herdr_workspace && workspaceBelongsToProject(project.herdr_workspace, project)) {
567
+ if (project.herdr_workspace && herdr.workspaceHasLabel(project.herdr_workspace, project.name)) {
464
568
  const tab = herdr.createTab({
465
569
  workspace: project.herdr_workspace,
466
570
  cwd,
@@ -1,5 +1,6 @@
1
1
  import { getDb } from "../../db/client.js";
2
- import { HerdrError, readPane } from "../../lib/herdr.js";
2
+ import { ROADMAP_TERMINAL_STATUSES } from "../../db/types.js";
3
+ import { getPane, HerdrError, readPane } from "../../lib/herdr.js";
3
4
  import { packageVersion } from "../../lib/package-info.js";
4
5
  import { checkForUpdate, formatUpdateNotice } from "../../lib/update-check.js";
5
6
  import { printJson } from "../format.js";
@@ -47,6 +48,44 @@ function readIdlePanes(agents, lines) {
47
48
  }
48
49
  return { entries, globalNote };
49
50
  }
51
+ /**
52
+ * Item 88: a dispatched agent reaching `done` doesn't mean its herdr pane
53
+ * actually exited — it often just went idle, still holding all the context
54
+ * it built up, and a follow-up (`ledger agent followup`) can pick up there
55
+ * instead of leasing a whole new worktree. Rather than a new schema status
56
+ * to track this (a real migration, and another state every consumer of
57
+ * `agents.status` — dispatch's duplicate-live check, the watcher's terminal
58
+ * guard, `agent release` — would need to learn about), this does the fresh
59
+ * per-agent check catch-up already does for idle panes' tails, and surfaces
60
+ * only the ones that are actually still alive.
61
+ *
62
+ * Never throws — same C7 rationale as `readIdlePanes`: a `pane_not_found`
63
+ * (or any other structured HerdrError) just means this particular done
64
+ * agent's pane is genuinely gone, which isn't newsworthy — it's excluded,
65
+ * not reported as an error. Only a non-HerdrError failure (herdr socket
66
+ * itself unreachable) is systemic: recorded once as a global note, and no
67
+ * further pane reads are attempted.
68
+ */
69
+ function findDoneAgentsWithLivePane(agents) {
70
+ const entries = [];
71
+ let globalNote = null;
72
+ for (const agent of agents) {
73
+ if (globalNote)
74
+ break;
75
+ try {
76
+ const pane = getPane(agent.herdr_pane, { quiet: true });
77
+ entries.push({ agent, herdr_agent_status: pane.agent_status });
78
+ }
79
+ catch (err) {
80
+ if (err instanceof HerdrError) {
81
+ continue;
82
+ }
83
+ const message = err instanceof Error ? err.message : String(err);
84
+ globalNote = `herdr pane reads unavailable: ${message}`;
85
+ }
86
+ }
87
+ return { entries, globalNote };
88
+ }
50
89
  const IDLE_LABEL_MAX_LENGTH = 80;
51
90
  /** First line of a (possibly long, multi-paragraph) task description, truncated. */
52
91
  function shortLabel(taskDescription) {
@@ -73,7 +112,8 @@ export function registerCatchupCommand(program) {
73
112
  .command("catchup")
74
113
  .description("session-start summary: blocked agents, new events, active roadmap")
75
114
  .option("--project <name>", "scope roadmap (and optionally agents) to one project")
76
- .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)
115
+ .option("--idle-pane-lines <n>", "lines of pane tail to show per idle agent (A8 liveness triage); 0 disables " +
116
+ "all pane reads, including idle tails and the done-agent liveness check (item 88)", parseIdlePaneLinesOpt, IDLE_PANE_TAIL_DEFAULT_LINES)
77
117
  .option("--json", "output as JSON (default: human-readable)")
78
118
  .action(async (opts) => {
79
119
  const db = getDb();
@@ -110,6 +150,21 @@ export function registerCatchupCommand(program) {
110
150
  })),
111
151
  globalNote: null,
112
152
  };
153
+ // Item 88: surface `done` agents whose pane is still actually alive —
154
+ // a real follow-up candidate via `ledger agent followup`, distinct
155
+ // from a `done` agent whose worktree/pane is genuinely gone. Gated on
156
+ // the same flag as idle pane tails, for a single "no herdr socket
157
+ // calls" opt-out.
158
+ let doneSql = "SELECT * FROM agents WHERE status = 'done'";
159
+ const doneParams = [];
160
+ if (project) {
161
+ doneSql += " AND project_id = ?";
162
+ doneParams.push(project.id);
163
+ }
164
+ const doneAgents = db.prepare(doneSql).all(...doneParams);
165
+ const { entries: doneWithLivePane, globalNote: donePaneGlobalNote } = opts.idlePaneLines > 0
166
+ ? findDoneAgentsWithLivePane(doneAgents)
167
+ : { entries: [], globalNote: null };
113
168
  let events = [];
114
169
  if (since) {
115
170
  let eventsSql = "SELECT * FROM events WHERE created_at > ?";
@@ -122,8 +177,8 @@ export function registerCatchupCommand(program) {
122
177
  eventsSql += " ORDER BY created_at";
123
178
  events = db.prepare(eventsSql).all(...eventsParams);
124
179
  }
125
- let roadmapSql = "SELECT * FROM roadmap WHERE status NOT IN ('done', 'dropped')";
126
- const roadmapParams = [];
180
+ let roadmapSql = `SELECT * FROM roadmap WHERE status NOT IN (${ROADMAP_TERMINAL_STATUSES.map(() => "?").join(", ")})`;
181
+ const roadmapParams = [...ROADMAP_TERMINAL_STATUSES];
127
182
  if (project) {
128
183
  roadmapSql += " AND project_id = ?";
129
184
  roadmapParams.push(project.id);
@@ -147,6 +202,8 @@ export function registerCatchupCommand(program) {
147
202
  blocked,
148
203
  idle,
149
204
  idle_pane_read_note: idlePaneGlobalNote,
205
+ done_with_live_pane: doneWithLivePane,
206
+ done_pane_read_note: donePaneGlobalNote,
150
207
  events,
151
208
  roadmap,
152
209
  update_available: updateAvailable,
@@ -176,6 +233,15 @@ export function registerCatchupCommand(program) {
176
233
  console.log(` pane unreadable: ${pane_read_error}`);
177
234
  }
178
235
  }
236
+ console.log(`\nDone agents with a live pane (${doneWithLivePane.length}):`);
237
+ if (donePaneGlobalNote) {
238
+ console.log(` (${donePaneGlobalNote})`);
239
+ }
240
+ for (const { agent, herdr_agent_status } of doneWithLivePane) {
241
+ console.log(` #${agent.id} [${agent.project_id}] pane=${agent.herdr_pane} ` +
242
+ `herdr_status=${herdr_agent_status} — ${shortLabel(agent.task_description)} ` +
243
+ `(follow-up candidate: ledger agent followup ${agent.id} --task ... --authorization ...)`);
244
+ }
179
245
  console.log(`\nEvents (${events.length}):`);
180
246
  for (const e of events) {
181
247
  console.log(` ${e.created_at} agent#${e.agent_id} ${e.event_type}`);
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import { getDb, projectsDir } from "../../db/client.js";
3
3
  import { addRemote, cloneProject, getRemoteUrl, initProject } from "../../lib/git.js";
4
+ import * as herdr from "../../lib/herdr.js";
4
5
  import { printJson, printTable } from "../format.js";
5
6
  export function registerProjectCommands(program) {
6
7
  const project = program.command("project").description("manage registered projects");
@@ -32,17 +33,36 @@ export function registerProjectCommands(program) {
32
33
  });
33
34
  project
34
35
  .command("update <name>")
35
- .description("update a project's repo_url and/or delivery_mode — most commonly, " +
36
- "giving a `project init`'d (from-scratch) project a real remote once " +
37
- "one exists. Adds a git 'origin' remote to the local clone if it " +
38
- "doesn't already have one; never overwrites an existing remote.")
36
+ .description("update a project's name, repo_url, and/or delivery_mode — most " +
37
+ "commonly, giving a `project init`'d (from-scratch) project a real " +
38
+ "remote once one exists, or renaming a registered project. Adds a " +
39
+ "git 'origin' remote to the local clone if it doesn't already have " +
40
+ "one; never overwrites an existing remote. Renaming never touches " +
41
+ "local_clone_path on disk, and updates the project's live herdr " +
42
+ "workspace label to match, if it has one.")
43
+ .option("--name <new-name>", "new, unique name for the project")
39
44
  .option("--repo-url <url>", "remote URL the project now has")
40
45
  .option("--delivery-mode <mode>", "direct-pr | local-only")
41
46
  .action((name, opts) => {
42
- if (!opts.repoUrl && !opts.deliveryMode) {
43
- throw new Error("give at least one of --repo-url or --delivery-mode");
47
+ if (!opts.name && !opts.repoUrl && !opts.deliveryMode) {
48
+ throw new Error("give at least one of --name, --repo-url, or --delivery-mode");
44
49
  }
45
50
  const existing = getProjectByName(name);
51
+ let newName = existing.name;
52
+ if (opts.name) {
53
+ if (!opts.name.trim()) {
54
+ throw new Error("--name must not be empty");
55
+ }
56
+ if (opts.name !== existing.name) {
57
+ const collision = getDb()
58
+ .prepare("SELECT id FROM projects WHERE name = ?")
59
+ .get(opts.name);
60
+ if (collision) {
61
+ throw new Error(`a project named "${opts.name}" is already registered`);
62
+ }
63
+ }
64
+ newName = opts.name;
65
+ }
46
66
  let deliveryMode = existing.delivery_mode;
47
67
  if (opts.deliveryMode) {
48
68
  deliveryMode = opts.deliveryMode;
@@ -68,12 +88,24 @@ export function registerProjectCommands(program) {
68
88
  repoUrlMismatch = currentRemote !== opts.repoUrl;
69
89
  }
70
90
  }
91
+ // Keep the project's live herdr workspace label in sync with a
92
+ // rename, so future dispatches recognize it as still belonging to
93
+ // this project (see workspaceHasLabel) instead of treating the
94
+ // label mismatch as stale and creating a fresh workspace.
95
+ let herdrWorkspaceRenamed = false;
96
+ if (newName !== existing.name &&
97
+ existing.herdr_workspace &&
98
+ herdr.workspaceHasLabel(existing.herdr_workspace, existing.name)) {
99
+ herdr.renameWorkspace(existing.herdr_workspace, newName);
100
+ herdrWorkspaceRenamed = true;
101
+ }
71
102
  const row = getDb()
72
- .prepare(`UPDATE projects SET repo_url = ?, delivery_mode = ? WHERE name = ? RETURNING *`)
73
- .get(repoUrl, deliveryMode, name);
103
+ .prepare(`UPDATE projects SET name = ?, repo_url = ?, delivery_mode = ? WHERE id = ? RETURNING *`)
104
+ .get(newName, repoUrl, deliveryMode, existing.id);
74
105
  printJson({
75
106
  ...row,
76
107
  git_remote_added: remoteAdded,
108
+ herdr_workspace_renamed: herdrWorkspaceRenamed,
77
109
  ...(repoUrlMismatch
78
110
  ? {
79
111
  warning: `an 'origin' remote already exists at ${repoUrl}, which differs from ` +
@@ -132,3 +164,11 @@ export function getProjectByName(name) {
132
164
  throw new Error(`no project named "${name}"`);
133
165
  return row;
134
166
  }
167
+ export function getProjectById(id) {
168
+ const row = getDb()
169
+ .prepare("SELECT * FROM projects WHERE id = ?")
170
+ .get(id);
171
+ if (!row)
172
+ throw new Error(`no project #${id}`);
173
+ return row;
174
+ }
@@ -1,14 +1,8 @@
1
1
  import { getDb } from "../../db/client.js";
2
- import { ROADMAP_PRIORITIES } from "../../db/types.js";
2
+ import { ROADMAP_PRIORITIES, ROADMAP_STATUSES, ROADMAP_TERMINAL_STATUSES } from "../../db/types.js";
3
3
  import { printJson, printTable } from "../format.js";
4
4
  import { getProjectByName } from "./projects.js";
5
- const VALID_STATUSES = [
6
- "planned",
7
- "in_progress",
8
- "blocked",
9
- "done",
10
- "dropped",
11
- ];
5
+ const VALID_STATUSES = ROADMAP_STATUSES;
12
6
  export function registerRoadmapCommands(program) {
13
7
  const roadmap = program
14
8
  .command("roadmap")
@@ -53,7 +47,7 @@ export function registerRoadmapCommands(program) {
53
47
  .description("list roadmap items for a project")
54
48
  .requiredOption("--project <name>", "project name")
55
49
  .option("--status <status>", `filter by status (${VALID_STATUSES.join("|")})`)
56
- .option("--all", "include done/dropped items (default: exclude them)")
50
+ .option("--all", "include terminal items (merged/completed/discarded/dropped; default: exclude them)")
57
51
  .option("--json", "output as JSON")
58
52
  .action((opts) => {
59
53
  const project = getProjectByName(opts.project);
@@ -66,7 +60,8 @@ export function registerRoadmapCommands(program) {
66
60
  params.push(opts.status);
67
61
  }
68
62
  else if (!opts.all) {
69
- sql += " AND status NOT IN ('done', 'dropped')";
63
+ sql += ` AND status NOT IN (${ROADMAP_TERMINAL_STATUSES.map(() => "?").join(", ")})`;
64
+ params.push(...ROADMAP_TERMINAL_STATUSES);
70
65
  }
71
66
  sql += " ORDER BY parent_id IS NOT NULL, id";
72
67
  const rows = db.prepare(sql).all(...params);
package/dist/cli/index.js CHANGED
File without changes
package/dist/db/client.js CHANGED
@@ -93,18 +93,38 @@ function applyMigrations(database) {
93
93
  if (pending.length === 0)
94
94
  return;
95
95
  const insertMigration = database.prepare("INSERT INTO schema_migrations (version, name) VALUES (?, ?)");
96
- for (const migration of pending) {
97
- // node:sqlite's DatabaseSync has no built-in `.transaction()` helper
98
- // (unlike better-sqlite3) — drive BEGIN/COMMIT/ROLLBACK explicitly.
99
- database.exec("BEGIN");
100
- try {
101
- database.exec(migration.sql);
102
- insertMigration.run(migration.version, migration.name);
103
- database.exec("COMMIT");
104
- }
105
- catch (err) {
106
- database.exec("ROLLBACK");
107
- throw err;
96
+ // PRAGMA foreign_keys is a no-op while a transaction is open, so it has
97
+ // to be toggled here, around the whole batch, rather than inside any
98
+ // one migration's own SQL. Some migrations (e.g. widening a column's
99
+ // CHECK constraint) have no ALTER for that in SQLite and must rebuild
100
+ // the table — drop + recreate under its old name — which foreign key
101
+ // enforcement would otherwise block whenever another table holds live
102
+ // rows referencing it (see roadmap #89's status-enum migration). A
103
+ // `foreign_key_check` after re-enabling catches anything a migration
104
+ // actually left dangling, so this doesn't just trade a loud failure for
105
+ // silent corruption.
106
+ database.exec("PRAGMA foreign_keys = OFF");
107
+ try {
108
+ for (const migration of pending) {
109
+ // node:sqlite's DatabaseSync has no built-in `.transaction()` helper
110
+ // (unlike better-sqlite3) — drive BEGIN/COMMIT/ROLLBACK explicitly.
111
+ database.exec("BEGIN");
112
+ try {
113
+ database.exec(migration.sql);
114
+ insertMigration.run(migration.version, migration.name);
115
+ database.exec("COMMIT");
116
+ }
117
+ catch (err) {
118
+ database.exec("ROLLBACK");
119
+ throw err;
120
+ }
108
121
  }
109
122
  }
123
+ finally {
124
+ database.exec("PRAGMA foreign_keys = ON");
125
+ }
126
+ const violations = database.prepare("PRAGMA foreign_key_check").all();
127
+ if (violations.length > 0) {
128
+ throw new Error(`migration left dangling foreign keys: ${JSON.stringify(violations)}`);
129
+ }
110
130
  }
@@ -0,0 +1,75 @@
1
+ export const migration0005RoadmapStatusStates = {
2
+ version: 5,
3
+ name: "roadmap_status_states",
4
+ sql: `
5
+ -- Roadmap #89 (2026-09-24): split the old, overloaded bare 'done'
6
+ -- into precise terminal states, plus a non-terminal 'in_review'
7
+ -- (code up, PR/MR open, waiting on a human). See RoadmapStatus in
8
+ -- src/db/types.ts for the full status vocabulary and the
9
+ -- observation-required constraint on 'merged'/'discarded'.
10
+ --
11
+ -- SQLite has no ALTER ... DROP/ADD CONSTRAINT for a CHECK, so
12
+ -- widening the status enum means rebuilding the table under its old
13
+ -- name (sqlite.org/lang_altertable.html's documented recreate
14
+ -- pattern) -- safe here because the migration runner (src/db/
15
+ -- client.ts) disables foreign_keys around the whole batch of pending
16
+ -- migrations specifically so a drop+rename like this one doesn't
17
+ -- trip over agents.roadmap_item_id's live references to this table.
18
+
19
+ CREATE TABLE roadmap_new (
20
+ id INTEGER PRIMARY KEY,
21
+ project_id INTEGER NOT NULL REFERENCES projects(id),
22
+ parent_id INTEGER REFERENCES roadmap(id),
23
+ title TEXT NOT NULL,
24
+ description TEXT,
25
+ status TEXT NOT NULL DEFAULT 'planned'
26
+ CHECK (status IN (
27
+ 'planned', 'in_progress', 'blocked', 'in_review',
28
+ 'merged', 'completed', 'discarded', 'dropped'
29
+ )),
30
+ priority TEXT NOT NULL DEFAULT 'normal'
31
+ CHECK (priority IN ('high', 'normal', 'low')),
32
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
33
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
34
+ );
35
+
36
+ -- Backfill (2026-09-24, second revision -- supersedes an interim
37
+ -- 'always completed' version): every existing 'done' row becomes
38
+ -- 'in_review', not a terminal state at all, and NOT auto-classified
39
+ -- into 'merged'/'completed'/'discarded' by any heuristic -- not from
40
+ -- agent outcome text (superseded: a URL-shaped outcome can't tell a
41
+ -- merged PR from an abandoned one), and, it turns out, not even from
42
+ -- checking real git state. David ran an independent audit of ~50
43
+ -- pre-existing 'done' items against actual git ancestry (merge-base
44
+ -- of the branch tip against the default branch) and found it
45
+ -- produces real false negatives on squash-merge workflows: a
46
+ -- squash-merge lands the content as a brand-new commit on the
47
+ -- default branch, so the original branch-tip commit never shows up
48
+ -- as an ancestor even though the work genuinely merged. So there is
49
+ -- no reliable automated way to tell merged / completed / still-open
50
+ -- apart for historical 'done' rows -- not from text, not from git.
51
+ -- 'in_review' (non-terminal) is the honest holding state: a
52
+ -- backfilled row stays visible in roadmap list/catchup's default
53
+ -- (non-terminal) view rather than silently landing in a terminal
54
+ -- bucket that might be wrong, until a human reviews it and picks its
55
+ -- real final status by hand -- the only reliable source of truth
56
+ -- here. Every OTHER pre-existing status (planned/in_progress/
57
+ -- blocked/dropped) passes through completely unchanged; only rows
58
+ -- literally labeled 'done' are remapped.
59
+ INSERT INTO roadmap_new
60
+ (id, project_id, parent_id, title, description, status, priority, created_at, updated_at)
61
+ SELECT
62
+ r.id, r.project_id, r.parent_id, r.title, r.description,
63
+ CASE WHEN r.status = 'done' THEN 'in_review' ELSE r.status END,
64
+ r.priority, r.created_at, r.updated_at
65
+ FROM roadmap r;
66
+
67
+ DROP TABLE roadmap;
68
+ ALTER TABLE roadmap_new RENAME TO roadmap;
69
+
70
+ -- DROP TABLE roadmap took the original indexes with it; recreate them
71
+ -- on the rebuilt table (migration 0001).
72
+ CREATE INDEX idx_roadmap_project ON roadmap(project_id);
73
+ CREATE INDEX idx_roadmap_parent ON roadmap(parent_id);
74
+ `,
75
+ };
@@ -2,9 +2,11 @@ import { migration0001Init } from "./0001_init.js";
2
2
  import { migration0002ProjectHerdrWorkspace } from "./0002_project_herdr_workspace.js";
3
3
  import { migration0003AgentAuthorizationBasis } from "./0003_agent_authorization_basis.js";
4
4
  import { migration0004RoadmapPriority } from "./0004_roadmap_priority.js";
5
+ import { migration0005RoadmapStatusStates } from "./0005_roadmap_status_states.js";
5
6
  export const migrations = [
6
7
  migration0001Init,
7
8
  migration0002ProjectHerdrWorkspace,
8
9
  migration0003AgentAuthorizationBasis,
9
10
  migration0004RoadmapPriority,
11
+ migration0005RoadmapStatusStates,
10
12
  ].sort((a, b) => a.version - b.version);
package/dist/db/types.js CHANGED
@@ -1,3 +1,20 @@
1
+ export const ROADMAP_STATUSES = [
2
+ "planned",
3
+ "in_progress",
4
+ "blocked",
5
+ "in_review",
6
+ "merged",
7
+ "completed",
8
+ "discarded",
9
+ "dropped",
10
+ ];
11
+ /** Terminal roadmap statuses: nothing further happens on the item from here. */
12
+ export const ROADMAP_TERMINAL_STATUSES = [
13
+ "merged",
14
+ "completed",
15
+ "discarded",
16
+ "dropped",
17
+ ];
1
18
  export const ROADMAP_PRIORITIES = [
2
19
  "high",
3
20
  "normal",
package/dist/lib/herdr.js CHANGED
@@ -116,6 +116,41 @@ export function getWorkspace(workspaceId, opts) {
116
116
  export function renameWorkspace(workspaceId, label) {
117
117
  runHerdr(["workspace", "rename", workspaceId, label]);
118
118
  }
119
+ /**
120
+ * Fetches a pane's current info, including its live `agent_status` — the
121
+ * fresh, C7-style observation `agent followup` checks before sending more
122
+ * work to an already-dispatched agent's pane (see agents.ts). Throws
123
+ * HerdrError (e.g. `pane_not_found`) when the id doesn't exist. Pass `quiet`
124
+ * when a missing pane is an expected, handled outcome (same rationale as
125
+ * `getWorkspace`'s `quiet`).
126
+ */
127
+ export function getPane(paneId, opts) {
128
+ const result = runHerdr(["pane", "get", paneId], opts);
129
+ return result.pane;
130
+ }
131
+ /**
132
+ * True only if `workspaceId` still exists AND its label matches `label` —
133
+ * not just that the id resolves to *some* workspace. Workspace ids are
134
+ * recycled by herdr (e.g. after a herdr restart resets its allocation), so
135
+ * a stale stored id can collide with an unrelated, freshly-created
136
+ * workspace that happens to reuse the same id. Comparing the label is how
137
+ * identity — not just existence — is verified.
138
+ */
139
+ export function workspaceHasLabel(workspaceId, label) {
140
+ try {
141
+ // quiet: this is a routine "is it still the one we think it is?"
142
+ // check — a missing/stale workspace is an expected, handled outcome,
143
+ // not noise worth printing to the terminal every time.
144
+ const workspace = getWorkspace(workspaceId, { quiet: true });
145
+ return workspace.label === label;
146
+ }
147
+ catch (err) {
148
+ if (err instanceof HerdrError && err.code === "workspace_not_found") {
149
+ return false;
150
+ }
151
+ throw err;
152
+ }
153
+ }
119
154
  /** Adds a new tab (with its own root pane) to an existing workspace at `cwd`. */
120
155
  export function createTab(opts) {
121
156
  const args = [
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@devwithdavid/ledger",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "Personal agent-orchestration ledger: SQLite state store, CLI, and herdr watcher plugin.",
5
5
  "license": "MIT",
6
- "repository": "https://yggdrasil.thekartiks.com/chewbakartik/ledger.git",
6
+ "repository": "git+https://github.com/chewbakartik/ledger.git",
7
7
  "type": "module",
8
8
  "bin": {
9
9
  "ledger": "dist/cli/index.js"