@devwithdavid/ledger 0.1.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 +459 -0
- package/README.md +140 -0
- package/dist/cli/commands/agents.js +457 -0
- package/dist/cli/commands/catchup.js +174 -0
- package/dist/cli/commands/clerk.js +90 -0
- package/dist/cli/commands/docs.js +29 -0
- package/dist/cli/commands/events.js +59 -0
- package/dist/cli/commands/projects.js +134 -0
- package/dist/cli/commands/roadmap.js +120 -0
- package/dist/cli/format.js +19 -0
- package/dist/cli/index.js +33 -0
- package/dist/db/client.js +48 -0
- package/dist/db/migrations/0001_init.js +72 -0
- package/dist/db/migrations/0002_project_herdr_workspace.js +13 -0
- package/dist/db/migrations/0003_agent_authorization_basis.js +18 -0
- package/dist/db/migrations/0004_roadmap_priority.js +19 -0
- package/dist/db/migrations/index.js +10 -0
- package/dist/db/migrations/types.js +1 -0
- package/dist/db/types.js +33 -0
- package/dist/lib/git.js +61 -0
- package/dist/lib/herdr.js +321 -0
- package/dist/lib/treehouse.js +23 -0
- package/dist/plugin/watcher.js +63 -0
- package/herdr-plugin.toml +16 -0
- package/package.json +33 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { getDb } from "../../db/client.js";
|
|
2
|
+
import * as herdr from "../../lib/herdr.js";
|
|
3
|
+
import { printJson } from "../format.js";
|
|
4
|
+
const STALE_AFTER_HOURS = 12;
|
|
5
|
+
export function registerClerkCommands(program) {
|
|
6
|
+
const clerk = program.command("clerk").description("first-clerk authority (single row)");
|
|
7
|
+
clerk
|
|
8
|
+
.command("claim")
|
|
9
|
+
.description("claim first-clerk authority. Fails if another claim is active and not " +
|
|
10
|
+
"stale, unless --force is given")
|
|
11
|
+
.requiredOption("--session-id <id>", "this clerk session's id")
|
|
12
|
+
.requiredOption("--herdr-pane <id>", "this clerk's own herdr pane id")
|
|
13
|
+
.option("--force", "claim even if the existing claim is not stale")
|
|
14
|
+
.action((opts) => {
|
|
15
|
+
const db = getDb();
|
|
16
|
+
const existing = db
|
|
17
|
+
.prepare("SELECT * FROM first_clerk WHERE id = 1")
|
|
18
|
+
.get();
|
|
19
|
+
if (existing && !opts.force && !isStale(existing)) {
|
|
20
|
+
throw new Error(`first_clerk is already claimed by session ${existing.session_id} ` +
|
|
21
|
+
`(pane ${existing.herdr_pane}, claimed ${existing.claimed_at}). ` +
|
|
22
|
+
`Pass --force to override.`);
|
|
23
|
+
}
|
|
24
|
+
const row = db
|
|
25
|
+
.prepare(`INSERT INTO first_clerk (id, session_id, herdr_pane, claimed_at)
|
|
26
|
+
VALUES (1, ?, ?, datetime('now'))
|
|
27
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
28
|
+
session_id = excluded.session_id,
|
|
29
|
+
herdr_pane = excluded.herdr_pane,
|
|
30
|
+
claimed_at = excluded.claimed_at,
|
|
31
|
+
last_seen = NULL
|
|
32
|
+
RETURNING *`)
|
|
33
|
+
.get(opts.sessionId, opts.herdrPane);
|
|
34
|
+
// The claim is durable now; the rename below is cosmetic only.
|
|
35
|
+
renameClaimantWorkspace(opts.herdrPane);
|
|
36
|
+
printJson(row);
|
|
37
|
+
});
|
|
38
|
+
clerk
|
|
39
|
+
.command("status")
|
|
40
|
+
.description("show the current first-clerk claim, if any")
|
|
41
|
+
.action(() => {
|
|
42
|
+
const row = getDb()
|
|
43
|
+
.prepare("SELECT * FROM first_clerk WHERE id = 1")
|
|
44
|
+
.get();
|
|
45
|
+
printJson(row ?? null);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function isStale(row) {
|
|
49
|
+
const claimedAt = new Date(row.claimed_at + "Z").getTime();
|
|
50
|
+
const ageHours = (Date.now() - claimedAt) / (1000 * 60 * 60);
|
|
51
|
+
return ageHours > STALE_AFTER_HOURS;
|
|
52
|
+
}
|
|
53
|
+
const CLERK_WORKSPACE_LABEL = "clerk";
|
|
54
|
+
/**
|
|
55
|
+
* Renames the claimant's own herdr workspace to "clerk" (roadmap item 17):
|
|
56
|
+
* a clerk session started in a project folder otherwise gets a workspace
|
|
57
|
+
* label identical to that project's agent workspaces, and the user can't
|
|
58
|
+
* tell at a glance which workspace is the clerk. Pane ids look like
|
|
59
|
+
* "w3:p1", so the workspace id is the part before the colon.
|
|
60
|
+
*
|
|
61
|
+
* Idempotent: a no-op if the label is already "clerk". Only the
|
|
62
|
+
* claimant's own workspace is ever addressed — its id comes solely from
|
|
63
|
+
* the claimant's own --herdr-pane. Never fails the claim: on any error
|
|
64
|
+
* (workspace gone, herdr unreachable, ...) a warning goes to stderr and
|
|
65
|
+
* the claim stands. Known limitation (accepted for now): during a --force
|
|
66
|
+
* handover the previous claimant's workspace keeps the "clerk" label until
|
|
67
|
+
* it is renamed again; this function never touches workspaces it wasn't
|
|
68
|
+
* given.
|
|
69
|
+
*/
|
|
70
|
+
function renameClaimantWorkspace(herdrPane) {
|
|
71
|
+
const sep = herdrPane.indexOf(":");
|
|
72
|
+
const workspaceId = sep > 0 ? herdrPane.slice(0, sep) : undefined;
|
|
73
|
+
if (!workspaceId) {
|
|
74
|
+
console.error(`Warning: clerk claim succeeded, but the herdr pane "${herdrPane}" ` +
|
|
75
|
+
`has no "<workspace>:" prefix, so its workspace cannot be renamed ` +
|
|
76
|
+
`to "${CLERK_WORKSPACE_LABEL}".`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const ws = herdr.getWorkspace(workspaceId, { quiet: true });
|
|
81
|
+
if (ws.label === CLERK_WORKSPACE_LABEL)
|
|
82
|
+
return; // idempotent
|
|
83
|
+
herdr.renameWorkspace(ws.workspace_id, CLERK_WORKSPACE_LABEL);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
87
|
+
console.error(`Warning: clerk claim succeeded, but renaming workspace ` +
|
|
88
|
+
`"${workspaceId}" to "${CLERK_WORKSPACE_LABEL}" failed: ${msg}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
// This file compiles to dist/cli/commands/docs.js, three levels under the
|
|
5
|
+
// repo root — resolve LEDGER.md relative to *this running code's own
|
|
6
|
+
// location* rather than any hardcoded/machine-specific path, so `ledger
|
|
7
|
+
// docs` works correctly regardless of where the repo was cloned or which
|
|
8
|
+
// machine it's running on (including through an `npm link` symlink: ESM
|
|
9
|
+
// resolves import.meta.url to the real file location).
|
|
10
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
11
|
+
const LEDGER_MD_PATH = join(REPO_ROOT, "LEDGER.md");
|
|
12
|
+
export function registerDocsCommand(program) {
|
|
13
|
+
program
|
|
14
|
+
.command("docs")
|
|
15
|
+
.description("print LEDGER.md, the full clerk/agent reference — read this first " +
|
|
16
|
+
"if acting as the clerk")
|
|
17
|
+
.action(() => {
|
|
18
|
+
let content;
|
|
19
|
+
try {
|
|
20
|
+
content = readFileSync(LEDGER_MD_PATH, "utf8");
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new Error(`couldn't read LEDGER.md at ${LEDGER_MD_PATH} — is this a ` +
|
|
24
|
+
`complete ledger install (git clone + npm run build), not just ` +
|
|
25
|
+
`a copied dist/ directory?`);
|
|
26
|
+
}
|
|
27
|
+
console.log(content);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { getDb } from "../../db/client.js";
|
|
2
|
+
import { printJson, printTable } from "../format.js";
|
|
3
|
+
import { getAgentById } from "./agents.js";
|
|
4
|
+
export function registerEventCommands(program) {
|
|
5
|
+
const event = program.command("event").description("the append-only audit log");
|
|
6
|
+
event
|
|
7
|
+
.command("add")
|
|
8
|
+
.description("record a free-form event against an agent")
|
|
9
|
+
.requiredOption("--agent <id>", "agent id", parseIntOpt)
|
|
10
|
+
.requiredOption("--type <type>", "event type, e.g. note, state_change")
|
|
11
|
+
.option("--payload <json>", "free-form JSON payload")
|
|
12
|
+
.action((opts) => {
|
|
13
|
+
getAgentById(opts.agent); // throws if missing
|
|
14
|
+
if (opts.payload) {
|
|
15
|
+
try {
|
|
16
|
+
JSON.parse(opts.payload);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new Error("--payload must be valid JSON");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const row = getDb()
|
|
23
|
+
.prepare(`INSERT INTO events (agent_id, event_type, payload)
|
|
24
|
+
VALUES (?, ?, ?)
|
|
25
|
+
RETURNING *`)
|
|
26
|
+
.get(opts.agent, opts.type, opts.payload ?? null);
|
|
27
|
+
printJson(row);
|
|
28
|
+
});
|
|
29
|
+
event
|
|
30
|
+
.command("list")
|
|
31
|
+
.description("list events, newest first")
|
|
32
|
+
.option("--agent <id>", "filter by agent id", parseIntOpt)
|
|
33
|
+
.option("--since <iso>", "only events after this timestamp (e.g. from a prior catchup)")
|
|
34
|
+
.option("--json", "output as JSON")
|
|
35
|
+
.action((opts) => {
|
|
36
|
+
let sql = "SELECT * FROM events WHERE 1=1";
|
|
37
|
+
const params = [];
|
|
38
|
+
if (opts.agent !== undefined) {
|
|
39
|
+
sql += " AND agent_id = ?";
|
|
40
|
+
params.push(opts.agent);
|
|
41
|
+
}
|
|
42
|
+
if (opts.since) {
|
|
43
|
+
sql += " AND created_at > ?";
|
|
44
|
+
params.push(opts.since);
|
|
45
|
+
}
|
|
46
|
+
sql += " ORDER BY created_at DESC, id DESC";
|
|
47
|
+
const rows = getDb().prepare(sql).all(...params);
|
|
48
|
+
if (opts.json)
|
|
49
|
+
printJson(rows);
|
|
50
|
+
else
|
|
51
|
+
printTable(rows);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function parseIntOpt(value) {
|
|
55
|
+
const n = Number.parseInt(value, 10);
|
|
56
|
+
if (Number.isNaN(n))
|
|
57
|
+
throw new Error(`not a valid integer: ${value}`);
|
|
58
|
+
return n;
|
|
59
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { getDb, projectsDir } from "../../db/client.js";
|
|
3
|
+
import { addRemote, cloneProject, getRemoteUrl, initProject } from "../../lib/git.js";
|
|
4
|
+
import { printJson, printTable } from "../format.js";
|
|
5
|
+
export function registerProjectCommands(program) {
|
|
6
|
+
const project = program.command("project").description("manage registered projects");
|
|
7
|
+
project
|
|
8
|
+
.command("add <source>")
|
|
9
|
+
.description("register a project by cloning it into ledger's own home directory " +
|
|
10
|
+
"(never the user's working checkout) — source is a remote URL or local path")
|
|
11
|
+
.requiredOption("--name <name>", "unique project name")
|
|
12
|
+
.option("--delivery-mode <mode>", "direct-pr | local-only", "direct-pr")
|
|
13
|
+
.action((source, opts) => {
|
|
14
|
+
const { db, deliveryMode, destPath } = prepareRegistration(opts);
|
|
15
|
+
const { defaultBranch } = cloneProject(source, destPath);
|
|
16
|
+
const row = insertProject(db, opts.name, source, destPath, defaultBranch, deliveryMode);
|
|
17
|
+
printJson(row);
|
|
18
|
+
});
|
|
19
|
+
project
|
|
20
|
+
.command("init")
|
|
21
|
+
.description("register a brand-new project that doesn't exist anywhere yet — no repo " +
|
|
22
|
+
"to clone, local or remote. Creates an empty git repo (one empty initial " +
|
|
23
|
+
"commit, so treehouse has a ref to lease worktrees from) in ledger's own " +
|
|
24
|
+
"home directory; a dispatched agent scaffolds it from there.")
|
|
25
|
+
.requiredOption("--name <name>", "unique project name")
|
|
26
|
+
.option("--delivery-mode <mode>", "direct-pr | local-only", "local-only")
|
|
27
|
+
.action((opts) => {
|
|
28
|
+
const { db, deliveryMode, destPath } = prepareRegistration(opts);
|
|
29
|
+
const { defaultBranch } = initProject(destPath);
|
|
30
|
+
const row = insertProject(db, opts.name, null, destPath, defaultBranch, deliveryMode);
|
|
31
|
+
printJson(row);
|
|
32
|
+
});
|
|
33
|
+
project
|
|
34
|
+
.command("update <name>")
|
|
35
|
+
.description("update a project's repo_url and/or delivery_mode — most commonly, " +
|
|
36
|
+
"giving a `project init`'d (from-scratch) project a real remote once " +
|
|
37
|
+
"one exists. Adds a git 'origin' remote to the local clone if it " +
|
|
38
|
+
"doesn't already have one; never overwrites an existing remote.")
|
|
39
|
+
.option("--repo-url <url>", "remote URL the project now has")
|
|
40
|
+
.option("--delivery-mode <mode>", "direct-pr | local-only")
|
|
41
|
+
.action((name, opts) => {
|
|
42
|
+
if (!opts.repoUrl && !opts.deliveryMode) {
|
|
43
|
+
throw new Error("give at least one of --repo-url or --delivery-mode");
|
|
44
|
+
}
|
|
45
|
+
const existing = getProjectByName(name);
|
|
46
|
+
let deliveryMode = existing.delivery_mode;
|
|
47
|
+
if (opts.deliveryMode) {
|
|
48
|
+
deliveryMode = opts.deliveryMode;
|
|
49
|
+
if (deliveryMode !== "direct-pr" && deliveryMode !== "local-only") {
|
|
50
|
+
throw new Error("--delivery-mode must be direct-pr or local-only");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// repo_url must always reflect the actual git remote, not just what
|
|
54
|
+
// was requested — an existing remote is never silently overwritten,
|
|
55
|
+
// so the DB record can't be allowed to silently diverge from it.
|
|
56
|
+
let repoUrl = existing.repo_url;
|
|
57
|
+
let remoteAdded = false;
|
|
58
|
+
let repoUrlMismatch = false;
|
|
59
|
+
if (opts.repoUrl) {
|
|
60
|
+
const currentRemote = getRemoteUrl(existing.local_clone_path, "origin");
|
|
61
|
+
if (currentRemote === null) {
|
|
62
|
+
addRemote(existing.local_clone_path, "origin", opts.repoUrl);
|
|
63
|
+
repoUrl = opts.repoUrl;
|
|
64
|
+
remoteAdded = true;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
repoUrl = currentRemote;
|
|
68
|
+
repoUrlMismatch = currentRemote !== opts.repoUrl;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const row = getDb()
|
|
72
|
+
.prepare(`UPDATE projects SET repo_url = ?, delivery_mode = ? WHERE name = ? RETURNING *`)
|
|
73
|
+
.get(repoUrl, deliveryMode, name);
|
|
74
|
+
printJson({
|
|
75
|
+
...row,
|
|
76
|
+
git_remote_added: remoteAdded,
|
|
77
|
+
...(repoUrlMismatch
|
|
78
|
+
? {
|
|
79
|
+
warning: `an 'origin' remote already exists at ${repoUrl}, which differs from ` +
|
|
80
|
+
`the --repo-url given (${opts.repoUrl}) — ledger recorded the existing ` +
|
|
81
|
+
`remote's actual URL and did not touch git; resolve manually if needed`,
|
|
82
|
+
}
|
|
83
|
+
: {}),
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
project
|
|
87
|
+
.command("list")
|
|
88
|
+
.description("list registered projects")
|
|
89
|
+
.option("--json", "output as JSON")
|
|
90
|
+
.action((opts) => {
|
|
91
|
+
const rows = getDb()
|
|
92
|
+
.prepare("SELECT * FROM projects ORDER BY name")
|
|
93
|
+
.all();
|
|
94
|
+
if (opts.json)
|
|
95
|
+
printJson(rows);
|
|
96
|
+
else
|
|
97
|
+
printTable(rows);
|
|
98
|
+
});
|
|
99
|
+
project
|
|
100
|
+
.command("get <name>")
|
|
101
|
+
.description("show a project by name")
|
|
102
|
+
.action((name) => {
|
|
103
|
+
const row = getProjectByName(name);
|
|
104
|
+
printJson(row);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
function prepareRegistration(opts) {
|
|
108
|
+
const deliveryMode = opts.deliveryMode;
|
|
109
|
+
if (deliveryMode !== "direct-pr" && deliveryMode !== "local-only") {
|
|
110
|
+
throw new Error("--delivery-mode must be direct-pr or local-only");
|
|
111
|
+
}
|
|
112
|
+
const db = getDb();
|
|
113
|
+
const existing = db.prepare("SELECT id FROM projects WHERE name = ?").get(opts.name);
|
|
114
|
+
if (existing) {
|
|
115
|
+
throw new Error(`a project named "${opts.name}" is already registered`);
|
|
116
|
+
}
|
|
117
|
+
const destPath = join(projectsDir(), opts.name);
|
|
118
|
+
return { db, deliveryMode, destPath };
|
|
119
|
+
}
|
|
120
|
+
function insertProject(db, name, repoUrl, localClonePath, defaultBranch, deliveryMode) {
|
|
121
|
+
return db
|
|
122
|
+
.prepare(`INSERT INTO projects (name, repo_url, local_clone_path, default_branch, delivery_mode)
|
|
123
|
+
VALUES (?, ?, ?, ?, ?)
|
|
124
|
+
RETURNING *`)
|
|
125
|
+
.get(name, repoUrl, localClonePath, defaultBranch, deliveryMode);
|
|
126
|
+
}
|
|
127
|
+
export function getProjectByName(name) {
|
|
128
|
+
const row = getDb()
|
|
129
|
+
.prepare("SELECT * FROM projects WHERE name = ?")
|
|
130
|
+
.get(name);
|
|
131
|
+
if (!row)
|
|
132
|
+
throw new Error(`no project named "${name}"`);
|
|
133
|
+
return row;
|
|
134
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { getDb } from "../../db/client.js";
|
|
2
|
+
import { ROADMAP_PRIORITIES } from "../../db/types.js";
|
|
3
|
+
import { printJson, printTable } from "../format.js";
|
|
4
|
+
import { getProjectByName } from "./projects.js";
|
|
5
|
+
const VALID_STATUSES = [
|
|
6
|
+
"planned",
|
|
7
|
+
"in_progress",
|
|
8
|
+
"blocked",
|
|
9
|
+
"done",
|
|
10
|
+
"dropped",
|
|
11
|
+
];
|
|
12
|
+
export function registerRoadmapCommands(program) {
|
|
13
|
+
const roadmap = program
|
|
14
|
+
.command("roadmap")
|
|
15
|
+
.description("manage the hierarchical feature breakdown for a project");
|
|
16
|
+
roadmap
|
|
17
|
+
.command("add")
|
|
18
|
+
.description("add a roadmap item (top-level, or a sub-item via --parent)")
|
|
19
|
+
.requiredOption("--project <name>", "project name")
|
|
20
|
+
.requiredOption("--title <title>", "item title")
|
|
21
|
+
.option("--description <text>", "longer description")
|
|
22
|
+
.option("--parent <id>", "parent roadmap item id, for a sub-item", parseIntOpt)
|
|
23
|
+
.option("--priority <priority>", `item priority (${ROADMAP_PRIORITIES.join("|")}, default normal)`)
|
|
24
|
+
.action((opts) => {
|
|
25
|
+
const project = getProjectByName(opts.project);
|
|
26
|
+
const db = getDb();
|
|
27
|
+
// Note: assertValidPriority() is an assertion function (returns
|
|
28
|
+
// void) — it validates/narrows, it must not be used as the value.
|
|
29
|
+
let priority = "normal";
|
|
30
|
+
if (opts.priority !== undefined) {
|
|
31
|
+
assertValidPriority(opts.priority);
|
|
32
|
+
priority = opts.priority;
|
|
33
|
+
}
|
|
34
|
+
if (opts.parent !== undefined) {
|
|
35
|
+
const parent = db
|
|
36
|
+
.prepare("SELECT id, project_id FROM roadmap WHERE id = ?")
|
|
37
|
+
.get(opts.parent);
|
|
38
|
+
if (!parent)
|
|
39
|
+
throw new Error(`no roadmap item #${opts.parent}`);
|
|
40
|
+
if (parent.project_id !== project.id) {
|
|
41
|
+
throw new Error(`roadmap item #${opts.parent} belongs to a different project`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const row = db
|
|
45
|
+
.prepare(`INSERT INTO roadmap (project_id, parent_id, title, description, priority)
|
|
46
|
+
VALUES (?, ?, ?, ?, ?)
|
|
47
|
+
RETURNING *`)
|
|
48
|
+
.get(project.id, opts.parent ?? null, opts.title, opts.description ?? null, priority);
|
|
49
|
+
printJson(row);
|
|
50
|
+
});
|
|
51
|
+
roadmap
|
|
52
|
+
.command("list")
|
|
53
|
+
.description("list roadmap items for a project")
|
|
54
|
+
.requiredOption("--project <name>", "project name")
|
|
55
|
+
.option("--status <status>", `filter by status (${VALID_STATUSES.join("|")})`)
|
|
56
|
+
.option("--all", "include done/dropped items (default: exclude them)")
|
|
57
|
+
.option("--json", "output as JSON")
|
|
58
|
+
.action((opts) => {
|
|
59
|
+
const project = getProjectByName(opts.project);
|
|
60
|
+
const db = getDb();
|
|
61
|
+
let sql = "SELECT * FROM roadmap WHERE project_id = ?";
|
|
62
|
+
const params = [project.id];
|
|
63
|
+
if (opts.status) {
|
|
64
|
+
assertValidStatus(opts.status);
|
|
65
|
+
sql += " AND status = ?";
|
|
66
|
+
params.push(opts.status);
|
|
67
|
+
}
|
|
68
|
+
else if (!opts.all) {
|
|
69
|
+
sql += " AND status NOT IN ('done', 'dropped')";
|
|
70
|
+
}
|
|
71
|
+
sql += " ORDER BY parent_id IS NOT NULL, id";
|
|
72
|
+
const rows = db.prepare(sql).all(...params);
|
|
73
|
+
if (opts.json)
|
|
74
|
+
printJson(rows);
|
|
75
|
+
else
|
|
76
|
+
printTable(rows);
|
|
77
|
+
});
|
|
78
|
+
roadmap
|
|
79
|
+
.command("update <id>")
|
|
80
|
+
.description("update a roadmap item's status/title/description/priority")
|
|
81
|
+
.option("--status <status>", `new status (${VALID_STATUSES.join("|")})`)
|
|
82
|
+
.option("--title <title>", "new title")
|
|
83
|
+
.option("--description <text>", "new description")
|
|
84
|
+
.option("--priority <priority>", `new priority (${ROADMAP_PRIORITIES.join("|")})`)
|
|
85
|
+
.action((id, opts) => {
|
|
86
|
+
if (opts.status)
|
|
87
|
+
assertValidStatus(opts.status);
|
|
88
|
+
if (opts.priority)
|
|
89
|
+
assertValidPriority(opts.priority);
|
|
90
|
+
const db = getDb();
|
|
91
|
+
const existing = db
|
|
92
|
+
.prepare("SELECT * FROM roadmap WHERE id = ?")
|
|
93
|
+
.get(Number(id));
|
|
94
|
+
if (!existing)
|
|
95
|
+
throw new Error(`no roadmap item #${id}`);
|
|
96
|
+
const row = db
|
|
97
|
+
.prepare(`UPDATE roadmap
|
|
98
|
+
SET status = ?, title = ?, description = ?, priority = ?, updated_at = datetime('now')
|
|
99
|
+
WHERE id = ?
|
|
100
|
+
RETURNING *`)
|
|
101
|
+
.get(opts.status ?? existing.status, opts.title ?? existing.title, opts.description ?? existing.description, opts.priority ?? existing.priority, Number(id));
|
|
102
|
+
printJson(row);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function assertValidStatus(status) {
|
|
106
|
+
if (!VALID_STATUSES.includes(status)) {
|
|
107
|
+
throw new Error(`--status must be one of: ${VALID_STATUSES.join(", ")}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function assertValidPriority(priority) {
|
|
111
|
+
if (!ROADMAP_PRIORITIES.includes(priority)) {
|
|
112
|
+
throw new Error(`--priority must be one of: ${ROADMAP_PRIORITIES.join(", ")}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function parseIntOpt(value) {
|
|
116
|
+
const n = Number.parseInt(value, 10);
|
|
117
|
+
if (Number.isNaN(n))
|
|
118
|
+
throw new Error(`not a valid integer: ${value}`);
|
|
119
|
+
return n;
|
|
120
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function printJson(value) {
|
|
2
|
+
console.log(JSON.stringify(value, null, 2));
|
|
3
|
+
}
|
|
4
|
+
/** Simple aligned-column table for terminal output (no dependency needed). */
|
|
5
|
+
export function printTable(rows) {
|
|
6
|
+
if (rows.length === 0) {
|
|
7
|
+
console.log("(none)");
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const asRecords = rows;
|
|
11
|
+
const columns = Object.keys(asRecords[0]);
|
|
12
|
+
const widths = columns.map((col) => Math.max(col.length, ...asRecords.map((row) => String(row[col] ?? "").length)));
|
|
13
|
+
const formatRow = (cells) => cells.map((cell, i) => cell.padEnd(widths[i])).join(" ");
|
|
14
|
+
console.log(formatRow(columns));
|
|
15
|
+
console.log(formatRow(widths.map((w) => "-".repeat(w))));
|
|
16
|
+
for (const row of asRecords) {
|
|
17
|
+
console.log(formatRow(columns.map((col) => String(row[col] ?? ""))));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { registerAgentCommands } from "./commands/agents.js";
|
|
4
|
+
import { registerCatchupCommand } from "./commands/catchup.js";
|
|
5
|
+
import { registerClerkCommands } from "./commands/clerk.js";
|
|
6
|
+
import { registerDocsCommand } from "./commands/docs.js";
|
|
7
|
+
import { registerEventCommands } from "./commands/events.js";
|
|
8
|
+
import { registerProjectCommands } from "./commands/projects.js";
|
|
9
|
+
import { registerRoadmapCommands } from "./commands/roadmap.js";
|
|
10
|
+
const program = new Command();
|
|
11
|
+
program
|
|
12
|
+
.name("ledger")
|
|
13
|
+
.description("Durable state store for a personal agent-orchestration workflow " +
|
|
14
|
+
"(projects, roadmap, dispatched agents, events).")
|
|
15
|
+
.version("0.1.0");
|
|
16
|
+
registerProjectCommands(program);
|
|
17
|
+
registerRoadmapCommands(program);
|
|
18
|
+
registerAgentCommands(program);
|
|
19
|
+
registerEventCommands(program);
|
|
20
|
+
registerClerkCommands(program);
|
|
21
|
+
registerCatchupCommand(program);
|
|
22
|
+
registerDocsCommand(program);
|
|
23
|
+
program.exitOverride();
|
|
24
|
+
try {
|
|
25
|
+
await program.parseAsync(process.argv);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
if (err.code?.startsWith("commander.")) {
|
|
29
|
+
process.exit(err.exitCode ?? 1);
|
|
30
|
+
}
|
|
31
|
+
console.error(`Error: ${err.message}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { migrations } from "./migrations/index.js";
|
|
6
|
+
export function ledgerHome() {
|
|
7
|
+
return process.env["LEDGER_HOME"] ?? join(homedir(), ".ledger");
|
|
8
|
+
}
|
|
9
|
+
export function projectsDir() {
|
|
10
|
+
return join(ledgerHome(), "projects");
|
|
11
|
+
}
|
|
12
|
+
let db;
|
|
13
|
+
export function getDb() {
|
|
14
|
+
if (db)
|
|
15
|
+
return db;
|
|
16
|
+
const home = ledgerHome();
|
|
17
|
+
mkdirSync(home, { recursive: true });
|
|
18
|
+
mkdirSync(projectsDir(), { recursive: true });
|
|
19
|
+
db = new Database(join(home, "ledger.db"));
|
|
20
|
+
db.pragma("journal_mode = WAL");
|
|
21
|
+
db.pragma("foreign_keys = ON");
|
|
22
|
+
applyMigrations(db);
|
|
23
|
+
return db;
|
|
24
|
+
}
|
|
25
|
+
function applyMigrations(database) {
|
|
26
|
+
database.exec(`
|
|
27
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
28
|
+
version INTEGER PRIMARY KEY,
|
|
29
|
+
name TEXT NOT NULL,
|
|
30
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
31
|
+
);
|
|
32
|
+
`);
|
|
33
|
+
const appliedRow = database
|
|
34
|
+
.prepare("SELECT COALESCE(MAX(version), 0) AS v FROM schema_migrations")
|
|
35
|
+
.get();
|
|
36
|
+
const applied = appliedRow.v;
|
|
37
|
+
const pending = migrations.filter((m) => m.version > applied);
|
|
38
|
+
if (pending.length === 0)
|
|
39
|
+
return;
|
|
40
|
+
const insertMigration = database.prepare("INSERT INTO schema_migrations (version, name) VALUES (?, ?)");
|
|
41
|
+
for (const migration of pending) {
|
|
42
|
+
const run = database.transaction(() => {
|
|
43
|
+
database.exec(migration.sql);
|
|
44
|
+
insertMigration.run(migration.version, migration.name);
|
|
45
|
+
});
|
|
46
|
+
run();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export const migration0001Init = {
|
|
2
|
+
version: 1,
|
|
3
|
+
name: "init",
|
|
4
|
+
sql: `
|
|
5
|
+
CREATE TABLE projects (
|
|
6
|
+
id INTEGER PRIMARY KEY,
|
|
7
|
+
name TEXT NOT NULL UNIQUE,
|
|
8
|
+
repo_url TEXT, -- NULL for a from-scratch project (ledger project init): no origin exists yet
|
|
9
|
+
local_clone_path TEXT NOT NULL,
|
|
10
|
+
default_branch TEXT NOT NULL,
|
|
11
|
+
delivery_mode TEXT NOT NULL DEFAULT 'direct-pr'
|
|
12
|
+
CHECK (delivery_mode IN ('direct-pr', 'local-only')),
|
|
13
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
CREATE TABLE roadmap (
|
|
17
|
+
id INTEGER PRIMARY KEY,
|
|
18
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
19
|
+
parent_id INTEGER REFERENCES roadmap(id),
|
|
20
|
+
title TEXT NOT NULL,
|
|
21
|
+
description TEXT,
|
|
22
|
+
status TEXT NOT NULL DEFAULT 'planned'
|
|
23
|
+
CHECK (status IN ('planned', 'in_progress', 'blocked', 'done', 'dropped')),
|
|
24
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
25
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
CREATE INDEX idx_roadmap_project ON roadmap(project_id);
|
|
29
|
+
CREATE INDEX idx_roadmap_parent ON roadmap(parent_id);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE agents (
|
|
32
|
+
id INTEGER PRIMARY KEY,
|
|
33
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
34
|
+
roadmap_item_id INTEGER REFERENCES roadmap(id),
|
|
35
|
+
task_description TEXT NOT NULL,
|
|
36
|
+
worktree_path TEXT NOT NULL,
|
|
37
|
+
herdr_workspace TEXT NOT NULL,
|
|
38
|
+
herdr_tab TEXT NOT NULL,
|
|
39
|
+
herdr_pane TEXT NOT NULL,
|
|
40
|
+
coding_agent TEXT NOT NULL,
|
|
41
|
+
status TEXT NOT NULL DEFAULT 'working'
|
|
42
|
+
CHECK (status IN ('blocked', 'working', 'done', 'idle')),
|
|
43
|
+
outcome TEXT,
|
|
44
|
+
spawned_by INTEGER REFERENCES agents(id),
|
|
45
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
46
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
CREATE INDEX idx_agents_project ON agents(project_id);
|
|
50
|
+
CREATE INDEX idx_agents_status ON agents(status);
|
|
51
|
+
CREATE INDEX idx_agents_herdr_pane ON agents(herdr_pane);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE events (
|
|
54
|
+
id INTEGER PRIMARY KEY,
|
|
55
|
+
agent_id INTEGER NOT NULL REFERENCES agents(id),
|
|
56
|
+
event_type TEXT NOT NULL,
|
|
57
|
+
payload TEXT,
|
|
58
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
CREATE INDEX idx_events_agent ON events(agent_id);
|
|
62
|
+
CREATE INDEX idx_events_created_at ON events(created_at);
|
|
63
|
+
|
|
64
|
+
CREATE TABLE first_clerk (
|
|
65
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
66
|
+
session_id TEXT NOT NULL,
|
|
67
|
+
herdr_pane TEXT NOT NULL,
|
|
68
|
+
claimed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
69
|
+
last_seen TEXT
|
|
70
|
+
);
|
|
71
|
+
`,
|
|
72
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const migration0002ProjectHerdrWorkspace = {
|
|
2
|
+
version: 2,
|
|
3
|
+
name: "project_herdr_workspace",
|
|
4
|
+
sql: `
|
|
5
|
+
-- One herdr workspace per project, not per dispatch (per the user):
|
|
6
|
+
-- the first dispatch to a project creates its workspace and records
|
|
7
|
+
-- the id here; every later dispatch to the same project reuses it,
|
|
8
|
+
-- adding a new tab rather than a new workspace. NULL until a first
|
|
9
|
+
-- dispatch happens, and re-nulled/replaced if that workspace is found
|
|
10
|
+
-- closed (see src/lib/herdr.ts workspaceExists).
|
|
11
|
+
ALTER TABLE projects ADD COLUMN herdr_workspace TEXT;
|
|
12
|
+
`,
|
|
13
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const migration0003AgentAuthorizationBasis = {
|
|
2
|
+
version: 3,
|
|
3
|
+
name: "agent_authorization_basis",
|
|
4
|
+
sql: `
|
|
5
|
+
-- Clerk gate C6 (DECISIONS.md, 2026-08-23): every dispatch must record
|
|
6
|
+
-- who authorized it. 'user-explicit' = an in-the-moment green light
|
|
7
|
+
-- from the user; 'pre-authorized' = a previously granted, per-item,
|
|
8
|
+
-- revocable standing latitude the clerk requested. The value is
|
|
9
|
+
-- clerk-attested -- the CLI cannot know whether the user actually
|
|
10
|
+
-- said yes, it only enforces that a basis is declared and recorded so
|
|
11
|
+
-- every dispatch is auditable (the default is no longer "allowed").
|
|
12
|
+
-- NULL only for rows predating this column; the dispatch command
|
|
13
|
+
-- refuses any new row without one.
|
|
14
|
+
ALTER TABLE agents ADD COLUMN authorization_basis TEXT
|
|
15
|
+
CHECK (authorization_basis IS NULL
|
|
16
|
+
OR authorization_basis IN ('user-explicit', 'pre-authorized'));
|
|
17
|
+
`,
|
|
18
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const migration0004RoadmapPriority = {
|
|
2
|
+
version: 4,
|
|
3
|
+
name: "roadmap_priority",
|
|
4
|
+
sql: `
|
|
5
|
+
-- Roadmap item priority (DECISIONS.md, 2026-08-23, user-directed):
|
|
6
|
+
-- a coarse triage rank for ordering the not-done queue at catch-up.
|
|
7
|
+
-- The captain chose a coarse enum over integer rank (rank values
|
|
8
|
+
-- rot as items are added and completed) and over dependency edges
|
|
9
|
+
-- (readiness is already carried by status = 'blocked' + description;
|
|
10
|
+
-- auto-unblock is a separate future feature). When stale, a wrong
|
|
11
|
+
-- priority only mis-sorts the queue -- visible at catch-up --
|
|
12
|
+
-- never silently blocking ready work the way a stale dependency
|
|
13
|
+
-- edge would. NOT NULL with a default so pre-existing rows get
|
|
14
|
+
-- backfilled to 'normal' by the ALTER itself.
|
|
15
|
+
ALTER TABLE roadmap ADD COLUMN priority TEXT NOT NULL
|
|
16
|
+
DEFAULT 'normal'
|
|
17
|
+
CHECK (priority IN ('high', 'normal', 'low'));
|
|
18
|
+
`,
|
|
19
|
+
};
|