lockage 0.0.0-stage → 0.9.1

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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +355 -2
  3. package/dist/db/index.d.ts +52 -0
  4. package/dist/db/index.js +238 -0
  5. package/dist/db/schema.sql +238 -0
  6. package/dist/hook/clients.d.ts +56 -0
  7. package/dist/hook/clients.js +258 -0
  8. package/dist/hook/context.d.ts +22 -0
  9. package/dist/hook/context.js +40 -0
  10. package/dist/hook/dispatch.d.ts +21 -0
  11. package/dist/hook/dispatch.js +212 -0
  12. package/dist/hook/guard.d.ts +34 -0
  13. package/dist/hook/guard.js +106 -0
  14. package/dist/hook/repo.d.ts +40 -0
  15. package/dist/hook/repo.js +173 -0
  16. package/dist/hook/setup-kiro.d.ts +30 -0
  17. package/dist/hook/setup-kiro.js +118 -0
  18. package/dist/index.d.ts +47 -0
  19. package/dist/index.js +694 -0
  20. package/dist/launcher/client.d.ts +22 -0
  21. package/dist/launcher/client.js +57 -0
  22. package/dist/launcher/publish-adapter.d.ts +88 -0
  23. package/dist/launcher/publish-adapter.js +233 -0
  24. package/dist/launcher/publish-cli.d.ts +2 -0
  25. package/dist/launcher/publish-cli.js +123 -0
  26. package/dist/launcher/publish.d.ts +140 -0
  27. package/dist/launcher/publish.js +522 -0
  28. package/dist/lockage/board.d.ts +247 -0
  29. package/dist/lockage/board.js +407 -0
  30. package/dist/lockage/claim.d.ts +307 -0
  31. package/dist/lockage/claim.js +580 -0
  32. package/dist/lockage/files.d.ts +229 -0
  33. package/dist/lockage/files.js +403 -0
  34. package/dist/lockage/git.d.ts +92 -0
  35. package/dist/lockage/git.js +183 -0
  36. package/dist/lockage/globs.d.ts +26 -0
  37. package/dist/lockage/globs.js +125 -0
  38. package/dist/lockage/merge.d.ts +132 -0
  39. package/dist/lockage/merge.js +831 -0
  40. package/dist/lockage/publish-lock.d.ts +38 -0
  41. package/dist/lockage/publish-lock.js +73 -0
  42. package/dist/lockage/tickets.d.ts +213 -0
  43. package/dist/lockage/tickets.js +340 -0
  44. package/dist/lockage/worktree.d.ts +212 -0
  45. package/dist/lockage/worktree.js +563 -0
  46. package/package.json +43 -4
