@devwithdavid/ledger 0.1.4 → 0.1.5

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
@@ -170,6 +170,15 @@ 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 checks (item 42) whether a newer `@devwithdavid/ledger` npm
174
+ version exists — a plain-text notice after the normal output in
175
+ human-readable mode, or the top-level `update_available` JSON key
176
+ (`{current, latest}` or `null`) in `--json` mode, so `--json` output stays
177
+ valid JSON either way. The check is cached for 6h in
178
+ `$LEDGER_HOME/update-check.json` and degrades silently on any failure
179
+ (offline, timeout, malformed response) — it never delays or breaks
180
+ `catchup`. Run `ledger update` to actually upgrade.
181
+
173
182
  ### First-clerk claiming
174
183
 
175
184
  ```sh
@@ -181,6 +190,25 @@ ledger clerk status
181
190
  `--force` is passed. Do this once per new/resumed clerk session before
182
191
  dispatching anything.
183
192
 
193
+ `claim` runs the same cached update-availability check as `catchup` (item
194
+ 42); since `claim` has no `--json` mode, a matching newer version prints
195
+ as a plain-text line after the claim's JSON output.
196
+
197
+ ### Updating ledger
198
+
199
+ ```sh
200
+ ledger update
201
+ ```
202
+
203
+ Checks npm for the latest `@devwithdavid/ledger` version (always fresh —
204
+ never the 6h cache `claim`/`catchup` use, since this is an explicit user
205
+ action). Already up to date: says so and exits. Newer version available:
206
+ runs `npm install -g @devwithdavid/ledger@latest` immediately (no
207
+ confirmation prompt) and reports old → new version. Unlike the passive
208
+ notice above, failures here (registry unreachable, npm install error) are
209
+ never swallowed — this is a foreground action, so they're reported
210
+ plainly and the command exits non-zero.
211
+
184
212
  ### Registering a project
185
213
 
186
214
  ```sh
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
@@ -426,15 +426,41 @@ function deriveLabel(task) {
426
426
  const truncated = raw.slice(0, 32).replace(/-+$/g, "");
427
427
  return truncated || "task";
428
428
  }
429
+ /**
430
+ * True only if `workspaceId` still exists AND is still the workspace
431
+ * belonging to `project` — not just that the id resolves to *some*
432
+ * workspace. Workspace ids are recycled by herdr (e.g. after a herdr
433
+ * restart resets its allocation), so a stale stored id can collide with an
434
+ * unrelated, freshly-created workspace that happens to reuse the same id.
435
+ * Workspaces are created with `label: project.name` (see openDispatchPane
436
+ * below), so comparing the label is how identity — not just existence — is
437
+ * verified.
438
+ */
439
+ function workspaceBelongsToProject(workspaceId, project) {
440
+ try {
441
+ // quiet: this is a routine "is it still ours?" check, run on every
442
+ // dispatch — a missing/stale workspace is an expected, handled outcome,
443
+ // not noise worth printing to the terminal every time.
444
+ const workspace = herdr.getWorkspace(workspaceId, { quiet: true });
445
+ return workspace.label === project.name;
446
+ }
447
+ catch (err) {
448
+ if (err instanceof herdr.HerdrError && err.code === "workspace_not_found") {
449
+ return false;
450
+ }
451
+ throw err;
452
+ }
453
+ }
429
454
  /**
430
455
  * One herdr workspace per project, not per dispatch (per the user — see
431
456
  * DECISIONS.md): reuses the project's existing workspace as a new tab when
432
457
  * one is already live, or creates it (and records it via the caller) when
433
458
  * this is the project's first dispatch, or its previous workspace was
434
- * closed (e.g. by the user) since the last dispatch.
459
+ * closed (e.g. by the user) or recycled to a different project since the
460
+ * last dispatch.
435
461
  */
436
462
  function openDispatchPane(project, cwd, label) {
437
- if (project.herdr_workspace && herdr.workspaceExists(project.herdr_workspace)) {
463
+ if (project.herdr_workspace && workspaceBelongsToProject(project.herdr_workspace, project)) {
438
464
  const tab = herdr.createTab({
439
465
  workspace: project.herdr_workspace,
440
466
  cwd,
@@ -1,5 +1,7 @@
1
1
  import { getDb } from "../../db/client.js";
2
2
  import { HerdrError, readPane } 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
  import { getProjectByName } from "./projects.js";
5
7
  /** Default tail length for each idle agent's pane read (A8 liveness triage). */
@@ -73,7 +75,7 @@ export function registerCatchupCommand(program) {
73
75
  .option("--project <name>", "scope roadmap (and optionally agents) to one project")
74
76
  .option("--idle-pane-lines <n>", "lines of pane tail to show per idle agent (A8 liveness triage); 0 disables pane reads", parseIdlePaneLinesOpt, IDLE_PANE_TAIL_DEFAULT_LINES)
75
77
  .option("--json", "output as JSON (default: human-readable)")
76
- .action((opts) => {
78
+ .action(async (opts) => {
77
79
  const db = getDb();
78
80
  const project = opts.project ? getProjectByName(opts.project) : undefined;
79
81
  const firstClerk = db
@@ -135,7 +137,20 @@ export function registerCatchupCommand(program) {
135
137
  if (firstClerk) {
136
138
  db.prepare("UPDATE first_clerk SET last_seen = datetime('now') WHERE id = 1").run();
137
139
  }
138
- const summary = { since, blocked, idle, idle_pane_read_note: idlePaneGlobalNote, events, roadmap };
140
+ // Item 42: cached (6h), silent-on-failure update-availability check.
141
+ // Added as an explicit top-level key (`update_available`, null when
142
+ // there's nothing to report) rather than mixed into plain text, so
143
+ // --json stays valid JSON in both cases.
144
+ const updateAvailable = await checkForUpdate(packageVersion());
145
+ const summary = {
146
+ since,
147
+ blocked,
148
+ idle,
149
+ idle_pane_read_note: idlePaneGlobalNote,
150
+ events,
151
+ roadmap,
152
+ update_available: updateAvailable,
153
+ };
139
154
  if (opts.json) {
140
155
  printJson(summary);
141
156
  return;
@@ -170,5 +185,7 @@ export function registerCatchupCommand(program) {
170
185
  const indent = r.parent_id ? " " : " ";
171
186
  console.log(`${indent}#${r.id} [${r.status}] ${r.title}`);
172
187
  }
188
+ if (updateAvailable)
189
+ console.log(`\n${formatUpdateNotice(updateAvailable)}`);
173
190
  });
174
191
  }
@@ -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")
@@ -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);
@@ -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
  };
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,22 +116,6 @@ 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) {
122
- try {
123
- // quiet: this is a routine "is it still there?" check, run on every
124
- // dispatch — a missing workspace is an expected, handled outcome, not
125
- // noise worth printing to the terminal every time.
126
- runHerdr(["workspace", "get", workspaceId], { quiet: true });
127
- return true;
128
- }
129
- catch (err) {
130
- if (err instanceof HerdrError && err.code === "workspace_not_found") {
131
- return false;
132
- }
133
- throw err;
134
- }
135
- }
136
119
  /** Adds a new tab (with its own root pane) to an existing workspace at `cwd`. */
137
120
  export function createTab(opts) {
138
121
  const args = [
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@devwithdavid/ledger",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Personal agent-orchestration ledger: SQLite state store, CLI, and herdr watcher plugin.",
5
5
  "license": "MIT",
6
6
  "repository": "https://yggdrasil.thekartiks.com/chewbakartik/ledger.git",