@devwithdavid/ledger 0.1.0 → 0.1.3
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 +16 -6
- package/LICENSE +9 -0
- package/README.md +64 -39
- package/dist/cli/commands/agents.js +11 -6
- package/dist/cli/commands/clerk.js +18 -2
- package/dist/cli/commands/init.js +192 -0
- package/dist/cli/index.js +35 -1
- package/dist/cli/suppress-experimental-warnings.js +23 -0
- package/dist/db/client.js +69 -7
- package/package.json +7 -5
- package/skills/ledger/SKILL.md +17 -0
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
|
-
**
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
102
|
-
user word
|
|
103
|
-
|
|
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/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Kartik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -13,7 +13,8 @@ a general product.
|
|
|
13
13
|
|
|
14
14
|
## Prerequisites
|
|
15
15
|
|
|
16
|
-
- [Node.js](https://nodejs.org) ≥
|
|
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,53 +25,64 @@ a general product.
|
|
|
24
25
|
## Install
|
|
25
26
|
|
|
26
27
|
```sh
|
|
27
|
-
|
|
28
|
-
cd ledger
|
|
29
|
-
npm install
|
|
30
|
-
npm run build
|
|
28
|
+
npm install -g @devwithdavid/ledger
|
|
31
29
|
```
|
|
32
30
|
|
|
33
|
-
|
|
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.
|
|
34
34
|
|
|
35
|
-
|
|
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
|
-
```
|
|
44
|
-
|
|
45
|
-
**Link the watcher plugin into herdr** — this is what keeps agent status
|
|
46
|
-
current automatically as dispatched agents work, without any polling:
|
|
35
|
+
**Finish the setup with `ledger init`** — the single post-install step:
|
|
47
36
|
|
|
48
37
|
```sh
|
|
49
|
-
|
|
38
|
+
ledger init
|
|
50
39
|
```
|
|
51
40
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
41
|
+
Run it once after installing. It does four things, printing a status line
|
|
42
|
+
for each as it goes:
|
|
43
|
+
|
|
44
|
+
1. **Verifies `herdr` and `treehouse` are on your PATH.** If either is
|
|
45
|
+
missing it exits without doing anything else, with an install pointer
|
|
46
|
+
for each missing tool (herdr: <https://herdr.dev>, treehouse:
|
|
47
|
+
<https://github.com/kunchenguid/treehouse>).
|
|
48
|
+
2. **Creates the ledger database** at `$LEDGER_HOME/ledger.db` if it
|
|
49
|
+
doesn't exist yet. An existing store is never reset or rewritten.
|
|
50
|
+
3. **Links the herdr watcher plugin**: runs `herdr plugin link` on the
|
|
51
|
+
installed package's root, where `herdr-plugin.toml` ships. That's what
|
|
52
|
+
keeps agent status current automatically as dispatched agents work,
|
|
53
|
+
without any polling — herdr invokes `dist/plugin/watcher.js` whenever a
|
|
54
|
+
pane's detected agent state changes.
|
|
55
|
+
4. **Installs the clerk skill** at `~/.agents/skills/ledger/SKILL.md` from
|
|
56
|
+
the copy bundled with this install, symlinking it into
|
|
57
|
+
`~/.claude/skills/ledger` and `~/.pi/agent/skills/ledger` so either tool
|
|
58
|
+
picks it up. See "Starting a clerk session" below for what the skill
|
|
59
|
+
does.
|
|
60
|
+
|
|
61
|
+
It's idempotent — re-running it (for example after `npm update -g
|
|
62
|
+
@devwithdavid/ledger`) is safe: the store is only created if missing,
|
|
63
|
+
re-linking the plugin leaves herdr's registration unchanged, the skill
|
|
64
|
+
file is always re-synced from the bundled copy, and an existing symlink is
|
|
65
|
+
only touched if it doesn't already point at the right place. The plugin
|
|
66
|
+
link is local and reversible: `herdr plugin unlink ledger` removes it.
|
|
67
|
+
herdr always runs whatever's currently in the installed package's `dist/`,
|
|
68
|
+
so `npm update -g @devwithdavid/ledger` picks up new releases (including
|
|
69
|
+
watcher changes) on the next event.
|
|
57
70
|
|
|
58
71
|
## Starting a clerk session
|
|
59
72
|
|
|
60
73
|
You don't run `ledger` commands yourself day to day — you talk to **the
|
|
61
|
-
clerk** (a Claude Code or Pi session), and it runs them on your behalf.
|
|
62
|
-
`ledger`
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
unrelated session.
|
|
74
|
+
clerk** (a Claude Code or Pi session), and it runs them on your behalf.
|
|
75
|
+
`ledger init` (step 4 above) installs a `ledger` skill so either Claude
|
|
76
|
+
Code or Pi can pick it up — it loads only when you actually ask for
|
|
77
|
+
ledger-related work (register a project, dispatch an agent, check status,
|
|
78
|
+
...), not on every unrelated session.
|
|
67
79
|
|
|
68
80
|
The skill itself carries no machine-specific path: it just tells the clerk
|
|
69
81
|
to run `ledger docs`, which prints `LEDGER.md` by resolving it relative to
|
|
70
82
|
wherever `ledger` is actually installed (works correctly through the
|
|
71
83
|
`npm link` symlink too — proven live, see `DECISIONS.md`). That's what
|
|
72
|
-
makes the skill portable to a fresh machine as-is:
|
|
73
|
-
|
|
84
|
+
makes the skill portable to a fresh machine as-is: run `ledger init` there
|
|
85
|
+
and the skill works with no edits.
|
|
74
86
|
|
|
75
87
|
## Quick start (what the clerk actually runs)
|
|
76
88
|
|
|
@@ -106,9 +118,9 @@ simply never fires.
|
|
|
106
118
|
|
|
107
119
|
| Doc | What's in it |
|
|
108
120
|
|---|---|
|
|
109
|
-
| [`DESIGN.md`](./DESIGN.md) | The philosophy, why this exists, and the original schema/flow brief |
|
|
121
|
+
| [`DESIGN.md`](./DESIGN.md) | The philosophy, why this exists, and the original schema/flow brief (in the git repo — public mirror coming soon) |
|
|
110
122
|
| [`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
|
|
123
|
+
| [`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
124
|
|
|
113
125
|
## Extending
|
|
114
126
|
|
|
@@ -125,11 +137,15 @@ genuinely does belong in core.
|
|
|
125
137
|
|
|
126
138
|
## Example extension
|
|
127
139
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
140
|
+
`ledger-notify` — a desktop-notification plugin that watches for agents
|
|
141
|
+
going `blocked` or `done`, built entirely outside this repo as a worked
|
|
142
|
+
example of the extension model. The plugin lives in a sibling git repo, and
|
|
143
|
+
the implementation brief, [`EXTENSION-EXAMPLE-BRIEF.md`](./EXTENSION-EXAMPLE-BRIEF.md),
|
|
144
|
+
is in this project's git repo — the public mirror for both is coming soon.
|
|
145
|
+
None of that is needed to build an extension, though: the whole contract is
|
|
146
|
+
reading/writing the SQLite file at `$LEDGER_HOME/ledger.db` or shelling out
|
|
147
|
+
to the `ledger` CLI, as [`LEDGER.md` § "Safe ways to extend this"]
|
|
148
|
+
(./LEDGER.md#safe-ways-to-extend-this) describes.
|
|
133
149
|
|
|
134
150
|
## Status
|
|
135
151
|
|
|
@@ -138,3 +154,12 @@ with a high release cadence; some of the CLI/plugin behavior this repo
|
|
|
138
154
|
depends on was reverse-engineered live (their docs don't fully match
|
|
139
155
|
current behavior in places — see `DECISIONS.md`) and may need
|
|
140
156
|
re-verification after either tool upgrades.
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT — see [`LICENSE`](./LICENSE).
|
|
161
|
+
|
|
162
|
+
Versions 0.1.0-0.1.2 were published to npm before this LICENSE file
|
|
163
|
+
existed, so their tarballs don't contain it: published npm versions are
|
|
164
|
+
immutable. As the sole author, the copyright holder grants those versions
|
|
165
|
+
the same MIT license.
|
|
@@ -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
|
|
356
|
-
*
|
|
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).
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
|
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";
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { accessSync, constants, existsSync, mkdirSync, readFileSync, readlinkSync, statSync, symlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { delimiter, dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { getDb, ledgerHome } from "../../db/client.js";
|
|
7
|
+
// This file compiles to dist/cli/commands/init.js, three levels under the
|
|
8
|
+
// package root — resolve the package root relative to *this running code's
|
|
9
|
+
// own location* rather than the cwd (same approach as docs.ts), so it works
|
|
10
|
+
// from any invocation directory, in a dev checkout and in an npm install.
|
|
11
|
+
// The herdr plugin manifest (herdr-plugin.toml) lives at the package root,
|
|
12
|
+
// so that directory is what gets linked.
|
|
13
|
+
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
14
|
+
// Step 4's bundled skill template: a hand-authored `~/.agents/skills/ledger`
|
|
15
|
+
// (cross-agent skill tree; see DECISIONS.md "Clerk bootstrapping") existed
|
|
16
|
+
// on one machine only and was never captured as a reproducible setup step —
|
|
17
|
+
// a second machine's clerk session never loaded LEDGER.md as a result and
|
|
18
|
+
// spent a whole session unaware of its own governance gates. This ships the
|
|
19
|
+
// real skill content as a package asset instead, resolved the same way as
|
|
20
|
+
// the herdr plugin root above, so `init` can (re-)install it anywhere.
|
|
21
|
+
const SKILL_TEMPLATE_PATH = join(PACKAGE_ROOT, "skills/ledger/SKILL.md");
|
|
22
|
+
const AGENTS_SKILL_DIR = join(homedir(), ".agents/skills/ledger");
|
|
23
|
+
const AGENTS_SKILL_FILE = join(AGENTS_SKILL_DIR, "SKILL.md");
|
|
24
|
+
// Deliberately outside this repo (same reasoning as the skill itself living
|
|
25
|
+
// outside it) — per-tool skill directories are symlinks into the shared
|
|
26
|
+
// `~/.agents/skills/<name>` tree, so any coding agent that understands that
|
|
27
|
+
// convention picks it up with zero ledger-specific config of its own.
|
|
28
|
+
const SKILL_SYMLINK_TARGETS = [join(homedir(), ".claude/skills/ledger"), join(homedir(), ".pi/agent/skills/ledger")];
|
|
29
|
+
// Install pointers for the pre-check:
|
|
30
|
+
// herdr: derived from the tool's own npm package metadata, verified
|
|
31
|
+
// 2026-09-03 — NOT guessed: npm view herdr homepage -> https://herdr.dev
|
|
32
|
+
// treehouse: npm's 'treehouse' is an unrelated React package (name
|
|
33
|
+
// squat); the required tool is kunchenguid's git-worktree tool
|
|
34
|
+
// (installed binary v2.3.0) — URL per user confirmation 2026-09-04,
|
|
35
|
+
// not npm metadata.
|
|
36
|
+
// (The README's prerequisites section carries the same treehouse URL —
|
|
37
|
+
// the two now agree; see DECISIONS.md for the resolution.)
|
|
38
|
+
const DOCS = {
|
|
39
|
+
herdr: "https://herdr.dev",
|
|
40
|
+
treehouse: "https://github.com/kunchenguid/treehouse",
|
|
41
|
+
};
|
|
42
|
+
const REQUIRED_TOOLS = ["herdr", "treehouse"];
|
|
43
|
+
/**
|
|
44
|
+
* PATH lookup for a required tool: the first $PATH directory that contains
|
|
45
|
+
* an executable FILE named <tool> wins. A plain lookup, deliberately not a
|
|
46
|
+
* version/capability probe — the pre-check's job is to fail fast with a
|
|
47
|
+
* per-tool install pointer, not to audit the tool (item 30).
|
|
48
|
+
*/
|
|
49
|
+
function findOnPath(tool) {
|
|
50
|
+
const dirs = (process.env["PATH"] ?? "").split(delimiter).filter((d) => d.length > 0);
|
|
51
|
+
for (const dir of dirs) {
|
|
52
|
+
const candidate = join(dir, tool);
|
|
53
|
+
try {
|
|
54
|
+
accessSync(candidate, constants.X_OK);
|
|
55
|
+
if (statSync(candidate).isFile())
|
|
56
|
+
return candidate;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// not here (or not executable) — keep looking
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Step 4 helper: symlink one per-tool skill path to AGENTS_SKILL_DIR,
|
|
66
|
+
* creating its parent directory if needed. Idempotent: a symlink already
|
|
67
|
+
* pointing at the right place is a silent no-op; a pre-existing real
|
|
68
|
+
* file/directory (or a symlink to somewhere else) is left untouched with a
|
|
69
|
+
* warning rather than clobbered — this must never destroy something a user
|
|
70
|
+
* put there on purpose.
|
|
71
|
+
*/
|
|
72
|
+
function linkSkill(linkPath) {
|
|
73
|
+
mkdirSync(dirname(linkPath), { recursive: true });
|
|
74
|
+
if (existsSync(linkPath)) {
|
|
75
|
+
let currentTarget;
|
|
76
|
+
try {
|
|
77
|
+
currentTarget = readlinkSync(linkPath);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Exists but isn't a symlink at all.
|
|
81
|
+
}
|
|
82
|
+
if (currentTarget === AGENTS_SKILL_DIR) {
|
|
83
|
+
console.log(`✓ ${linkPath} already links to ${AGENTS_SKILL_DIR}`);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
console.log(`Warning: ${linkPath} already exists and is not a symlink to ${AGENTS_SKILL_DIR} ` +
|
|
87
|
+
`(${currentTarget ?? "a real file/directory"}) — left untouched. Remove it and ` +
|
|
88
|
+
`re-run 'ledger init' to relink.`);
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
symlinkSync(AGENTS_SKILL_DIR, linkPath);
|
|
93
|
+
console.log(`✓ linked ${linkPath} -> ${AGENTS_SKILL_DIR}`);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The single post-install step (item 30, user-directed): the README's
|
|
97
|
+
* install section no longer tells users to hand-run `herdr plugin link
|
|
98
|
+
* <path>` — this command does that, transparently.
|
|
99
|
+
*
|
|
100
|
+
* Steps, in order:
|
|
101
|
+
* 1. Pre-check herdr AND treehouse on PATH before touching anything. If
|
|
102
|
+
* one or both are missing, exit non-zero with a per-tool install
|
|
103
|
+
* pointer (both in one error when both are missing) — no store is
|
|
104
|
+
* created and no link is attempted.
|
|
105
|
+
* 2. Ensure the ledger store, reusing getDb() (it creates $LEDGER_HOME
|
|
106
|
+
* and opens/migrates ledger.db; an existing store is only opened —
|
|
107
|
+
* never reset or rewritten). Prints the path created or found.
|
|
108
|
+
* 3. Link the herdr plugin: `herdr plugin link <package root>`.
|
|
109
|
+
* Verified live against herdr 0.7.5 (2026-09-03): re-linking a path
|
|
110
|
+
* that's already linked exits 0, prints its `plugin_linked` JSON
|
|
111
|
+
* result, and leaves herdr's plugin registry file byte-identical —
|
|
112
|
+
* so this step is idempotent and the command is safe to re-run (e.g.
|
|
113
|
+
* after `npm update -g @devwithdavid/ledger`). A non-zero exit (for
|
|
114
|
+
* instance an already-linked different path) is surfaced: herdr's
|
|
115
|
+
* own output is printed, the command fails, and the user resolves it
|
|
116
|
+
* (`herdr plugin unlink ledger` first).
|
|
117
|
+
* 4. Install/update the clerk skill: write the bundled
|
|
118
|
+
* `skills/ledger/SKILL.md` to `~/.agents/skills/ledger/SKILL.md`
|
|
119
|
+
* (always overwritten from the bundled copy, so re-running `init`
|
|
120
|
+
* after an upgrade re-syncs it), then symlink `~/.claude/skills/ledger`
|
|
121
|
+
* and `~/.pi/agent/skills/ledger` to it if not already correctly
|
|
122
|
+
* linked. Found missing on a second machine (see DECISIONS.md) —
|
|
123
|
+
* the skill's *content* was portable, but nothing ever installed it.
|
|
124
|
+
*
|
|
125
|
+
* Every step prints an explicit status line (✓) naming what was done and
|
|
126
|
+
* the path/URL involved, so the user sees exactly what `ledger init` did.
|
|
127
|
+
*/
|
|
128
|
+
export function registerInitCommand(program) {
|
|
129
|
+
program
|
|
130
|
+
.command("init")
|
|
131
|
+
.description("one-time post-install setup: verify herdr and treehouse are on PATH, " +
|
|
132
|
+
"create the ledger store if missing, link the herdr watcher plugin, " +
|
|
133
|
+
"install the clerk skill (idempotent — safe to re-run)")
|
|
134
|
+
.action(() => {
|
|
135
|
+
// 1. Pre-check both required tools up front, before any side effect.
|
|
136
|
+
for (const tool of REQUIRED_TOOLS) {
|
|
137
|
+
if (findOnPath(tool)) {
|
|
138
|
+
console.log(`✓ ${tool} found on PATH`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const missing = REQUIRED_TOOLS.filter((tool) => !findOnPath(tool));
|
|
142
|
+
if (missing.length > 0) {
|
|
143
|
+
// All missing tools are reported in one error, per the exact
|
|
144
|
+
// message format "<tool> not found on PATH - install it first:
|
|
145
|
+
// <docs URL>" (item 30). The CLI entry prints this as a single
|
|
146
|
+
// `Error:` line and exits non-zero.
|
|
147
|
+
throw new Error(missing.map((tool) => `${tool} not found on PATH - install it first: ${DOCS[tool]}`).join("; "));
|
|
148
|
+
}
|
|
149
|
+
// 2. Ensure the store — reuse the existing getDb() machinery (it
|
|
150
|
+
// creates the home dir and migrates on open). Never resets or
|
|
151
|
+
// rewrites an existing store.
|
|
152
|
+
const storePath = join(ledgerHome(), "ledger.db");
|
|
153
|
+
const alreadyThere = existsSync(storePath);
|
|
154
|
+
getDb();
|
|
155
|
+
console.log(`✓ ledger store ${alreadyThere ? "found at" : "created at"} ${storePath}`);
|
|
156
|
+
// 3. Link the herdr plugin from the package root.
|
|
157
|
+
const res = spawnSync("herdr", ["plugin", "link", PACKAGE_ROOT], { encoding: "utf8" });
|
|
158
|
+
if (res.error || res.status !== 0) {
|
|
159
|
+
const out = res.stdout ?? "";
|
|
160
|
+
const err = res.stderr ?? "";
|
|
161
|
+
if (out.trim())
|
|
162
|
+
process.stderr.write(`${out.trimEnd()}\n`);
|
|
163
|
+
if (err.trim())
|
|
164
|
+
process.stderr.write(`${err.trimEnd()}\n`);
|
|
165
|
+
const detail = res.error ? res.error.message : `exit ${res.status}`;
|
|
166
|
+
throw new Error(`herdr plugin link failed (${detail})`);
|
|
167
|
+
}
|
|
168
|
+
// Print herdr's own result so a fresh link and a no-op re-link are
|
|
169
|
+
// both visible, then our own status line naming the linked path.
|
|
170
|
+
const out = res.stdout ?? "";
|
|
171
|
+
if (out.trim())
|
|
172
|
+
console.log(out.trimEnd());
|
|
173
|
+
console.log(`✓ herdr plugin linked from ${PACKAGE_ROOT}`);
|
|
174
|
+
// 4. Install/update the clerk skill from the bundled template, then
|
|
175
|
+
// link it into every known per-tool skill tree.
|
|
176
|
+
let skillTemplate;
|
|
177
|
+
try {
|
|
178
|
+
skillTemplate = readFileSync(SKILL_TEMPLATE_PATH, "utf8");
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
throw new Error(`couldn't read the bundled skill template at ${SKILL_TEMPLATE_PATH} ` +
|
|
182
|
+
`— is this a complete ledger install, not a partial copy?`);
|
|
183
|
+
}
|
|
184
|
+
const skillAlreadyThere = existsSync(AGENTS_SKILL_FILE);
|
|
185
|
+
mkdirSync(AGENTS_SKILL_DIR, { recursive: true });
|
|
186
|
+
writeFileSync(AGENTS_SKILL_FILE, skillTemplate);
|
|
187
|
+
console.log(`✓ clerk skill ${skillAlreadyThere ? "updated at" : "installed at"} ${AGENTS_SKILL_FILE}`);
|
|
188
|
+
for (const linkPath of SKILL_SYMLINK_TARGETS) {
|
|
189
|
+
linkSkill(linkPath);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -1,18 +1,47 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// Must come first — see the comment in suppress-experimental-warnings.ts.
|
|
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";
|
|
2
7
|
import { Command } from "commander";
|
|
3
8
|
import { registerAgentCommands } from "./commands/agents.js";
|
|
4
9
|
import { registerCatchupCommand } from "./commands/catchup.js";
|
|
5
10
|
import { registerClerkCommands } from "./commands/clerk.js";
|
|
6
11
|
import { registerDocsCommand } from "./commands/docs.js";
|
|
7
12
|
import { registerEventCommands } from "./commands/events.js";
|
|
13
|
+
import { registerInitCommand } from "./commands/init.js";
|
|
8
14
|
import { registerProjectCommands } from "./commands/projects.js";
|
|
9
15
|
import { registerRoadmapCommands } from "./commands/roadmap.js";
|
|
16
|
+
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
|
+
}
|
|
10
39
|
const program = new Command();
|
|
11
40
|
program
|
|
12
41
|
.name("ledger")
|
|
13
42
|
.description("Durable state store for a personal agent-orchestration workflow " +
|
|
14
43
|
"(projects, roadmap, dispatched agents, events).")
|
|
15
|
-
.version(
|
|
44
|
+
.version(packageVersion());
|
|
16
45
|
registerProjectCommands(program);
|
|
17
46
|
registerRoadmapCommands(program);
|
|
18
47
|
registerAgentCommands(program);
|
|
@@ -20,11 +49,16 @@ registerEventCommands(program);
|
|
|
20
49
|
registerClerkCommands(program);
|
|
21
50
|
registerCatchupCommand(program);
|
|
22
51
|
registerDocsCommand(program);
|
|
52
|
+
registerInitCommand(program);
|
|
23
53
|
program.exitOverride();
|
|
24
54
|
try {
|
|
25
55
|
await program.parseAsync(process.argv);
|
|
56
|
+
// Item 27: heartbeat after the command has run its own logic — see
|
|
57
|
+
// touchClerkHeartbeat's doc comment for why it can't live in getDb().
|
|
58
|
+
touchClerkHeartbeat();
|
|
26
59
|
}
|
|
27
60
|
catch (err) {
|
|
61
|
+
touchClerkHeartbeat();
|
|
28
62
|
if (err.code?.startsWith("commander.")) {
|
|
29
63
|
process.exit(err.exitCode ?? 1);
|
|
30
64
|
}
|
|
@@ -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
|
|
20
|
-
db.
|
|
21
|
-
db.
|
|
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
|
-
|
|
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
|
-
|
|
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,16 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devwithdavid/ledger",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Personal agent-orchestration ledger: SQLite state store, CLI, and herdr watcher plugin.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": "https://yggdrasil.thekartiks.com/chewbakartik/ledger.git",
|
|
5
7
|
"type": "module",
|
|
6
8
|
"bin": {
|
|
7
9
|
"ledger": "dist/cli/index.js"
|
|
8
10
|
},
|
|
9
11
|
"files": [
|
|
12
|
+
"LICENSE",
|
|
10
13
|
"dist",
|
|
11
14
|
"LEDGER.md",
|
|
12
15
|
"README.md",
|
|
13
|
-
"herdr-plugin.toml"
|
|
16
|
+
"herdr-plugin.toml",
|
|
17
|
+
"skills"
|
|
14
18
|
],
|
|
15
19
|
"scripts": {
|
|
16
20
|
"build": "tsc -p tsconfig.json",
|
|
@@ -19,14 +23,12 @@
|
|
|
19
23
|
"prepare": "tsc -p tsconfig.json"
|
|
20
24
|
},
|
|
21
25
|
"engines": {
|
|
22
|
-
"node": ">=
|
|
26
|
+
"node": ">=22.13.0"
|
|
23
27
|
},
|
|
24
28
|
"dependencies": {
|
|
25
|
-
"better-sqlite3": "^11.10.0",
|
|
26
29
|
"commander": "^12.1.0"
|
|
27
30
|
},
|
|
28
31
|
"devDependencies": {
|
|
29
|
-
"@types/better-sqlite3": "^7.6.11",
|
|
30
32
|
"@types/node": "^22.10.2",
|
|
31
33
|
"typescript": "^5.7.2"
|
|
32
34
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ledger
|
|
3
|
+
description: Act as the first clerk for `ledger`, a personal SQLite-backed agent-orchestration tool (herdr + treehouse). Use when asked to register a ledger project, break work into a roadmap, dispatch a coding agent via ledger, check on dispatched agents, run a ledger catch-up, or otherwise manage state via the `ledger` CLI.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Run `ledger docs` and read its full output before doing anything else in
|
|
7
|
+
this role — that prints the complete clerk/agent reference (CLI surface,
|
|
8
|
+
what the first clerk is responsible for, what a dispatched agent is told).
|
|
9
|
+
|
|
10
|
+
This skill is intentionally just a pointer, not a copy of that content, so
|
|
11
|
+
it can't drift out of sync with the real CLI, and carries no machine-
|
|
12
|
+
specific path — `ledger docs` resolves its own reference doc relative to
|
|
13
|
+
wherever the `ledger` package is actually installed on this machine.
|
|
14
|
+
|
|
15
|
+
If `ledger` isn't found on PATH, it isn't installed here yet: find the
|
|
16
|
+
`ledger` project's own repo (or ask the user where it's cloned) and follow
|
|
17
|
+
its `README.md` install steps first.
|