@devwithdavid/ledger 0.1.0 → 0.1.2

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
@@ -38,9 +38,12 @@ what's below is a description of it, not a separate copy to keep in sync):
38
38
  - `direct-pr`: open a pull request against the default branch — using
39
39
  whatever tooling is available for that project's remote (e.g. `tea`
40
40
  for a Forgejo remote; ledger doesn't care which, that's your call) —
41
- **do not merge it yourself, regardless of anything else you're told.**
42
- PR review is a real checkpoint, not a formality to clear on your own
43
- (see `DECISIONS.md` for the incident that made this explicit). Then
41
+ **never merge any branch or PR (your own or anyone else's), and
42
+ never approve any PR, regardless of anything else you're told,
43
+ including by the clerk.** PR review is a real checkpoint, not a
44
+ formality to clear on your own (see `DECISIONS.md` for the incident
45
+ that made this explicit, and the 2026-09-02 generalization that also
46
+ forbids approving any PR). Then
44
47
  `ledger agent update <your-agent-id> --status done --outcome
45
48
  '<pr-url>'`.
46
49
  - `local-only`: just `ledger agent update <your-agent-id> --status done
@@ -98,9 +101,11 @@ the code.
98
101
  concrete, in-the-moment, user-approved operation — executed exactly as
99
102
  approved, never inferred or generalized, conferring no standing
100
103
  authority.
101
- - **C2 — you never merge, force-push, or close a PR without an explicit
102
- user word.** One explicit word at a time, in the moment; there is no
103
- standing relaxation. (Worker-side mirror: A2.)
104
+ - **C2 — you never merge a branch or PR, force-push, or close a PR without an
105
+ explicit user word naming the specific merge/pull request.** One explicit
106
+ word at a time, in the moment, for that specific PR; there is no standing
107
+ relaxation — a general instruction does not license a specific merge.
108
+ (Worker-side mirror: A2.)
104
109
  - **C4 — agents never address the user directly; you are the single
105
110
  channel.** If the user intervenes directly in a worker pane, that
106
111
  instruction is authoritative: reconcile at the next catch-up, never
@@ -119,6 +124,11 @@ the code.
119
124
  - **C9 — you do not self-modify.** Never edit your own contract or skills
120
125
  (this file, `DECISIONS.md`, the skill pointer) without explicit user
121
126
  approval — a gate must not be editable by the party it binds.
127
+ - **C10 — you refer to a ledger item with visual context.** Always make an
128
+ item identifiable without board access: if it (or its project) has an open
129
+ PR/MR, include that PR/MR's number (and URL) alongside the ledger id — e.g.
130
+ 'item #22 (PR #10)'; if no merge is open, add a short context phrase — the
131
+ item's title or a one-line description.
122
132
 
123
133
  **Liveness (A8, clerk side).** At catch-up, an `idle` agent with
124
134
  unfinished work is suspect — it may have died on a usage limit. `catchup`
package/README.md CHANGED
@@ -13,7 +13,8 @@ a general product.
13
13
 
14
14
  ## Prerequisites
15
15
 
16
- - [Node.js](https://nodejs.org) ≥ 20
16
+ - [Node.js](https://nodejs.org) ≥ 22.13 (needed for the built-in
17
+ `node:sqlite` storage driver — see [`DECISIONS.md`](./DECISIONS.md))
17
18
  - [herdr](https://herdr.dev) installed and running — the terminal
18
19
  workspace/pane manager. `herdr status` should show a running server.
19
20
  - [treehouse](https://github.com/kunchenguid/treehouse) installed — isolated
@@ -24,36 +25,29 @@ a general product.
24
25
  ## Install
25
26
 
26
27
  ```sh
27
- git clone <this-repo> ledger # or you already have it locally
28
- cd ledger
29
- npm install
30
- npm run build
28
+ npm install -g @devwithdavid/ledger
31
29
  ```
32
30
 
33
- **Make the CLI available.** Either:
34
-
35
- ```sh
36
- npm link # puts `ledger` on PATH globally
37
- ```
38
-
39
- or invoke it directly / alias it:
40
-
41
- ```sh
42
- node dist/cli/index.js ...
43
- ```
31
+ That's the whole install. The package ships pre-built, and the global
32
+ install puts `ledger` on your PATH — no build step, no separate linking
33
+ step: the `ledger` binary lands on PATH with the install itself.
44
34
 
45
35
  **Link the watcher plugin into herdr** — this is what keeps agent status
46
- current automatically as dispatched agents work, without any polling:
36
+ current automatically as dispatched agents work, without any polling.
37
+ `herdr-plugin.toml` ships inside the package, so point the link at the
38
+ installed package's root:
47
39
 
48
40
  ```sh
49
- herdr plugin link .
41
+ herdr plugin link "$(npm root -g)/@devwithdavid/ledger"
50
42
  ```
51
43
 
52
44
  This registers `herdr-plugin.toml`, so herdr invokes `dist/plugin/watcher.js`
53
45
  whenever a pane's detected agent state changes. It's local and reversible:
54
- `herdr plugin unlink ledger` removes it. Re-run `npm run build` after any
55
- change to `src/plugin/watcher.ts` — herdr always invokes whatever's
56
- currently in `dist/`.
46
+ `herdr plugin unlink ledger` removes it. herdr always runs whatever's
47
+ currently in the installed package's `dist/`, so `npm update -g
48
+ @devwithdavid/ledger` picks up new releases (including watcher changes) on
49
+ the next event; re-run the link command if you want the registered version
50
+ to track a new release.
57
51
 
58
52
  ## Starting a clerk session
59
53
 
@@ -106,9 +100,9 @@ simply never fires.
106
100
 
107
101
  | Doc | What's in it |
108
102
  |---|---|
109
- | [`DESIGN.md`](./DESIGN.md) | The philosophy, why this exists, and the original schema/flow brief |
103
+ | [`DESIGN.md`](./DESIGN.md) | The philosophy, why this exists, and the original schema/flow brief (in the git repo — public mirror coming soon) |
110
104
  | [`LEDGER.md`](./LEDGER.md) | The operational reference — full CLI surface, what the first clerk is responsible for vs. what a dispatched agent is told, and how to extend this safely |
111
- | [`DECISIONS.md`](./DECISIONS.md) | Running log of implementation decisions and why, including things verified live against the real herdr/treehouse binaries (some of herdr's actual behavior differs from its docs — see this file before assuming a documented API shape is accurate) |
105
+ | [`DECISIONS.md`](./DECISIONS.md) | Running log of implementation decisions and why, including things verified live against the real herdr/treehouse binaries — some of herdr's actual behavior differs from its docs, so read this before assuming a documented API shape is accurate (in the git repo — public mirror coming soon) |
112
106
 
113
107
  ## Extending
114
108
 
@@ -125,11 +119,15 @@ genuinely does belong in core.
125
119
 
126
120
  ## Example extension
127
121
 
128
- [`ledger-notify`](../ledger-notify) *(sibling repo, once built)* — a
129
- desktop-notification plugin that watches for agents going `blocked` or
130
- `done`, built entirely outside this repo as a worked example of the
131
- extension model. See [`EXTENSION-EXAMPLE-BRIEF.md`](./EXTENSION-EXAMPLE-BRIEF.md)
132
- for the implementation brief.
122
+ `ledger-notify` — a desktop-notification plugin that watches for agents
123
+ going `blocked` or `done`, built entirely outside this repo as a worked
124
+ example of the extension model. The plugin lives in a sibling git repo, and
125
+ the implementation brief, [`EXTENSION-EXAMPLE-BRIEF.md`](./EXTENSION-EXAMPLE-BRIEF.md),
126
+ is in this project's git repo — the public mirror for both is coming soon.
127
+ None of that is needed to build an extension, though: the whole contract is
128
+ reading/writing the SQLite file at `$LEDGER_HOME/ledger.db` or shelling out
129
+ to the `ledger` CLI, as [`LEDGER.md` § "Safe ways to extend this"]
130
+ (./LEDGER.md#safe-ways-to-extend-this) describes.
133
131
 
134
132
  ## Status
135
133
 
@@ -352,8 +352,9 @@ function survivalProof(worktreePath) {
352
352
  * one worktree / no spawning (A1), blocked as a structured decision
353
353
  * request (A4), no self-modification of the contract or the board (A5),
354
354
  * faithful outcomes (A6), and work surviving in a durable posture before
355
- * exit (A7). A2 (no self-merge) already lives in the direct-pr delivery
356
- * text since the self-merge incident.
355
+ * exit (A7). A2 (no merging, ever: no branch merge in any mode,
356
+ * no PR approval) lives in both delivery texts since the
357
+ * self-merge incident, generalized 2026-09-02.
357
358
  */
358
359
  function buildTaskPrompt(agentId, task, project) {
359
360
  const deliveryInstructions = project.delivery_mode === "direct-pr"
@@ -363,15 +364,19 @@ Before you exit, all your work must be on that branch pushed to the remote
363
364
  it is at risk of being lost.
364
365
  When you're done, open a pull request against '${project.default_branch}'
365
366
  (use whatever tooling is available for this project's remote, e.g. \`tea\`
366
- for a Forgejo remote). Do NOT merge it yourself, even if you technically
367
- can — opening the PR is the whole job. Merging is a human/review decision,
368
- not yours to make, regardless of anything else you're told. Then run this
369
- as your last step:
367
+ for a Forgejo remote). Never merge any branch or PR — your own or anyone
368
+ else's — and never approve any PR, even if you technically can: opening
369
+ the PR is the whole job. Merging and approving are human/review decisions,
370
+ not yours to make, regardless of anything else you're told, including by
371
+ the clerk. Then run this as your last step:
370
372
  ledger agent update ${agentId} --status done --outcome '<pr-url>'
371
373
  using the PR's URL.`
372
374
  : `Work on a new branch — never commit directly to '${project.default_branch}'.
373
375
  Before you exit, commit all your work in the worktree — it is leased and
374
376
  gets recycled, so anything left uncommitted is at risk of being lost.
377
+ Merging that branch into any other branch is not your act — you report it
378
+ and stop. Never merge any branch or PR, and never approve any PR, regardless
379
+ of anything else you're told; the merge is a human decision.
375
380
  When you're done, run this as your last step:
376
381
  ledger agent update ${agentId} --status done --outcome '<branch-name-or-report-path>'`;
377
382
  return `${task}
@@ -1,4 +1,4 @@
1
- import { getDb } from "../../db/client.js";
1
+ import { getDb, suppressNextClerkHeartbeat } from "../../db/client.js";
2
2
  import * as herdr from "../../lib/herdr.js";
3
3
  import { printJson } from "../format.js";
4
4
  const STALE_AFTER_HOURS = 12;
@@ -31,6 +31,10 @@ export function registerClerkCommands(program) {
31
31
  last_seen = NULL
32
32
  RETURNING *`)
