@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 +111 -7
- package/README.md +7 -0
- package/dist/cli/commands/agents.js +134 -4
- package/dist/cli/commands/catchup.js +89 -6
- package/dist/cli/commands/clerk.js +9 -1
- package/dist/cli/commands/projects.js +48 -8
- package/dist/cli/commands/roadmap.js +5 -10
- package/dist/cli/commands/update.js +43 -0
- package/dist/cli/index.js +3 -25
- package/dist/db/client.js +32 -12
- package/dist/db/migrations/0002_project_herdr_workspace.js +2 -1
- package/dist/db/migrations/0005_roadmap_status_states.js +75 -0
- package/dist/db/migrations/index.js +2 -0
- package/dist/db/types.js +17 -0
- package/dist/lib/herdr.js +29 -11
- package/dist/lib/package-info.js +27 -0
- package/dist/lib/update-check.js +128 -0
- package/package.json +2 -2
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
|
|
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
|
|
237
|
-
|
|
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`
|
|
258
|
-
|
|
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|
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 {
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
36
|
-
"giving a `project init`'d (from-scratch) project a real
|
|
37
|
-
"one exists
|
|
38
|
-
"
|
|
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
|
|
73
|
-
.get(repoUrl, deliveryMode,
|
|
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
|
|
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 +=
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
-
/**
|
|
121
|
-
|
|
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
|
|
124
|
-
//
|
|
125
|
-
// noise worth printing to the terminal every time.
|
|
126
|
-
|
|
127
|
-
return
|
|
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.
|
|
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://
|
|
6
|
+
"repository": "git+https://github.com/chewbakartik/ledger.git",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"bin": {
|
|
9
9
|
"ledger": "dist/cli/index.js"
|