@devwithdavid/ledger 0.1.4 → 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,27 @@ 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
+
185
+ `catchup` also checks (item 42) whether a newer `@devwithdavid/ledger` npm
186
+ version exists — a plain-text notice after the normal output in
187
+ human-readable mode, or the top-level `update_available` JSON key
188
+ (`{current, latest}` or `null`) in `--json` mode, so `--json` output stays
189
+ valid JSON either way. The check is cached for 6h in
190
+ `$LEDGER_HOME/update-check.json` and degrades silently on any failure
191
+ (offline, timeout, malformed response) — it never delays or breaks
192
+ `catchup`. Run `ledger update` to actually upgrade.
193
+
173
194
  ### First-clerk claiming
174
195
 
175
196
  ```sh
@@ -181,6 +202,25 @@ ledger clerk status
181
202
  `--force` is passed. Do this once per new/resumed clerk session before
182
203
  dispatching anything.
183
204
 
205
+ `claim` runs the same cached update-availability check as `catchup` (item
206
+ 42); since `claim` has no `--json` mode, a matching newer version prints
207
+ as a plain-text line after the claim's JSON output.
208
+
209
+ ### Updating ledger
210
+
211
+ ```sh
212
+ ledger update
213
+ ```
214
+
215
+ Checks npm for the latest `@devwithdavid/ledger` version (always fresh —
216
+ never the 6h cache `claim`/`catchup` use, since this is an explicit user
217
+ action). Already up to date: says so and exits. Newer version available:
218
+ runs `npm install -g @devwithdavid/ledger@latest` immediately (no
219
+ confirmation prompt) and reports old → new version. Unlike the passive
220
+ notice above, failures here (registry unreachable, npm install error) are
221
+ never swallowed — this is a foreground action, so they're reported
222
+ plainly and the command exits non-zero.
223
+
184
224
  ### Registering a project
185
225
 
186
226
  ```sh
@@ -233,8 +273,33 @@ ledger roadmap list --project <name> [--status <status>] [--all] [--json]
233
273
  ledger roadmap update <id> [--status <status>] [--title <title>] [--description <text>] [--priority <high|normal|low>]
234
274
  ```
235
275
 
236
- Statuses: `planned | in_progress | blocked | done | dropped`. `--parent`
237
- 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).
238
303
 
239
304
  Priority: `high | normal | low`, default `normal` (also a column on every
240
305
  row: JSON output and `list`'s table both show it). It is a coarse triage
@@ -254,8 +319,9 @@ rather than deciding unilaterally or always making them write it themselves
254
319
  constraints, what's explicitly out of scope) — that's what you'll turn into
255
320
  `--task` text at dispatch time via `--roadmap-item <id>`.
256
321
 
257
- `roadmap list` excludes `done`/`dropped` by default; pass `--all` to see
258
- 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.
259
325
 
260
326
  ### Dispatching an agent
261
327
 
@@ -328,6 +394,42 @@ From here, **you do nothing further** to track state — the herdr watcher
328
394
  plugin keeps `agents.status` and `events` current automatically as that
329
395
  pane's agent state changes.
330
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
+
331
433
  Other agent commands:
332
434
 
333
435
  ```sh
@@ -461,7 +563,9 @@ is a summary, not a copy to keep in sync by hand):
461
563
  `default_branch`, `delivery_mode`, `herdr_workspace` — the project's
462
564
  shared herdr workspace, NULL until its first dispatch).
463
565
  - `roadmap` — hierarchical (`parent_id` self-reference), `status` enum
