@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.
@@ -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
+ };