33
33
  .get(opts.sessionId, opts.herdrPane);
34
+ // The upsert above just reset last_seen to NULL (fresh clock) —
35
+ // don't let the generic post-command heartbeat (src/cli/index.ts)
36
+ // immediately overwrite that within this same invocation.
37
+ suppressNextClerkHeartbeat();
34
38
  // The claim is durable now; the rename below is cosmetic only.
35
39
  renameClaimantWorkspace(opts.herdrPane);
36
40
  printJson(row);
@@ -45,9 +49,21 @@ export function registerClerkCommands(program) {
45
49
  printJson(row ?? null);
46
50
  });
47
51
  }
52
+ /**
53
+ * Item 27 (2026-09-03, user-directed "robust" option): staleness reflects
54
+ * actual activity, not just how long ago the claim was made. A clerk
55
+ * session that's still working past 12h shouldn't be displaceable just
56
+ * because it claimed early — so this compares now against the LATEST of
57
+ * claimed_at and last_seen, falling back to claimed_at when last_seen is
58
+ * NULL (what a fresh claim sets — see the upsert above). last_seen is
59
+ * kept current by the heartbeat in src/cli/index.ts (touchClerkHeartbeat)
60
+ * and, belt-and-braces, by catchup's own write.
61
+ */
48
62
  function isStale(row) {
49
63
  const claimedAt = new Date(row.claimed_at + "Z").getTime();
50
- const ageHours = (Date.now() - claimedAt) / (1000 * 60 * 60);
64
+ const lastSeen = row.last_seen ? new Date(row.last_seen + "Z").getTime() : claimedAt;
65
+ const lastActivity = Math.max(claimedAt, lastSeen);
66
+ const ageHours = (Date.now() - lastActivity) / (1000 * 60 * 60);
51
67
  return ageHours > STALE_AFTER_HOURS;
52
68
  }