@@ -0,0 +1,238 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { readFileSync, mkdirSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, join } from 'node:path';
5
+ import { hostname } from 'node:os';
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ /**
8
+ * Location of schema.sql.
9
+ * dev (tsx): read src/db/schema.sql directly.
10
+ * build (tsc): tsc does not copy .sql, so the schema is copied to dist/db/ at build time
11
+ * (handled by package.json's build script). Phase 1 simply assumes "the schema.sql at the same
12
+ * level as this file".
13
+ */
14
+ const SCHEMA_PATH = join(__dirname, 'schema.sql');
15
+ /**
16
+ * Open SQLite, issue PRAGMAs, and apply the schema idempotently.
17
+ * - journal_mode=WAL: concurrent read/write (but WAL is unavailable for :memory:, so skip)
18
+ * - foreign_keys=ON: enable FK constraints
19
+ * - busy_timeout=5000: wait up to 5s on lock contention (both BEGIN IMMEDIATE and the following PRAGMAs). Set it first
20
+ */
21
+ export function openDb(opts) {
22
+ // A file DB cannot be opened by SQLite if its parent directory (default ~/.lockage/; changeable
23
+ // via LOCKAGE_DB) does not exist. Create it idempotently here. This SQLite is the primary source
24
+ // of truth for state.
25
+ if (opts.path !== ':memory:') {
26
+ const dir = dirname(opts.path);
27
+ if (dir && dir !== '.')
28
+ mkdirSync(dir, { recursive: true });
29
+ }
30
+ const db = new DatabaseSync(opts.path);
31
+ const hostId = opts.hostId ?? hostname();
32
+ // Set busy_timeout first. If set later, while another concurrently started connection is opening/
33
+ // closing the DB (WAL recovery, etc.), the following journal_mode switch fails immediately with
34
+ // SQLITE_BUSY instead of waiting. This happens on concurrent startup regardless of migration.
35
+ db.exec('PRAGMA busy_timeout=5000;');
36
+ // :memory: does not support WAL. Use WAL only for a file DB.
37
+ if (opts.path !== ':memory:') {
38
+ db.exec('PRAGMA journal_mode=WAL;');
39
+ }
40
+ db.exec('PRAGMA foreign_keys=ON;');
41
+ const schema = readFileSync(SCHEMA_PATH, 'utf8');
42
+ // Apply the issue → ticket rename (#70) before schema.sql. If applied after, CREATE TABLE IF NOT
43
+ // EXISTS would create an empty tickets first, making the existing issues impossible to rename.
44
+ migrateIssuesToTickets(db, schema);
45
+ // Apply the schema idempotently (the CREATE TABLE IF NOT EXISTS group).
46
+ // New tables such as publish_locks are applied idempotently by schema.sql's CREATE TABLE IF NOT
47
+ // EXISTS and are created here if absent in an existing DB (brand-new tables, so migrate()'s column
48
+ // additions are not needed).
49
+ db.exec(schema);
50
+ // Idempotent migration for an existing DB. CREATE TABLE IF NOT EXISTS does not add columns to an
51
+ // existing table, so newly added columns are checked for presence via PRAGMA table_info and ALTERed.
52
+ migrate(db);
53
+ return {
54
+ db,
55
+ hostId,
56
+ close() {
57
+ db.close();
58
+ },
59
+ };
60
+ }
61
+ /** Whether a column exists (PRAGMA table_info). */
62
+ function hasColumn(db, table, column) {
63
+ const rows = db.prepare(`PRAGMA table_info(${table})`).all();
64
+ return rows.some((r) => r.name === column);
65
+ }
66
+ /** Whether a table exists (sqlite_master). */
67
+ function hasTable(db, table) {
68
+ return (db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).get(table) !== undefined);
69
+ }
70
+ function renameColumnIfExists(db, table, from, to) {
71
+ if (hasTable(db, table) && hasColumn(db, table, from)) {
72
+ db.exec(`ALTER TABLE ${table} RENAME COLUMN ${from} TO ${to}`);
73
+ }
74
+ }
75
+ /**
76
+ * #70: in step with renaming lockage's work unit from issue to ticket, move the existing DB's
77
+ * tables, columns, and board kinds to the new names (idempotent). Does nothing if issues is absent
78
+ * (new DB) or tickets already exists.
79
+ *
80
+ * FTS5's external-content setting (content='issues') does not follow a table rename, so drop
81
+ * issues_fts and its triggers, re-create it as tickets_fts via schema.sql, and rebuild the index.
82
+ * Also drop the old indexes (they would remain rewritten to point at the new column names after the
83
+ * rename and duplicate the new-named ones that schema.sql creates).
84
+ *
85
+ * The rename, applying schema.sql, and rebuilding the index are committed in a single transaction.
86
+ * If it stops midway, the next startup sees tickets so migration does not run, leaving an empty
87
+ * tickets_fts. Updating a pre-migration ticket then would make FTS5's delete look for a row absent
88
+ * from the index and raise SQLITE_CORRUPT_VTAB.
89
+ *
90
+ * @param schema the contents of schema.sql (read by the caller).
91
+ * @returns true if a rename was performed.
92
+ */
93
+ export function migrateIssuesToTickets(db, schema) {
94
+ if (!hasTable(db, 'issues') || hasTable(db, 'tickets'))
95
+ return false;
96
+ db.exec('BEGIN IMMEDIATE');
97
+ try {
98
+ // The pre-lock check may be stale if another concurrently started process finished the migration first.
99
+ if (!hasTable(db, 'issues') || hasTable(db, 'tickets')) {
100
+ db.exec('COMMIT');
101
+ return false;
102
+ }
103
+ db.exec(`
104
+ DROP TRIGGER IF EXISTS issues_ai;
105
+ DROP TRIGGER IF EXISTS issues_ad;
106
+ DROP TRIGGER IF EXISTS issues_au;
107
+ DROP TABLE IF EXISTS issues_fts;
108
+ DROP INDEX IF EXISTS idx_issues_status;
109
+ DROP INDEX IF EXISTS idx_issue_repos_repo;
110
+ DROP INDEX IF EXISTS idx_tasks_issue;
111
+ ALTER TABLE issues RENAME TO tickets;
112
+ `);
113
+ renameColumnIfExists(db, 'tickets', 'issue_ready_at', 'ticket_ready_at');
114
+ if (hasTable(db, 'issue_repos')) {
115
+ db.exec(`ALTER TABLE issue_repos RENAME TO ticket_repos`);
116
+ }
117
+ renameColumnIfExists(db, 'ticket_repos', 'issue_id', 'ticket_id');
118
+ renameColumnIfExists(db, 'tasks', 'issue_id', 'ticket_id');
119
+ renameColumnIfExists(db, 'publish_locks', 'issue_id', 'ticket_id');
120
+ renameColumnIfExists(db, 'board_messages', 'ref_issue_id', 'ref_ticket_id');
121
+ if (hasTable(db, 'board_messages')) {
122
+ // lockage-publish looks at the board's ticket_ready to find tickets awaiting a PR, so move the old kind too.
123
+ db.exec(`UPDATE board_messages SET kind = 'ticket_ready' WHERE kind = 'issue_ready'`);
124
+ }
125
+ db.exec(schema);
126
+ db.exec(`INSERT INTO tickets_fts(tickets_fts) VALUES ('rebuild')`);
127
+ db.exec('COMMIT');
128
+ }
129
+ catch (e) {
130
+ try {
131
+ db.exec('ROLLBACK');
132
+ }
133
+ catch {
134
+ /* already closed */
135
+ }
136
+ throw e;
137
+ }
138
+ return true;
139
+ }
140
+ /**
141
+ * Backward-compatible migration for an existing DB (idempotent).
142
+ * - worktree_assignments.repo_path: the FS root of the repo that merge uses for `git -C`. Absent in
143
+ * an old DB, so ADD COLUMN (existing rows stay NULL = fall back to the argument at merge time).
144
+ * - worktree_assignments.base_sha: the fork-point commit SHA, to compute the diff from base later.
145
+ * Absent in an old DB, so ADD COLUMN (existing rows stay NULL = the diff side falls back to status only).
146
+ * - Per-ticket PR (A-1〜A-3): ADD COLUMN for tickets.integration_branch / tickets.ticket_ready_at /
147
+ * merge_queue.target_branch / board_messages.ref_ticket_id (all nullable).
148
+ * - Remove the CHECK (note|handoff|question) on board_messages.kind (option 3). SQLite requires a
149
+ * table rebuild to change a CHECK, so if the old CHECK remains in the DDL string, copy to _new and swap.
150
+ */
151
+ export function migrate(db) {
152
+ if (!hasColumn(db, 'worktree_assignments', 'repo_path')) {
153
+ db.exec(`ALTER TABLE worktree_assignments ADD COLUMN repo_path TEXT`);
154
+ }
155
+ if (!hasColumn(db, 'worktree_assignments', 'base_sha')) {
156
+ db.exec(`ALTER TABLE worktree_assignments ADD COLUMN base_sha TEXT`);
157
+ }
158
+ // Per-ticket PR additional columns (idempotent)
159
+ if (!hasColumn(db, 'tickets', 'integration_branch')) {
160
+ db.exec(`ALTER TABLE tickets ADD COLUMN integration_branch TEXT`);
161
+ }
162
+ if (!hasColumn(db, 'tickets', 'ticket_ready_at')) {
163
+ db.exec(`ALTER TABLE tickets ADD COLUMN ticket_ready_at INTEGER`);
164
+ }
165
+ if (!hasColumn(db, 'merge_queue', 'target_branch')) {
166
+ db.exec(`ALTER TABLE merge_queue ADD COLUMN target_branch TEXT`);
167
+ }
168
+ if (!hasColumn(db, 'board_messages', 'ref_ticket_id')) {
169
+ db.exec(`ALTER TABLE board_messages ADD COLUMN ref_ticket_id TEXT`);
170
+ }
171
+ // Issue #49: a question's resolution state (NULL = unresolved). Existing rows stay NULL = backward compatible (readable as unresolved).
172
+ if (!hasColumn(db, 'board_messages', 'resolved_at')) {
173
+ db.exec(`ALTER TABLE board_messages ADD COLUMN resolved_at INTEGER`);
174
+ }
175
+ if (!hasColumn(db, 'board_messages', 'resolved_by')) {
176
+ db.exec(`ALTER TABLE board_messages ADD COLUMN resolved_by TEXT`);
177
+ }
178
+ migrateBoardKindCheck(db);
179
+ }
180
+ /**
181
+ * Remove the CHECK constraint on board_messages.kind (option 3). SQLite cannot drop a CHECK via
182
+ * ALTER, so if the old CHECK (kind IN ...) remains in sqlite_master's DDL string, rebuild the table.
183
+ * board has only the seq UNIQUE index and no triggers, so _new create → copy → DROP → RENAME →
184
+ * recreate indexes is safe. A new DB (schema.sql creates it without a CHECK) does not match the DDL and is a no-op.
185
+ */
186
+ function migrateBoardKindCheck(db) {
187
+ const row = db
188
+ .prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='board_messages'`)
189
+ .get();
190
+ if (!row || !/CHECK\s*\(\s*kind\s+IN/i.test(row.sql))
191
+ return; // no CHECK = no rebuild needed
192
+ db.exec('BEGIN IMMEDIATE');
193
+ try {
194
+ // Include ref_ticket_id / resolved_at / resolved_by in the new table too, assuming they already
195
+ // exist from the ADD COLUMN above (otherwise the new columns vanish the moment an old CHECK-ed DB
196
+ // is migrated; the Issue #49 pitfall).
197
+ db.exec(`
198
+ CREATE TABLE board_messages_new (
199
+ id TEXT PRIMARY KEY,
200
+ seq INTEGER NOT NULL,
201
+ author TEXT NOT NULL,
202
+ to_agent TEXT,
203
+ kind TEXT NOT NULL DEFAULT 'note',
204
+ body TEXT NOT NULL,
205
+ ref_task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
206
+ ref_ticket_id TEXT REFERENCES tickets(id) ON DELETE SET NULL,
207
+ resolved_at INTEGER,
208
+ resolved_by TEXT,
209
+ created_at INTEGER NOT NULL,
210
+ host_id TEXT NOT NULL,
211
+ origin TEXT NOT NULL DEFAULT 'local'
212
+ );
213
+ INSERT INTO board_messages_new
214
+ (id, seq, author, to_agent, kind, body, ref_task_id, ref_ticket_id, resolved_at, resolved_by, created_at, host_id, origin)
215
+ SELECT id, seq, author, to_agent, kind, body, ref_task_id, ref_ticket_id, resolved_at, resolved_by, created_at, host_id, origin
216
+ FROM board_messages;
217
+ DROP TABLE board_messages;
218
+ ALTER TABLE board_messages_new RENAME TO board_messages;
219
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_board_seq ON board_messages(seq);
220
+ CREATE INDEX IF NOT EXISTS idx_board_to ON board_messages(to_agent, seq);
221
+ `);
222
+ db.exec('COMMIT');
223
+ }
224
+ catch (e) {
225
+ try {
226
+ db.exec('ROLLBACK');
227
+ }
228
+ catch {
229
+ /* already closed */
230
+ }
231
+ throw e;
232
+ }
233
+ }
234
+ /** unix seconds (integer). All tables' *_at are unified as unix seconds. */
235
+ export function unixNow() {
236
+ return Math.floor(Date.now() / 1000);
237
+ }
238
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,238 @@
1
+ -- =========================================================
2
+ -- coord-dev-mcp schema (per minimal-design.md §3)
3
+ -- Source of truth = local SQLite (WAL). Issue the following PRAGMAs at startup.
4
+ -- PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;
5
+ --
6
+ -- Distribution meta columns are added to every table:
7
+ -- host_id : the originating host (fixed to the local host in Phase 1)
8
+ -- origin : 'local' | 'synced' | 'candidate' (fixed to 'local' in Phase 1)
9
+ -- =========================================================
10
+
11
+ -- ---------- tickets: the container for work items (cross-cutting, long-lived, never claimed. model 3) ----------
12
+ CREATE TABLE IF NOT EXISTS tickets (
13
+ id TEXT PRIMARY KEY, -- UUID/ULID
14
+ title TEXT NOT NULL,
15
+ body TEXT, -- body (background / acceptance criteria). Indexed by FTS5
16
+ status TEXT NOT NULL DEFAULT 'open' -- open|in_progress|closed
17
+ CHECK (status IN ('open','in_progress','closed')),
18
+ priority INTEGER NOT NULL DEFAULT 0, -- larger is higher priority. Ties break by created_at ascending. Negatives allowed (defer). Rough guide -10/0/10/100
19
+ assignee TEXT, -- assignee (not a claim; just an owner label)
20
+ version INTEGER NOT NULL DEFAULT 0, -- D-4 decided: optimistic lock on body/status (CAS only via update_ticket)
21
+ integration_branch TEXT, -- per-ticket PR: integration branch name (NULL = not yet created). A-2
22
+ ticket_ready_at INTEGER, -- per-ticket PR: time ticket_ready was emitted (NULL = not emitted). CAS prevents double emission. A-3
23
+ created_by TEXT,
24
+ created_at INTEGER NOT NULL,
25
+ updated_at INTEGER NOT NULL,
26
+ host_id TEXT NOT NULL,
27
+ origin TEXT NOT NULL DEFAULT 'local'
28
+ );
29
+ CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
30
+
31
+ -- ---------- ticket_repos: ticket↔repo cross-cutting (many-to-many, D-1 decided = join table) ----------
32
+ -- Convention: a ticket with no rows = fully cross-cutting (repo-independent). One or more rows = it concerns those repos.
33
+ CREATE TABLE IF NOT EXISTS ticket_repos (
34
+ ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
35
+ repo TEXT NOT NULL,
36
+ host_id TEXT NOT NULL,
37
+ origin TEXT NOT NULL DEFAULT 'local',
38
+ PRIMARY KEY (ticket_id, repo)
39
+ );
40
+ CREATE INDEX IF NOT EXISTS idx_ticket_repos_repo ON ticket_repos(repo);
41
+
42
+ -- ---------- tickets_fts: full-text search over ticket bodies (FTS5) ----------
43
+ CREATE VIRTUAL TABLE IF NOT EXISTS tickets_fts USING fts5(
44
+ title, body,
45
+ content='tickets',
46
+ content_rowid='rowid',
47
+ tokenize='trigram' -- substring search for Japanese (unicode61 cannot segment CJK)
48
+ );
49
+ CREATE TRIGGER IF NOT EXISTS tickets_ai AFTER INSERT ON tickets BEGIN
50
+ INSERT INTO tickets_fts(rowid, title, body) VALUES (new.rowid, new.title, new.body);
51
+ END;
52
+ CREATE TRIGGER IF NOT EXISTS tickets_ad AFTER DELETE ON tickets BEGIN
53
+ INSERT INTO tickets_fts(tickets_fts, rowid, title, body) VALUES ('delete', old.rowid, old.title, old.body);
54
+ END;
55
+ CREATE TRIGGER IF NOT EXISTS tickets_au AFTER UPDATE ON tickets BEGIN
56
+ INSERT INTO tickets_fts(tickets_fts, rowid, title, body) VALUES ('delete', old.rowid, old.title, old.body);
57
+ INSERT INTO tickets_fts(rowid, title, body) VALUES (new.rowid, new.title, new.body);
58
+ END;
59
+
60
+ -- ---------- tasks: the task entity (has a version column for optimistic locking) ----------
61
+ CREATE TABLE IF NOT EXISTS tasks (
62
+ id TEXT PRIMARY KEY,
63
+ ticket_id TEXT REFERENCES tickets(id) ON DELETE SET NULL, -- parent ticket (nullable. model 3)
64
+ repo TEXT NOT NULL, -- a task is repo-local (FR-7.4)
65
+ title TEXT NOT NULL,
66
+ description TEXT,
67
+ status TEXT NOT NULL DEFAULT 'open' -- open|in_progress|done|abandoned|blocked
68
+ CHECK (status IN ('open','in_progress','done','abandoned','blocked')),
69
+ assignee TEXT, -- the claiming agent_id (NULL = unclaimed)
70
+ priority INTEGER NOT NULL DEFAULT 0, -- larger is higher priority. Ties break by created_at ascending. Negatives allowed (defer). Rough guide -10/0/10/100
71
+ version INTEGER NOT NULL DEFAULT 0, -- for CAS. +1 on every update
72
+ heartbeat_at INTEGER, -- last liveness time of the claim holder (unix sec)
73
+ created_at INTEGER NOT NULL,
74
+ updated_at INTEGER NOT NULL,
75
+ host_id TEXT NOT NULL,
76
+ origin TEXT NOT NULL DEFAULT 'local'
77
+ );
78
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
79
+ CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee);
80
+ CREATE INDEX IF NOT EXISTS idx_tasks_ticket ON tasks(ticket_id);
81
+ CREATE INDEX IF NOT EXISTS idx_tasks_repo ON tasks(repo);
82
+
83
+ -- ---------- task_edges: the dependency DAG (blocks edges) ----------
84
+ CREATE TABLE IF NOT EXISTS task_edges (
85
+ from_task TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
86
+ to_task TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
87
+ kind TEXT NOT NULL DEFAULT 'blocks' CHECK (kind IN ('blocks')),
88
+ host_id TEXT NOT NULL,
89
+ origin TEXT NOT NULL DEFAULT 'local',
90
+ PRIMARY KEY (from_task, to_task)
91
+ );
92
+ CREATE INDEX IF NOT EXISTS idx_edges_to ON task_edges(to_task);
93
+
94
+ -- ---------- claims: advisory lease over file globs ----------
95
+ CREATE TABLE IF NOT EXISTS claims (
96
+ id TEXT PRIMARY KEY,
97
+ holder TEXT NOT NULL, -- agent_id
98
+ globs TEXT NOT NULL, -- JSON array string
99
+ task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
100
+ lease_ttl_sec INTEGER NOT NULL DEFAULT 28800, -- 8h
101
+ heartbeat_at INTEGER NOT NULL,
102
+ expires_at INTEGER NOT NULL, -- heartbeat_at + lease_ttl_sec (denormalized)
103
+ released_at INTEGER, -- explicit release time (NULL = active)
104
+ created_at INTEGER NOT NULL,
105
+ host_id TEXT NOT NULL,
106
+ origin TEXT NOT NULL DEFAULT 'local'
107
+ );
108
+ CREATE INDEX IF NOT EXISTS idx_claims_active ON claims(released_at, expires_at);
109
+ CREATE INDEX IF NOT EXISTS idx_claims_holder ON claims(holder);
110
+
111
+ -- ---------- publish_locks: a per-ticket publish advisory lock (lease/TTL) ----------
112
+ -- An advisory lock that closes the read→act race where two lockage-publish processes pick up
113
+ -- the same ticket simultaneously. It mirrors the lease/TTL design of the claims table (glob lease)
114
+ -- and is acquired/stolen via a BEGIN IMMEDIATE CAS. PK=ticket_id serializes only the same ticket
115
+ -- and does not block other tickets (other tickets in the batch keep being processed). A row with expires_at < now is considered stale and may be stolen.
116
+ CREATE TABLE IF NOT EXISTS publish_locks (
117
+ ticket_id TEXT PRIMARY KEY, -- 1 ticket = 1 lock. Does not block other tickets
118
+ holder TEXT NOT NULL, -- the acquirer token (ulid). Used to authenticate the release
119
+ acquired_at INTEGER NOT NULL, -- acquisition time (unix seconds)
120
+ expires_at INTEGER NOT NULL, -- expiry time (acquired_at + ttl). Past this it may be stolen
121
+ host_id TEXT NOT NULL,
122
+ origin TEXT NOT NULL DEFAULT 'local'
123
+ );
124
+ CREATE INDEX IF NOT EXISTS idx_publish_locks_expires ON publish_locks(expires_at);
125
+
126
+ -- ---------- board_messages: the shared board / directed handoff ----------
127
+ CREATE TABLE IF NOT EXISTS board_messages (
128
+ id TEXT PRIMARY KEY,
129
+ seq INTEGER NOT NULL, -- a monotonically increasing cursor. Assigned MAX+1 inside BEGIN IMMEDIATE
130
+ author TEXT NOT NULL,
131
+ to_agent TEXT,
132
+ -- kind has no CHECK (option 3): the value set will keep growing (note/handoff/question/ticket_ready/…),
133
+ -- so the app-layer z.enum (src/index.ts) is the single source of truth. Changing a CHECK in SQLite
134
+ -- requires recreating the table, which is costly each time it grows, so the DB does not constrain it.
135
+ -- State-machine columns (status/state/edges.kind) are fixed by design, so they are still guarded by a DB CHECK (dividing principle: a growing enum lives in the app layer / a fixed one in the DB).
136
+ kind TEXT NOT NULL DEFAULT 'note',
137
+ body TEXT NOT NULL,
138
+ ref_task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
139
+ ref_ticket_id TEXT REFERENCES tickets(id) ON DELETE SET NULL, -- a reference pointing at a ticket, e.g. ticket_ready. A-3
140
+ -- Issue #49: the resolution state of a question. NULL = unresolved / non-NULL = resolved (at that time). resolved_by is
141
+ -- the resolving actor (auto-resolve = the assignee who set the task to done/abandoned, manual resolve = the agent_id of resolve_question).
142
+ -- No CHECK (same policy as kind. The source of truth is the app layer: resolve_question rejects anything other than a question).
143
+ resolved_at INTEGER, -- NULL = unresolved. Used to filter read_board(unresolved_only)
144
+ resolved_by TEXT, -- the agent_id of the resolving actor (nullable)
145
+ created_at INTEGER NOT NULL,
146
+ host_id TEXT NOT NULL,
147
+ origin TEXT NOT NULL DEFAULT 'local'
148
+ );
149
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_board_seq ON board_messages(seq);
150
+ CREATE INDEX IF NOT EXISTS idx_board_to ON board_messages(to_agent, seq);
151
+
152
+ -- context_entries / context_fts (context tied to git commits) were removed in #69.
153
+ -- Tables left in an existing DB are not DROPped so as not to erase records (harmless since nothing reads or writes them).
154
+ -- Because an old DB still has these names, do not create a table with the same name for another purpose (CREATE TABLE IF NOT EXISTS
155
+ -- would do nothing and keep running with the old column layout).
156
+
157
+ -- ---------- worktree_assignments: agent↔worktree physical isolation ----------
158
+ CREATE TABLE IF NOT EXISTS worktree_assignments (
159
+ id TEXT PRIMARY KEY,
160
+ agent_id TEXT NOT NULL,
161
+ worktree_path TEXT NOT NULL, -- 1 worktree = 1 agent (the core of physical isolation)
162
+ repo_path TEXT, -- the repo's FS root (target of git -C). The merge treats it as the source of truth
163
+ branch TEXT NOT NULL,
164
+ base_sha TEXT, -- the branch-point commit SHA. Used later to compute the diff from base (git diff --name-only <base_sha>...HEAD). NULL for old rows / failed resolution
165
+ task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
166
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','released')),
167
+ created_at INTEGER NOT NULL,
168
+ released_at INTEGER,
169
+ host_id TEXT NOT NULL,
170
+ origin TEXT NOT NULL DEFAULT 'local'
171
+ );
172
+ CREATE INDEX IF NOT EXISTS idx_wt_agent ON worktree_assignments(agent_id, status);
173
+ -- "1 worktree = 1 agent" applies only to active rows (partial UNIQUE).
174
+ -- A column-level UNIQUE would also make released history unique and conflict with §4.5 "reconnect returns to the same deterministic path"
175
+ -- (you could not re-INSERT the same path). Limiting it to active satisfies both.
176
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_wt_path_active
177
+ ON worktree_assignments(worktree_path) WHERE status = 'active';
178
+
179
+ -- ---------- agents: session liveness management (the basis for crash-expiry decisions) ----------
180
+ CREATE TABLE IF NOT EXISTS agents (
181
+ agent_id TEXT PRIMARY KEY,
182
+ label TEXT,
183
+ last_seen_at INTEGER NOT NULL,
184
+ created_at INTEGER NOT NULL,
185
+ host_id TEXT NOT NULL,
186
+ origin TEXT NOT NULL DEFAULT 'local'
187
+ );
188
+
189
+ -- ---------- dirty_sets: pre-commit change announcement (mutable. FR-1.5. D-2 = per-file) ----------
190
+ CREATE TABLE IF NOT EXISTS dirty_sets (
191
+ id TEXT PRIMARY KEY,
192
+ agent_id TEXT NOT NULL,
193
+ worktree_path TEXT NOT NULL,
194
+ files TEXT NOT NULL, -- JSON array: changed file paths (per file)
195
+ updated_at INTEGER NOT NULL,
196
+ host_id TEXT NOT NULL,
197
+ origin TEXT NOT NULL DEFAULT 'local',
198
+ UNIQUE (agent_id, worktree_path) -- one dirty-set row per worktree (overwritten)
199
+ );
200
+ CREATE INDEX IF NOT EXISTS idx_dirty_agent ON dirty_sets(agent_id);
201
+
202
+ -- ---------- merge_queue: the serial queue for the integration merge gate (FR-1.6) ----------
203
+ CREATE TABLE IF NOT EXISTS merge_queue (
204
+ id TEXT PRIMARY KEY,
205
+ seq INTEGER NOT NULL, -- serial processing order. Same numbering discipline as board: MAX+1 inside BEGIN IMMEDIATE
206
+ agent_id TEXT NOT NULL,
207
+ worktree_path TEXT NOT NULL,
208
+ branch TEXT NOT NULL,
209
+ task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
210
+ repo TEXT NOT NULL,
211
+ state TEXT NOT NULL DEFAULT 'queued' -- queued|merging|merged|needs_review
212
+ CHECK (state IN ('queued','merging','merged','needs_review')),
213
+ precheck TEXT, -- 3-way precheck result (JSON)
214
+ target_branch TEXT, -- per-ticket PR: the aggregation target branch (NULL = base, backward compatible). A-1
215
+ enqueued_at INTEGER NOT NULL,
216
+ processed_at INTEGER,
217
+ host_id TEXT NOT NULL,
218
+ origin TEXT NOT NULL DEFAULT 'local'
219
+ );
220
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_merge_seq ON merge_queue(seq);
221
+ CREATE INDEX IF NOT EXISTS idx_merge_state ON merge_queue(state, seq);
222
+
223
+ -- ---------- wakes: the pending queue for A2A wakes (§4.8. non-board wake signals such as overlap) ----------
224
+ -- The board is represented by board_messages (fetched incrementally by the seq cursor), but transient wake signals
225
+ -- that should not be left on the board, such as overlap, are stacked here and consumed by wait_for_unblock.
226
+ -- take_pending_wakes(agent_id, kind) = read the rows with consumed_at IS NULL and mark them consumed.
227
+ CREATE TABLE IF NOT EXISTS wakes (
228
+ id TEXT PRIMARY KEY,
229
+ target TEXT NOT NULL, -- the agent_id to wake
230
+ kind TEXT NOT NULL -- overlap|task_ready|board (currently only overlap goes through this table)
231
+ CHECK (kind IN ('overlap','task_ready','board')),
232
+ payload TEXT, -- JSON (originating agent / files, etc.)
233
+ created_at INTEGER NOT NULL,
234
+ consumed_at INTEGER, -- consumed time (NULL = pending)
235
+ host_id TEXT NOT NULL,
236
+ origin TEXT NOT NULL DEFAULT 'local'
237
+ );
238
+ CREATE INDEX IF NOT EXISTS idx_wakes_pending ON wakes(target, kind, consumed_at);
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Per-client adapters. They call the pure logic in repo.ts / context.ts / guard.ts, parse each
3
+ * client's input (claude / antigravity / kiro), and build the response. Only the input/output
4
+ * shapes are client-specific; the decision logic itself stays inside the pure logic.
5
+ *
6
+ * The final control of exit codes and stdout/stderr is handled by the outermost shell in
7
+ * dispatch.ts. Here we decide "what to output", and dispatch.ts handles "how to output it" (write
8
+ * the JSON to stdout / set the exit code). Rather than returning both side effects (record, stderr
9
+ * log) and an "output payload", each adapter function returns a value natural to its client.
10
+ */
11
+ /**
12
+ * Result of the claude guard. Returns the JSON to write to stdout (null if none). record is already
13
+ * performed as a side effect. For PreToolUse, if decide returns a reason, an ask JSON, otherwise no
14
+ * output. For PostToolUse, record and output nothing.
15
+ */
16
+ export declare function claudeGuard(stdin: string): object | null;
17
+ /**
18
+ * Result of the claude session. Returns the JSON to write to stdout (null if none).
19
+ * For SessionStart, no output if repoState(cwd) is null, otherwise buildContext as
20
+ * additionalContext. For SubagentStart, no output unless agent_type includes lockage-worker, in
21
+ * which case buildWorkerContext as additionalContext. The output JSON's hookEventName echoes the
22
+ * input event name.
23
+ */
24
+ export declare function claudeSession(stdin: string, agentsMd: string): object | null;
25
+ /**
26
+ * Result of the antigravity guard. Always returns an output JSON (returning nothing is treated as
27
+ * deny, so always return a decision).
28
+ * pre: decide null→{decision:'ask'}, with a reason→{decision:'force_ask',reason}.
29
+ * post: record only when there is no error, always {}.
30
+ */
31
+ export declare function antigravityGuard(stdin: string, event: 'pre' | 'post'): object;
32
+ /**
33
+ * Result of the antigravity session. Returns the JSON to inject (injectSteps or {}).
34
+ * If invocationNum!==0 or already injected, {}; otherwise concatenate buildContext over
35
+ * workspacePaths in order, deduplicating by repoState→mainRoot??toplevel.
36
+ */
37
+ export declare function antigravitySession(stdin: string, agentsMd: string): object;
38
+ export declare const KIRO_EXIT_ALLOW = 0;
39
+ export declare const KIRO_EXIT_DENY = 2;
40
+ /**
41
+ * Decision result of the kiro guard. Returns an exit code. Messages to stderr are emitted inside
42
+ * this function.
43
+ * - PostToolUse → exit 0 (a defensive contract; not called by the generated hook)
44
+ * - path unavailable → exit 0 + stderr warning (the list of tool_input keys)
45
+ * - mainTreeRoot !== null → exit 2 + stderr message
46
+ * - otherwise → exit 0
47
+ * Exceptions (e.g. a git parse error) are made fail-closed (exit 2) by the outermost shell in
48
+ * dispatch.ts.
49
+ */
50
+ export declare function kiroGuard(stdin: string): number;
51
+ /**
52
+ * Result of the kiro session. Returns the text to write to stdout (null if none).
53
+ * No output if repoState(cwd) is null, otherwise buildContext (KIRO_HEADER) as stdout text.
54
+ * Appends a self-diagnostic at the end inside the kiro adapter.
55
+ */
56
+ export declare function kiroSession(stdin: string, agentsMd: string): string | null;