464
- `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
465
569
  `high|normal|low` (default `normal`) — triage order for the not-done
466
570
  queue, not readiness.
467
571
  - `agents` — one row per dispatch (`project_id`, `roadmap_item_id`,
package/README.md CHANGED
@@ -68,6 +68,13 @@ herdr always runs whatever's currently in the installed package's `dist/`,
68
68
  so `npm update -g @devwithdavid/ledger` picks up new releases (including
69
69
  watcher changes) on the next event.
70
70
 
71
+ **Or just run `ledger update`** — checks npm for a newer version and, if
72
+ one exists, runs the install for you (equivalent to `npm install -g
73
+ @devwithdavid/ledger@latest`). `ledger claim` and `ledger catchup` also
74
+ print a one-line notice when a newer version is available (checked at
75
+ most every 6 hours, silently skipped if npm is unreachable), so you don't
76
+ have to think to check.
77
+
71
78
  ## Starting a clerk session
72
79
 
73
80
  You don't run `ledger` commands yourself day to day — you talk to **the
@@ -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
@@ -431,10 +560,11 @@ function deriveLabel(task) {
431
560
  * DECISIONS.md): reuses the project's existing workspace as a new tab when
432
561
  * one is already live, or creates it (and records it via the caller) when
433
562
  * this is the project's first dispatch, or its previous workspace was
434
- * closed (e.g. by the user) since the last dispatch.
563
+ * closed (e.g. by the user) or recycled to a different project since the
564
+ * last dispatch.
435
565
  */
436
566
  function openDispatchPane(project, cwd, label) {
437
- if (project.herdr_workspace && herdr.workspaceExists(project.herdr_workspace)) {
567
+ if (project.herdr_workspace && herdr.workspaceHasLabel(project.herdr_workspace, project.name)) {
438
568
  const tab = herdr.createTab({
439
569
  workspace: project.herdr_workspace,
440
570
  cwd,
@@ -1,5 +1,8 @@
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";
4
+ import { packageVersion } from "../../lib/package-info.js";
5
+ import { checkForUpdate, formatUpdateNotice } from "../../lib/update-check.js";
3
6
  import { printJson } from "../format.js";
4
7
  import { getProjectByName } from "./projects.js";
5
8
  /** Default tail length for each idle agent's pane read (A8 liveness triage). */
@@ -45,6 +48,44 @@ function readIdlePanes(agents, lines) {
45
48
  }
46
49
  return { entries, globalNote };
47
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
+ }
48
89
  const IDLE_LABEL_MAX_LENGTH = 80;
49
90
  /** First line of a (possibly long, multi-paragraph) task description, truncated. */
50
91
  function shortLabel(taskDescription) {
@@ -71,9 +112,10 @@ export function registerCatchupCommand(program) {
71
112
  .command("catchup")
72
113
  .description("session-start summary: blocked agents, new events, active roadmap")
73
114
  .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)
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)
75
117
  .option("--json", "output as JSON (default: human-readable)")
76
- .action((opts) => {
118
+ .action(async (opts) => {
77
119
  const db = getDb();
78
120
  const project = opts.project ? getProjectByName(opts.project) : undefined;
79
121
  const firstClerk = db
@@ -108,6 +150,21 @@ export function registerCatchupCommand(program) {
108
150
  })),
109
151
  globalNote: null,
110
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 };
111
168
  let events = [];
112
169
  if (since) {
113
170
  let eventsSql = "SELECT * FROM events WHERE created_at > ?";
@@ -120,8 +177,8 @@ export function registerCatchupCommand(program) {
120
177
  eventsSql += " ORDER BY created_at";
121
178
  events = db.prepare(eventsSql).all(...eventsParams);
122
179
  }
123
- let roadmapSql = "SELECT * FROM roadmap WHERE status NOT IN ('done', 'dropped')";
124
- const roadmapParams = [];
180
+ let roadmapSql = `SELECT * FROM roadmap WHERE status NOT IN (${ROADMAP_TERMINAL_STATUSES.map(() => "?").join(", ")})`;
181
+ const roadmapParams = [...ROADMAP_TERMINAL_STATUSES];
125
182
  if (project) {
126
183
  roadmapSql += " AND project_id = ?";
127
184
  roadmapParams.push(project.id);
@@ -135,7 +192,22 @@ export function registerCatchupCommand(program) {
135
192
  if (firstClerk) {
136
193
  db.prepare("UPDATE first_clerk SET last_seen = datetime('now') WHERE id = 1").run();
137
194
  }
138
- const summary = { since, blocked, idle, idle_pane_read_note: idlePaneGlobalNote, events, roadmap };
195
+ // Item 42: cached (6h), silent-on-failure update-availability check.
196
+ // Added as an explicit top-level key (`update_available`, null when
197
+ // there's nothing to report) rather than mixed into plain text, so
198
+ // --json stays valid JSON in both cases.
199
+ const updateAvailable = await checkForUpdate(packageVersion());
200
+ const summary = {
201
+ since,
202
+ blocked,
203
+ idle,
204
+ idle_pane_read_note: idlePaneGlobalNote,
205
+ done_with_live_pane: doneWithLivePane,
206
+ done_pane_read_note: donePaneGlobalNote,
207
+ events,
208
+ roadmap,
209
+ update_available: updateAvailable,
210
+ };
139
211
  if (opts.json) {
140
212
  printJson(summary);
141
213
  return;
@@ -161,6 +233,15 @@ export function registerCatchupCommand(program) {
161
233
  console.log(` pane unreadable: ${pane_read_error}`);
162
234
  }
163
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
+ }
164
245
  console.log(`\nEvents (${events.length}):`);
165
246
  for (const e of events) {
166
247
  console.log(` ${e.created_at} agent#${e.agent_id} ${e.event_type}`);