53
69
  const CLERK_WORKSPACE_LABEL = "clerk";
package/dist/cli/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ // Must come first — see the comment in suppress-experimental-warnings.ts.
3
+ import "./suppress-experimental-warnings.js";
2
4
  import { Command } from "commander";
3
5
  import { registerAgentCommands } from "./commands/agents.js";
4
6
  import { registerCatchupCommand } from "./commands/catchup.js";
@@ -7,6 +9,7 @@ import { registerDocsCommand } from "./commands/docs.js";
7
9
  import { registerEventCommands } from "./commands/events.js";
8
10
  import { registerProjectCommands } from "./commands/projects.js";
9
11
  import { registerRoadmapCommands } from "./commands/roadmap.js";
12
+ import { touchClerkHeartbeat } from "../db/client.js";
10
13
  const program = new Command();
11
14
  program
12
15
  .name("ledger")
@@ -23,8 +26,12 @@ registerDocsCommand(program);
23
26
  program.exitOverride();
24
27
  try {
25
28
  await program.parseAsync(process.argv);
29
+ // Item 27: heartbeat after the command has run its own logic — see
30
+ // touchClerkHeartbeat's doc comment for why it can't live in getDb().
31
+ touchClerkHeartbeat();
26
32
  }
27
33
  catch (err) {
34
+ touchClerkHeartbeat();
28
35
  if (err.code?.startsWith("commander.")) {
29
36
  process.exit(err.exitCode ?? 1);
30
37
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * node:sqlite is still experimental (unflagged since Node 22.13.0/23.4.0)
3
+ * and prints an ExperimentalWarning the moment it's first imported.
4
+ * Suppress only that one warning — everything else still reaches stderr
5
+ * as normal.
6
+ *
7
+ * Must be the *first* import in the CLI entry point: ES module static
8
+ * imports are hoisted and executed before the importing module's own
9
+ * top-level body, so this override has to be its own dependency-free
10
+ * module, imported before anything that (transitively) imports
11
+ * "node:sqlite" — otherwise the warning fires before the override is
12
+ * installed.
13
+ */
14
+ const originalEmitWarning = process.emitWarning.bind(process);
15
+ process.emitWarning = ((warning, ...args) => {
16
+ const type = typeof args[0] === "string" ? args[0] : args[0]?.type;
17
+ const message = warning instanceof Error ? warning.message : warning;
18
+ if (type === "ExperimentalWarning" && typeof message === "string" && message.includes("SQLite")) {
19
+ return;
20
+ }
21
+ return originalEmitWarning(warning, ...args);
22
+ });
23
+ export {};
package/dist/db/client.js CHANGED
@@ -1,8 +1,16 @@
1
- import Database from "better-sqlite3";
2
1
  import { mkdirSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { migrations } from "./migrations/index.js";
6
+ // node:sqlite emits its one-time ExperimentalWarning as soon as the module
7
+ // is *loaded* — a static `import ... from "node:sqlite"` here would trigger
8
+ // it during ESM's graph-link phase, before the CLI entry point's warning
9
+ // filter (src/cli/suppress-experimental-warnings.ts) has run. Loading it
10
+ // via `require` instead defers that load to normal, in-order statement
11
+ // execution, so the filter is already installed by the time it fires.
12
+ const require = createRequire(import.meta.url);
13
+ const { DatabaseSync } = require("node:sqlite");
6
14
  export function ledgerHome() {
7
15
  return process.env["LEDGER_HOME"] ?? join(homedir(), ".ledger");
8
16
  }
@@ -16,12 +24,59 @@ export function getDb() {
16
24
  const home = ledgerHome();
17
25
  mkdirSync(home, { recursive: true });
18
26
  mkdirSync(projectsDir(), { recursive: true });
19
- db = new Database(join(home, "ledger.db"));
20
- db.pragma("journal_mode = WAL");
21
- db.pragma("foreign_keys = ON");
27
+ db = new DatabaseSync(join(home, "ledger.db"));
28
+ db.exec("PRAGMA journal_mode = WAL");
29
+ db.exec("PRAGMA foreign_keys = ON");
22
30
  applyMigrations(db);
23
31
  return db;
24
32
  }
33
+ let suppressNextHeartbeat = false;
34
+ /**
35
+ * `clerk claim`'s own upsert resets last_seen to NULL on every claim,
36
+ * fresh or forced — "a fresh claim starts a fresh clock" (DECISIONS.md,
37
+ * item 27). Without this, the generic post-command heartbeat below would
38
+ * immediately overwrite that NULL with `now` before the same invocation
39
+ * ends, erasing the reset the claim command just made. Call this right
40
+ * after the upsert; it's consumed (one-shot) by this invocation's own
41
+ * touchClerkHeartbeat() call in src/cli/index.ts.
42
+ */
43
+ export function suppressNextClerkHeartbeat() {
44
+ suppressNextHeartbeat = true;
45
+ }
46
+ /**
47
+ * Item 27 (activity-based clerk liveness, 2026-09-03 user decision):
48
+ * bump first_clerk.last_seen so staleness reflects real activity, not
49
+ * just time-since-claim. Called once per CLI invocation, from
50
+ * src/cli/index.ts, AFTER the invoked command's own logic has run —
51
+ * deliberately not from inside getDb() itself. `clerk claim` reads
52
+ * first_clerk to decide whether the *existing* claim is stale before it
53
+ * does anything else; if a heartbeat fired on that same getDb() call it
54
+ * would stamp last_seen = now on the very row being checked (which may
55
+ * belong to a different, possibly-dead session) and erase the staleness
56
+ * the check exists to detect. Running the heartbeat after the command
57
+ * body closes that gap.
58
+ *
59
+ * A no-op if the store was never opened this invocation (e.g. --help),
60
+ * there's no first_clerk row yet (0 rows updated), or the invocation was
61
+ * itself a `clerk claim` (see suppressNextClerkHeartbeat). Never throws:
62
+ * a heartbeat failure must not fail the command it's riding on, so any
63
+ * error is only warned to stderr.
64
+ */
65
+ export function touchClerkHeartbeat() {
66
+ if (suppressNextHeartbeat) {
67
+ suppressNextHeartbeat = false;
68
+ return;
69
+ }
70
+ if (!db)
71
+ return;
72
+ try {
73
+ db.prepare("UPDATE first_clerk SET last_seen = datetime('now') WHERE id = 1").run();
74
+ }
75
+ catch (err) {
76
+ const msg = err instanceof Error ? err.message : String(err);
77
+ console.error(`Warning: clerk heartbeat failed: ${msg}`);
78
+ }
79
+ }
25
80
  function applyMigrations(database) {
26
81
  database.exec(`
27
82
  CREATE TABLE IF NOT EXISTS schema_migrations (
@@ -39,10 +94,17 @@ function applyMigrations(database) {
39
94
  return;
40
95
  const insertMigration = database.prepare("INSERT INTO schema_migrations (version, name) VALUES (?, ?)");
41
96
  for (const migration of pending) {
42
- const run = database.transaction(() => {
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 {
43
101
  database.exec(migration.sql);
44
102
  insertMigration.run(migration.version, migration.name);
45
- });
46
- run();
103
+ database.exec("COMMIT");
104
+ }
105
+ catch (err) {
106
+ database.exec("ROLLBACK");
107
+ throw err;
108
+ }
47
109
  }
48
110
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devwithdavid/ledger",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Personal agent-orchestration ledger: SQLite state store, CLI, and herdr watcher plugin.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,14 +19,12 @@
19
19
  "prepare": "tsc -p tsconfig.json"
20
20
  },
21
21
  "engines": {
22
- "node": ">=20"
22
+ "node": ">=22.13.0"
23
23
  },
24
24
  "dependencies": {
25
- "better-sqlite3": "^11.10.0",
26
25
  "commander": "^12.1.0"
27
26
  },
28
27
  "devDependencies": {
29
- "@types/better-sqlite3": "^7.6.11",
30
28
  "@types/node": "^22.10.2",
31
29
  "typescript": "^5.7.2"
32
30
  }