@@ -170,5 +251,7 @@ export function registerCatchupCommand(program) {
170
251
  const indent = r.parent_id ? " " : " ";
171
252
  console.log(`${indent}#${r.id} [${r.status}] ${r.title}`);
172
253
  }
254
+ if (updateAvailable)
255
+ console.log(`\n${formatUpdateNotice(updateAvailable)}`);
173
256
  });
174
257
  }
@@ -1,5 +1,7 @@
1
1
  import { getDb, suppressNextClerkHeartbeat } from "../../db/client.js";
2
2
  import * as herdr from "../../lib/herdr.js";
3
+ import { packageVersion } from "../../lib/package-info.js";
4
+ import { checkForUpdate, formatUpdateNotice } from "../../lib/update-check.js";
3
5
  import { printJson } from "../format.js";
4
6
  const STALE_AFTER_HOURS = 12;
5
7
  export function registerClerkCommands(program) {
@@ -11,7 +13,7 @@ export function registerClerkCommands(program) {
11
13
  .requiredOption("--session-id <id>", "this clerk session's id")
12
14
  .requiredOption("--herdr-pane <id>", "this clerk's own herdr pane id")
13
15
  .option("--force", "claim even if the existing claim is not stale")
14
- .action((opts) => {
16
+ .action(async (opts) => {
15
17
  const db = getDb();
16
18
  const existing = db
17
19
  .prepare("SELECT * FROM first_clerk WHERE id = 1")
@@ -38,6 +40,12 @@ export function registerClerkCommands(program) {
38
40
  // The claim is durable now; the rename below is cosmetic only.
39
41
  renameClaimantWorkspace(opts.herdrPane);
40
42
  printJson(row);
43
+ // Item 42: cached (6h), silent-on-failure update-availability notice.
44
+ // `claim` has no --json mode, so a plain-text line after the JSON
45
+ // output is fine per the spec.
46
+ const update = await checkForUpdate(packageVersion());
47
+ if (update)
48
+ console.log(formatUpdateNotice(update));
41
49
  });
42
50
  clerk
43
51
  .command("status")
@@ -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);
@@ -0,0 +1,43 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { packageVersion, PACKAGE_NAME } from "../../lib/package-info.js";
3
+ import { fetchLatestVersionOrThrow, isNewerVersion } from "../../lib/update-check.js";
4
+ /**
5
+ * `ledger update` (item 42): an explicit, foreground user action, so unlike
6
+ * the passive claim/catchup notice this always checks fresh (never the 6h
7
+ * cache) and never degrades silently — every failure (the version check
8
+ * itself, or the npm install) is reported plainly and exits non-zero.
9
+ * No confirmation prompt: per the spec, running it at all is the user's
10
+ * confirmation.
11
+ */
12
+ export function registerUpdateCommand(program) {
13
+ program
14
+ .command("update")
15
+ .description(`check for and install the latest ${PACKAGE_NAME} from npm`)
16
+ .action(async () => {
17
+ const current = packageVersion();
18
+ let latest;
19
+ try {
20
+ latest = await fetchLatestVersionOrThrow();
21
+ }
22
+ catch (err) {
23
+ const msg = err instanceof Error ? err.message : String(err);
24
+ throw new Error(`couldn't check npm for the latest version: ${msg}`);
25
+ }
26
+ if (!isNewerVersion(latest, current)) {
27
+ console.log(`Already up to date (${current}).`);
28
+ return;
29
+ }
30
+ console.log(`Updating ${PACKAGE_NAME}: ${current} -> ${latest} ...`);
31
+ const res = spawnSync("npm", ["install", "-g", `${PACKAGE_NAME}@latest`], {
32
+ encoding: "utf8",
33
+ stdio: "inherit",
34
+ });
35
+ if (res.error) {
36
+ throw new Error(`npm install failed: ${res.error.message}`);
37
+ }
38
+ if (res.status !== 0) {
39
+ throw new Error(`npm install exited with code ${res.status}`);
40
+ }
41
+ console.log(`Updated ${PACKAGE_NAME}: ${current} -> ${latest}.`);
42
+ });
43
+ }
package/dist/cli/index.js CHANGED
@@ -1,9 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // Must come first — see the comment in suppress-experimental-warnings.ts.
3
3
  import "./suppress-experimental-warnings.js";
4
- import { readFileSync } from "node:fs";
5
- import { dirname, join } from "node:path";
6
- import { fileURLToPath } from "node:url";
7
4
  import { Command } from "commander";
8
5
  import { registerAgentCommands } from "./commands/agents.js";
9
6
  import { registerCatchupCommand } from "./commands/catchup.js";
@@ -13,29 +10,9 @@ import { registerEventCommands } from "./commands/events.js";
13
10
  import { registerInitCommand } from "./commands/init.js";
14
11
  import { registerProjectCommands } from "./commands/projects.js";
15
12
  import { registerRoadmapCommands } from "./commands/roadmap.js";
13
+ import { registerUpdateCommand } from "./commands/update.js";
16
14
  import { touchClerkHeartbeat } from "../db/client.js";
17
- // The version is derived from the package's own package.json at runtime —
18
- // package.json is the ONLY source of it. This entry compiles to
19
- // dist/cli/index.js, two levels below the package root, so resolve the
20
- // file relative to *this running code's own location* rather than the cwd
21
- // (same approach as docs.ts): that works from any invocation directory, in
22
- // a dev checkout, and in an npm install (the npm tarball carries
23
- // package.json at its root — verified in the 0.1.2 tarball). Publishing
24
- // bumps package.json, and since this reads package.json, the two can never
25
- // desync — a literal here is a second copy and is forbidden.
26
- // Graceful degradation: if the file can't be read or parsed (a corrupt
27
- // install), report "unknown" instead of throwing — a version query must
28
- // never crash the CLI (item 30, DECISIONS.md).
29
- function packageVersion() {
30
- const pkgJsonPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
31
- try {
32
- const parsed = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
33
- return parsed.version ?? "unknown";
34
- }
35
- catch {
36
- return "unknown";
37
- }
38
- }
15
+ import { packageVersion } from "../lib/package-info.js";
39
16
  const program = new Command();
40
17
  program
41
18
  .name("ledger")
@@ -50,6 +27,7 @@ registerClerkCommands(program);
50
27
  registerCatchupCommand(program);
51
28
  registerDocsCommand(program);
52
29
  registerInitCommand(program);
30
+ registerUpdateCommand(program);
53
31
  program.exitOverride();
54
32
  try {
55
33
  await program.parseAsync(process.argv);
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
  }
@@ -7,7 +7,8 @@ export const migration0002ProjectHerdrWorkspace = {
7
7
  -- the id here; every later dispatch to the same project reuses it,
8
8
  -- adding a new tab rather than a new workspace. NULL until a first
9
9
  -- dispatch happens, and re-nulled/replaced if that workspace is found
10
- -- closed (see src/lib/herdr.ts workspaceExists).
10
+ -- closed or recycled to a different project (see src/lib/herdr.ts
11
+ -- getWorkspace and openDispatchPane's use of it).
11
12
  ALTER TABLE projects ADD COLUMN herdr_workspace TEXT;
12
13
  `,
13
14
  };
@@ -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
@@ -105,10 +105,9 @@ export function closeWorkspace(workspaceId) {
105
105
  /**
106
106
  * Fetches a workspace's info (including its label). Throws HerdrError
107
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.
108
+ * when a missing workspace is an expected, handled outcome: it suppresses
109
+ * herdr's raw error envelope being echoed to the terminal, while
110
+ * `throwHerdrFailure` still parses it from the piped stderr.
112
111
  */
113
112
  export function getWorkspace(workspaceId, opts) {
114
113
  const result = runHerdr(["workspace", "get", workspaceId], opts);
@@ -117,14 +116,33 @@ export function getWorkspace(workspaceId, opts) {
117
116
  export function renameWorkspace(workspaceId, label) {
118
117
  runHerdr(["workspace", "rename", workspaceId, label]);
119
118
  }
120
- /** True if `workspaceId` still exists (wasn't closed, e.g. by the user). */
121
- export function workspaceExists(workspaceId) {
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) {
122
140
  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;
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;
128
146
  }
129
147
  catch (err) {
130
148
  if (err instanceof HerdrError && err.code === "workspace_not_found") {
@@ -0,0 +1,27 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ // The npm package name this CLI is published as — also the identity used
5
+ // to query the registry for the latest published version (item 42).
6
+ export const PACKAGE_NAME = "@devwithdavid/ledger";
7
+ // This file compiles to dist/lib/package-info.js, two levels below the
8
+ // package root — resolve package.json relative to *this running code's own
9
+ // location* rather than the cwd (same approach as docs.ts/init.ts), so it
10
+ // works from any invocation directory, in a dev checkout, and in an npm
11
+ // install (the npm tarball carries package.json at its root).
12
+ const PACKAGE_JSON_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
13
+ /**
14
+ * The running CLI's own version, read from package.json at runtime —
15
+ * package.json is the ONLY source of it (item 30, DECISIONS.md). Graceful
16
+ * degradation: an unreadable/unparseable package.json reports "unknown"
17
+ * instead of throwing — a version query must never crash the CLI.
18
+ */
19
+ export function packageVersion() {
20
+ try {
21
+ const parsed = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf8"));
22
+ return parsed.version ?? "unknown";
23
+ }
24
+ catch {
25
+ return "unknown";
26
+ }
27
+ }
@@ -0,0 +1,128 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ledgerHome } from "../db/client.js";
4
+ import { PACKAGE_NAME } from "./package-info.js";
5
+ const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // item 42, user-specified
6
+ const REGISTRY_TIMEOUT_MS = 1500; // "short timeout (~1-2s)", item 42
7
+ function cachePath() {
8
+ return join(ledgerHome(), "update-check.json");
9
+ }
10
+ function isUpdateCache(value) {
11
+ const v = value;
12
+ return (typeof v === "object" &&
13
+ v !== null &&
14
+ typeof v.checkedAt === "string" &&
15
+ typeof v.latestVersion === "string");
16
+ }
17
+ /** Per-machine cache under $LEDGER_HOME — deliberately not ledger.db (item 42: this is local cache state, not shared ledger state). */
18
+ function readCache() {
19
+ try {
20
+ const parsed = JSON.parse(readFileSync(cachePath(), "utf8"));
21
+ return isUpdateCache(parsed) ? parsed : null;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ function writeCache(cache) {
28
+ try {
29
+ writeFileSync(cachePath(), JSON.stringify(cache), "utf8");
30
+ }
31
+ catch {
32
+ // Best-effort: a failed cache write must never surface — the notice
33
+ // check degrades silently on any failure (item 42).
34
+ }
35
+ }
36
+ function isFresh(cache) {
37
+ const age = Date.now() - new Date(cache.checkedAt).getTime();
38
+ return age >= 0 && age < CACHE_TTL_MS;
39
+ }
40
+ /**
41
+ * Queries the npm registry directly (not the `npm` CLI — no dependency on
42
+ * it being installed beyond what's needed to actually run the update) for
43
+ * the latest published version. Throws with a descriptive message on any
44
+ * failure — offline, timeout, non-2xx, malformed body; callers choose
45
+ * whether that should be silent (the passive notice, via
46
+ * `fetchLatestVersion` below) or surfaced (`ledger update`, via
47
+ * `fetchLatestVersionOrThrow`).
48
+ */
49
+ async function fetchLatestVersionInternal() {
50
+ const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
51
+ signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
52
+ });
53
+ if (!res.ok) {
54
+ throw new Error(`npm registry returned HTTP ${res.status} for ${PACKAGE_NAME}`);
55
+ }
56
+ const body = await res.json();
57
+ const version = body?.version;
58
+ if (typeof version !== "string") {
59
+ throw new Error("npm registry response had no version field");
60
+ }
61
+ return version;
62
+ }
63
+ /**
64
+ * Silent variant for the passive claim/catchup notice: any failure —
65
+ * offline, timeout, non-2xx, malformed body — must degrade to "no notice
66
+ * this time", never throw (item 42).
67
+ */
68
+ async function fetchLatestVersion() {
69
+ try {
70
+ return await fetchLatestVersionInternal();
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ /** Parses "x.y.z" into a 3-tuple; a missing/non-numeric part reads as 0. */
77
+ function parseVersion(v) {
78
+ const parts = v.split(".").map((p) => Number.parseInt(p, 10));
79
+ return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0].map((n) => (Number.isNaN(n) ? 0 : n));
80
+ }
81
+ /** True when `a` is a properly-newer semver than `b` (not string comparison — "0.2.0" > "0.10.0" would be wrong as strings). */
82
+ export function isNewerVersion(a, b) {
83
+ const [aMaj, aMin, aPatch] = parseVersion(a);
84
+ const [bMaj, bMin, bPatch] = parseVersion(b);
85
+ if (aMaj !== bMaj)
86
+ return aMaj > bMaj;
87
+ if (aMin !== bMin)
88
+ return aMin > bMin;
89
+ return aPatch > bPatch;
90
+ }
91
+ /**
92
+ * Cached (6h TTL) check for a newer npm version than the one running.
93
+ * Returns null whenever no *newer* version is known to exist — the
94
+ * running version is already current, the registry lookup failed, or
95
+ * "unknown" (package.json unreadable) is running. Never throws (item 42:
96
+ * this backs the passive claim/catchup notice, which must degrade
97
+ * silently on any failure).
98
+ */
99
+ export async function checkForUpdate(currentVersion) {
100
+ if (currentVersion === "unknown")
101
+ return null;
102
+ const cached = readCache();
103
+ let latest;
104
+ if (cached && isFresh(cached)) {
105
+ latest = cached.latestVersion;
106
+ }
107
+ else {
108
+ latest = await fetchLatestVersion();
109
+ if (latest)
110
+ writeCache({ checkedAt: new Date().toISOString(), latestVersion: latest });
111
+ }
112
+ if (!latest || !isNewerVersion(latest, currentVersion))
113
+ return null;
114
+ return { current: currentVersion, latest };
115
+ }
116
+ /**
117
+ * Always queries the registry fresh, bypassing the cache — for `ledger
118
+ * update`, an explicit user action where a stale cached answer would be
119
+ * wrong (item 42: "fresh check is fine here"). Throws (rather than
120
+ * degrading to null) since this is a foreground, user-invoked action:
121
+ * failures here must be visible, unlike the passive notice (item 42).
122
+ */
123
+ export async function fetchLatestVersionOrThrow() {
124
+ return fetchLatestVersionInternal();
125
+ }
126
+ export function formatUpdateNotice(info) {
127
+ return `A new version of ledger is available: ${info.current} -> ${info.latest}. Run \`ledger update\` to upgrade.`;
128
+ }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@devwithdavid/ledger",
3
- "version": "0.1.4",
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"