@dyzsasd/dev-loop 0.22.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/dist/db.js ADDED
@@ -0,0 +1,385 @@
1
+ // dev-loop hub — the SQLite system of record (built-in node:sqlite, WAL).
2
+ // Zero native deps. One process opens one hub.db; see ../docs/HUB-ARCHITECTURE.md §6/§7.
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { mkdirSync } from "node:fs";
5
+ import { dirname } from "node:path";
6
+ // The dev-loop state machine (§3). CHECKed so a fuzzy/typo state can never be stored (kills §10#2).
7
+ export const STATES = ["Backlog", "Todo", "In Progress", "In Review", "Human-Blocked", "Done", "Canceled", "Duplicate"];
8
+ // CHECK clause built FROM `STATES` so the fresh-DB schema (below) and the v1 migration (openDb)
9
+ // can never drift — one source of truth for the legal state set. (DL-25: Human-Blocked added.)
10
+ const STATE_CHECK = STATES.map((s) => `'${s}'`).join(", ");
11
+ // DL-52: same no-drift discipline for channels.transport — the CHECK in BOTH the fresh SCHEMA and the v2
12
+ // ALTER is built from this one source, so adding a transport later can't desync the create vs the migrate.
13
+ const TRANSPORTS = ["bot", "webhook"];
14
+ const TRANSPORT_CHECK = TRANSPORTS.map((t) => `'${t}'`).join(", ");
15
+ // DL split (v3): same no-drift discipline for documents.kind — the CHECK in BOTH the fresh SCHEMA and the
16
+ // v3 rebuild is built from this one source, so adding a doc-kind can't desync create vs migrate. Must match
17
+ // DOC_KINDS in docstore.ts (kept in lock-step by hand; docstore.ts is the LEAF that the zod enum imports).
18
+ const DOC_KINDS = ["strategy", "roadmap", "decisions", "notes", "design"];
19
+ const DOC_KIND_CHECK = DOC_KINDS.map((k) => `'${k}'`).join(", ");
20
+ // ─── Schema ────────────────────────────────────────────────────────────────
21
+ const SCHEMA = `
22
+ CREATE TABLE IF NOT EXISTS actors (
23
+ id TEXT PRIMARY KEY,
24
+ handle TEXT UNIQUE NOT NULL,
25
+ kind TEXT NOT NULL CHECK(kind IN ('agent','human')),
26
+ display_name TEXT NOT NULL,
27
+ active INTEGER NOT NULL DEFAULT 1,
28
+ created_at TEXT NOT NULL
29
+ );
30
+ CREATE TABLE IF NOT EXISTS projects (
31
+ id TEXT PRIMARY KEY,
32
+ key TEXT UNIQUE NOT NULL,
33
+ name TEXT NOT NULL,
34
+ ticket_prefix TEXT NOT NULL DEFAULT 'DL',
35
+ ticket_seq INTEGER NOT NULL DEFAULT 0,
36
+ mode TEXT NOT NULL DEFAULT 'live' CHECK(mode IN ('live','dry-run')),
37
+ autonomy TEXT NOT NULL DEFAULT 'ask' CHECK(autonomy IN ('ask','full')),
38
+ settings_json TEXT NOT NULL DEFAULT '{}',
39
+ created_at TEXT NOT NULL
40
+ );
41
+ CREATE TABLE IF NOT EXISTS labels (
42
+ id TEXT PRIMARY KEY,
43
+ project_id TEXT NOT NULL REFERENCES projects(id),
44
+ name TEXT NOT NULL,
45
+ kind TEXT NOT NULL CHECK(kind IN ('marker','type','owner','subtype','workflow','repo')),
46
+ UNIQUE(project_id, name)
47
+ );
48
+ CREATE TABLE IF NOT EXISTS tickets (
49
+ id TEXT PRIMARY KEY,
50
+ project_id TEXT NOT NULL REFERENCES projects(id),
51
+ title TEXT NOT NULL,
52
+ description TEXT NOT NULL DEFAULT '',
53
+ type TEXT NOT NULL DEFAULT 'Feature',
54
+ state TEXT NOT NULL DEFAULT 'Todo' CHECK(state IN (${STATE_CHECK})),
55
+ assignee TEXT,
56
+ priority INTEGER NOT NULL DEFAULT 0,
57
+ labels TEXT NOT NULL DEFAULT '[]', -- JSON array; REPLACE-style on save_issue (mirrors Linear)
58
+ duplicate_of TEXT, -- §8 dedupe canonical pointer (scalar; pairs with state Duplicate)
59
+ related_to TEXT NOT NULL DEFAULT '[]', -- §4 splits / §15 coverage siblings (JSON array; append-only merge, §18 line 965)
60
+ created_by TEXT NOT NULL,
61
+ created_at TEXT NOT NULL,
62
+ updated_at TEXT NOT NULL
63
+ );
64
+ CREATE INDEX IF NOT EXISTS idx_tickets_project_state ON tickets(project_id, state);
65
+ CREATE TABLE IF NOT EXISTS comments (
66
+ id TEXT PRIMARY KEY,
67
+ ticket_id TEXT NOT NULL REFERENCES tickets(id),
68
+ author TEXT NOT NULL, -- attributable per-agent identity (the headline win)
69
+ body TEXT NOT NULL,
70
+ created_at TEXT NOT NULL
71
+ );
72
+ CREATE INDEX IF NOT EXISTS idx_comments_ticket ON comments(ticket_id, created_at);
73
+ -- append-only attribution / audit log; every write stamps who did it
74
+ CREATE TABLE IF NOT EXISTS events (
75
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
76
+ project_id TEXT NOT NULL,
77
+ ticket_id TEXT,
78
+ actor TEXT NOT NULL,
79
+ kind TEXT NOT NULL, -- e.g. issue.create, issue.transition, comment.add
80
+ data TEXT NOT NULL DEFAULT '{}', -- JSON
81
+ created_at TEXT NOT NULL
82
+ );
83
+ CREATE INDEX IF NOT EXISTS idx_events_project ON events(project_id, id);
84
+ -- ── P4 documents: versioned, attributable, operator-published product docs ────
85
+ -- §17 firewall is STRUCTURAL: docs live ONLY in these tables; NO doc tool touches the
86
+ -- filesystem (no fs import, no path arg) — a doc can never represent a SKILL/conventions/code file.
87
+ CREATE TABLE IF NOT EXISTS documents (
88
+ id TEXT PRIMARY KEY,
89
+ project_id TEXT NOT NULL REFERENCES projects(id),
90
+ kind TEXT NOT NULL CHECK(kind IN (${DOC_KIND_CHECK})), -- PRODUCT docs only; no code-ish kind exists (v3: + 'design')
91
+ slug TEXT NOT NULL,
92
+ title TEXT NOT NULL,
93
+ status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','current')),
94
+ current_version INTEGER NOT NULL DEFAULT 0, -- 0 = never published; else the live PUBLISHED version
95
+ created_by TEXT NOT NULL, -- actor HANDLE (like tickets.created_by), not a FK to actors(id)
96
+ created_at TEXT NOT NULL,
97
+ updated_at TEXT NOT NULL,
98
+ UNIQUE(project_id, slug)
99
+ -- per-kind singleton uniqueness is a PARTIAL index (below), NOT a table constraint: 'design' is
100
+ -- multi-instance (one doc per module slug) so UNIQUE(project_id,kind) must exclude it. The other
101
+ -- four kinds stay single-instance. UNIQUE(project_id,slug) above keeps every slug globally unique.
102
+ );
103
+ CREATE INDEX IF NOT EXISTS idx_documents_project ON documents(project_id, kind);
104
+ -- v3: enforce one doc per kind for the SINGLETON kinds only (excludes 'design', which is per-slug).
105
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_documents_singleton_kind ON documents(project_id, kind) WHERE kind != 'design';
106
+ CREATE TABLE IF NOT EXISTS document_versions (
107
+ id TEXT PRIMARY KEY,
108
+ doc_id TEXT NOT NULL REFERENCES documents(id),
109
+ version INTEGER NOT NULL, -- 1-based, monotonic per doc, append-only
110
+ body TEXT NOT NULL DEFAULT '', -- §16: author-side discipline (same trust as ticket bodies), never a fs path
111
+ status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','current')),
112
+ summary TEXT NOT NULL DEFAULT '',
113
+ base_version INTEGER NOT NULL DEFAULT 0, -- the version edited FROM; the optimistic-CAS key
114
+ author TEXT NOT NULL, -- actor HANDLE
115
+ created_at TEXT NOT NULL,
116
+ UNIQUE(doc_id, version)
117
+ );
118
+ CREATE INDEX IF NOT EXISTS idx_docversions_doc ON document_versions(doc_id, version);
119
+ -- ── P5 discussion board: the Director chairs; invited agents post per round ────
120
+ CREATE TABLE IF NOT EXISTS topics (
121
+ id TEXT PRIMARY KEY,
122
+ project_id TEXT NOT NULL REFERENCES projects(id),
123
+ question TEXT NOT NULL,
124
+ invited TEXT NOT NULL DEFAULT '[]', -- JSON array of actor handles
125
+ status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','closed')),
126
+ round INTEGER NOT NULL DEFAULT 1,
127
+ round_opened_at TEXT NOT NULL, -- wall-clock for the state-free termination budget
128
+ opened_by TEXT NOT NULL, -- the chair (authority = opened_by)
129
+ opened_at TEXT NOT NULL,
130
+ closed_at TEXT,
131
+ decision TEXT -- inline terminal decision (set on close); DATA, never auto-applied (§17)
132
+ );
133
+ CREATE INDEX IF NOT EXISTS idx_topics_project_status ON topics(project_id, status);
134
+ CREATE TABLE IF NOT EXISTS posts (
135
+ id TEXT PRIMARY KEY,
136
+ topic_id TEXT NOT NULL REFERENCES topics(id),
137
+ round INTEGER NOT NULL,
138
+ author TEXT NOT NULL, -- actor HANDLE (attribution)
139
+ kind TEXT NOT NULL DEFAULT 'perspective' CHECK(kind IN ('perspective','synthesis')),
140
+ body TEXT NOT NULL,
141
+ created_at TEXT NOT NULL,
142
+ UNIQUE(topic_id, round, author, kind) -- one perspective per (round, author); chair's synthesis coexists
143
+ );
144
+ CREATE INDEX IF NOT EXISTS idx_posts_topic ON posts(topic_id, round, created_at);
145
+ -- ── P6 IM channel: per-project provider-agnostic two-way plane (§9/§16/§25) ───
146
+ -- §16 STRUCTURAL: this table holds the ENV-VAR NAME (config_ref/secret_ref) + the room id
147
+ -- (channel_ref), NEVER a token/secret/URL. The secret is read from process.env[config_ref]
148
+ -- server-side only and never persisted/returned/logged. (DL-52: for transport='webhook', config_ref
149
+ -- is the env-var NAME of the incoming-webhook URL and secret_ref the optional sign-secret name — still
150
+ -- NAMES, never the literal URL/secret.)
151
+ CREATE TABLE IF NOT EXISTS channels (
152
+ id TEXT PRIMARY KEY,
153
+ project_id TEXT NOT NULL REFERENCES projects(id),
154
+ provider TEXT NOT NULL CHECK(provider IN ('slack','lark')), -- CHECKed: an unknown provider can never be stored
155
+ config_ref TEXT NOT NULL, -- ENV-VAR NAME of the bot token (slack) / app_id (lark) — OR the incoming-webhook URL when transport='webhook'; NEVER the secret/URL literal
156
+ secret_ref TEXT, -- optional ENV-VAR NAME of the app_secret (lark) / signing secret; NEVER the secret
157
+ channel_ref TEXT NOT NULL, -- the room/chat id (slack 'C…' / lark chat_id 'oc_…') — an addressing handle (unused by transport='webhook', which posts to the URL directly)
158
+ inbound_cursor TEXT, -- THE no-daemon cursor: slack ts / lark create_time of the last-seen msg. NULL = never polled
159
+ last_poll_at TEXT, -- wall-clock of the last successful poll (advisory/observability)
160
+ enabled INTEGER NOT NULL DEFAULT 1,
161
+ created_at TEXT NOT NULL,
162
+ updated_at TEXT NOT NULL,
163
+ transport TEXT NOT NULL DEFAULT 'bot' CHECK(transport IN (${TRANSPORT_CHECK})), -- DL-52: 'bot' = provider bot API (default; existing channels unchanged) | 'webhook' = one-way incoming-webhook (no bot app)
164
+ UNIQUE(project_id, provider, channel_ref)
165
+ );
166
+ CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id, enabled);
167
+ -- inbound audit + DEDUP + the durable inbox between stateless Director fires.
168
+ CREATE TABLE IF NOT EXISTS channel_messages (
169
+ id TEXT PRIMARY KEY,
170
+ channel_id TEXT NOT NULL REFERENCES channels(id),
171
+ project_id TEXT NOT NULL, -- denormalized for the §2 project-scope filter
172
+ direction TEXT NOT NULL CHECK(direction IN ('inbound','outbound')),
173
+ provider_msg_id TEXT, -- slack ts / lark message_id — the dedup key
174
+ author_ref TEXT, -- inbound: the OPAQUE provider sender id (NEVER a resolved name/email, NEVER operator authority)
175
+ body TEXT NOT NULL DEFAULT '', -- inbound: operator raw text (DATA, §16-scrubbed before any ticket/doc); outbound: the built allow-listed summary
176
+ kind TEXT, -- outbound: 'digest'|'notify'|'reply'; inbound: NULL
177
+ acted INTEGER NOT NULL DEFAULT 0, -- inbound: 0=in the Director inbox, 1=consumed
178
+ acted_into TEXT, -- the hub artifact id (topic/ticket) the Director turned it into — provenance; the hub stays the state
179
+ created_at TEXT NOT NULL,
180
+ provider_ts TEXT, -- inbound: provider-reported send time (ordering / cursor)
181
+ UNIQUE(channel_id, direction, provider_msg_id)
182
+ );
183
+ CREATE INDEX IF NOT EXISTS idx_chanmsg_inbox ON channel_messages(project_id, direction, acted, created_at);
184
+ -- ── P7 one-way Linear mirror: hub → Linear projection map (the hub is the SoR) ─
185
+ -- §16: holds Linear ids + a content hash, NEVER a token/secret. linear_id NULL = a create is
186
+ -- pending (the row is written BEFORE the remote create, so a crash never orphans/double-creates --
187
+ -- a NULL-id retry reconciles by the [hub:id] title marker). The hash is computed from HUB
188
+ -- fields only, so a human edit on the Linear side never changes it (one-way; hub state always wins).
189
+ CREATE TABLE IF NOT EXISTS mirror_map (
190
+ id TEXT PRIMARY KEY,
191
+ project_id TEXT NOT NULL REFERENCES projects(id),
192
+ hub_kind TEXT NOT NULL DEFAULT 'ticket' CHECK(hub_kind IN ('ticket')), -- tickets only for P7; docs/topics deferred
193
+ hub_id TEXT NOT NULL,
194
+ linear_id TEXT, -- the mirrored Linear issue id; NULL = create pending (crash-safe)
195
+ last_pushed_hash TEXT, -- sha256 of the HUB-derived mirror content; an unchanged ticket is SKIPPED (incremental)
196
+ last_pushed_at TEXT,
197
+ created_at TEXT NOT NULL,
198
+ UNIQUE(project_id, hub_kind, hub_id)
199
+ );
200
+ CREATE INDEX IF NOT EXISTS idx_mirror_project ON mirror_map(project_id, hub_kind);
201
+ `;
202
+ // ─── Schema migrations (PRAGMA user_version) ─────────────────────────────────
203
+ // SQLite cannot ALTER a CHECK constraint, so widening tickets.state means rebuilding the table
204
+ // (the documented table-redefinition procedure). Keyed by user_version so every opener applies
205
+ // pending migrations exactly once, idempotently; the version is re-checked UNDER the write lock so
206
+ // concurrent openers (server + daemon, two connections) can't double-migrate. foreign_keys must be
207
+ // toggled OUTSIDE the txn (the PRAGMA is a no-op inside one). Runs BEFORE any caller sets
208
+ // query_only (daemon sets it after openDb returns), so the read connection still migrates cleanly.
209
+ const tableExists = (db, name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
210
+ const columnExists = (db, table, col) => db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === col);
211
+ // pre-v3 marker: the stored documents CREATE SQL carries a table-level `UNIQUE(project_id, kind)`
212
+ // (the v3 rebuild replaces it with the partial index uq_documents_singleton_kind). The regex tolerates
213
+ // arbitrary inner whitespace. Used as the v3 rebuild guard (a SCHEMA-re-exec'd partial index would
214
+ // otherwise mask whether the table itself was migrated).
215
+ const documentsHasTableLevelKindUnique = (db) => {
216
+ const r = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='documents'").get();
217
+ return !!r && /UNIQUE\s*\(\s*project_id\s*,\s*kind\s*\)/i.test(r.sql);
218
+ };
219
+ const userVersion = (db) => db.prepare("PRAGMA user_version").get().user_version;
220
+ const SCHEMA_VERSION = 3; // bump when adding a migration below (DL-25 → v1; DL-52 channels.transport → v2; DL split documents.kind+='design' → v3)
221
+ function migrate(db) {
222
+ if (userVersion(db) >= SCHEMA_VERSION)
223
+ return; // fast path: already current, no txn
224
+ db.exec("PRAGMA foreign_keys=OFF");
225
+ db.exec("BEGIN IMMEDIATE");
226
+ try {
227
+ if (userVersion(db) < 1) {
228
+ // v1 (DL-25): widen tickets.state CHECK to include 'Human-Blocked'. Lossless rebuild:
229
+ // explicit column copy (never SELECT *), FK off so comments(ticket_id)/mirror_map children
230
+ // survive the DROP+RENAME, PK + index recreated. CHECK comes from STATE_CHECK (no drift).
231
+ db.exec(`
232
+ CREATE TABLE tickets_new (
233
+ id TEXT PRIMARY KEY,
234
+ project_id TEXT NOT NULL REFERENCES projects(id),
235
+ title TEXT NOT NULL,
236
+ description TEXT NOT NULL DEFAULT '',
237
+ type TEXT NOT NULL DEFAULT 'Feature',
238
+ state TEXT NOT NULL DEFAULT 'Todo' CHECK(state IN (${STATE_CHECK})),
239
+ assignee TEXT,
240
+ priority INTEGER NOT NULL DEFAULT 0,
241
+ labels TEXT NOT NULL DEFAULT '[]',
242
+ duplicate_of TEXT,
243
+ related_to TEXT NOT NULL DEFAULT '[]',
244
+ created_by TEXT NOT NULL,
245
+ created_at TEXT NOT NULL,
246
+ updated_at TEXT NOT NULL
247
+ );
248
+ INSERT INTO tickets_new (id,project_id,title,description,type,state,assignee,priority,labels,duplicate_of,related_to,created_by,created_at,updated_at)
249
+ SELECT id,project_id,title,description,type,state,assignee,priority,labels,duplicate_of,related_to,created_by,created_at,updated_at FROM tickets;
250
+ DROP TABLE tickets;
251
+ ALTER TABLE tickets_new RENAME TO tickets;
252
+ CREATE INDEX IF NOT EXISTS idx_tickets_project_state ON tickets(project_id, state);
253
+ `);
254
+ db.exec("PRAGMA user_version=1");
255
+ }
256
+ if (userVersion(db) < 2) {
257
+ // v2 (DL-52): add channels.transport ('bot'|'webhook', default 'bot'). Additive ALTER — a new column
258
+ // with a default backfills existing rows to 'bot', so every existing channel is byte-for-byte
259
+ // unchanged (unlike v1's CHECK-widen, an ADD COLUMN needs no table rebuild). Guarded on column
260
+ // presence: a brand-new / channel-less DB had its channels table created WITH transport by the SCHEMA
261
+ // re-exec above ⇒ skip the ALTER (no "duplicate column" error); a real pre-v2 DB has channels WITHOUT
262
+ // it ⇒ the ALTER adds + backfills the column.
263
+ if (tableExists(db, "channels") && !columnExists(db, "channels", "transport"))
264
+ db.exec(`ALTER TABLE channels ADD COLUMN transport TEXT NOT NULL DEFAULT 'bot' CHECK(transport IN (${TRANSPORT_CHECK}))`);
265
+ db.exec("PRAGMA user_version=2");
266
+ }
267
+ if (userVersion(db) < 3) {
268
+ // v3 (DL split): widen documents.kind CHECK to admit 'design' AND relax per-kind uniqueness so
269
+ // 'design' is multi-instance (one doc per module slug). SQLite cannot ALTER a CHECK nor drop a
270
+ // table-level UNIQUE, so this is the DL-25 lossless rebuild shape: explicit column copy (never
271
+ // SELECT *), FK off so document_versions(doc_id) children survive the DROP+RENAME, index recreated.
272
+ // CHECK comes from DOC_KIND_CHECK (no drift). The new table keeps UNIQUE(project_id,slug) but DROPS
273
+ // the table-level UNIQUE(project_id,kind), replacing it with a PARTIAL unique index that excludes
274
+ // 'design' — so strategy/roadmap/decisions/notes stay singletons while design rows coexist by slug.
275
+ // Guard on the OLD TABLE SHAPE, NOT the partial index: openDb's SCHEMA re-exec runs BEFORE migrate()
276
+ // and its standalone `CREATE UNIQUE INDEX IF NOT EXISTS uq_documents_singleton_kind` already added that
277
+ // index onto the pre-v3 documents table — so an index-presence guard would wrongly skip the rebuild.
278
+ // The reliable pre-v3 marker is the table-level `UNIQUE(project_id, kind)` in the stored CREATE SQL
279
+ // (replaced by the partial index post-rebuild). Absent it (fresh DB or already-rebuilt) ⇒ skip.
280
+ if (tableExists(db, "documents") && documentsHasTableLevelKindUnique(db)) {
281
+ db.exec(`
282
+ CREATE TABLE documents_new (
283
+ id TEXT PRIMARY KEY,
284
+ project_id TEXT NOT NULL REFERENCES projects(id),
285
+ kind TEXT NOT NULL CHECK(kind IN (${DOC_KIND_CHECK})),
286
+ slug TEXT NOT NULL,
287
+ title TEXT NOT NULL,
288
+ status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','current')),
289
+ current_version INTEGER NOT NULL DEFAULT 0,
290
+ created_by TEXT NOT NULL,
291
+ created_at TEXT NOT NULL,
292
+ updated_at TEXT NOT NULL,
293
+ UNIQUE(project_id, slug)
294
+ );
295
+ INSERT INTO documents_new (id,project_id,kind,slug,title,status,current_version,created_by,created_at,updated_at)
296
+ SELECT id,project_id,kind,slug,title,status,current_version,created_by,created_at,updated_at FROM documents;
297
+ DROP TABLE documents;
298
+ ALTER TABLE documents_new RENAME TO documents;
299
+ CREATE INDEX IF NOT EXISTS idx_documents_project ON documents(project_id, kind);
300
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_documents_singleton_kind ON documents(project_id, kind) WHERE kind != 'design';
301
+ `);
302
+ }
303
+ db.exec("PRAGMA user_version=3");
304
+ }
305
+ db.exec("COMMIT");
306
+ }
307
+ catch (e) {
308
+ try {
309
+ db.exec("ROLLBACK");
310
+ }
311
+ catch { /* */ }
312
+ db.exec("PRAGMA foreign_keys=ON");
313
+ throw e;
314
+ }
315
+ db.exec("PRAGMA foreign_keys=ON");
316
+ }
317
+ // ─── Open ──────────────────────────────────────────────────────────────────
318
+ export function openDb(path) {
319
+ mkdirSync(dirname(path), { recursive: true });
320
+ const db = new DatabaseSync(path);
321
+ db.exec("PRAGMA journal_mode=WAL");
322
+ db.exec("PRAGMA foreign_keys=ON");
323
+ db.exec("PRAGMA busy_timeout=5000"); // wait out a concurrent writer instead of erroring
324
+ const fresh = !tableExists(db, "tickets");
325
+ db.exec(SCHEMA);
326
+ if (fresh)
327
+ db.exec(`PRAGMA user_version=${SCHEMA_VERSION}`); // brand-new DB already has the current schema — no migration
328
+ else
329
+ migrate(db); // existing DB — apply pending migrations (idempotent)
330
+ return db;
331
+ }
332
+ export function nowIso() {
333
+ // Date.now()/new Date() are fine in the hub process (it is NOT a workflow script).
334
+ return new Date().toISOString();
335
+ }
336
+ // Allocate the next ticket id atomically inside a txn (race-free, unlike the §18 O_EXCL file counter).
337
+ export function nextTicketId(db, projectId) {
338
+ const row = db
339
+ .prepare("UPDATE projects SET ticket_seq = ticket_seq + 1 WHERE id = ? RETURNING ticket_seq, ticket_prefix")
340
+ .get(projectId);
341
+ if (!row)
342
+ throw new Error(`unknown project ${projectId}`);
343
+ return `${row.ticket_prefix}-${row.ticket_seq}`;
344
+ }
345
+ // ─── Line diff (P4 doc.diff — LCS-based unified-ish diff; pure JS, zero dep) ──
346
+ export function unifiedDiff(a, b) {
347
+ const al = a.split("\n"), bl = b.split("\n");
348
+ const m = al.length, n = bl.length;
349
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
350
+ for (let i = m - 1; i >= 0; i--)
351
+ for (let j = n - 1; j >= 0; j--)
352
+ dp[i][j] = al[i] === bl[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
353
+ const out = [];
354
+ let i = 0, j = 0;
355
+ while (i < m && j < n) {
356
+ if (al[i] === bl[j]) {
357
+ out.push(" " + al[i]);
358
+ i++;
359
+ j++;
360
+ }
361
+ else if (dp[i + 1][j] >= dp[i][j + 1]) {
362
+ out.push("- " + al[i++]);
363
+ }
364
+ else {
365
+ out.push("+ " + bl[j++]);
366
+ }
367
+ }
368
+ while (i < m)
369
+ out.push("- " + al[i++]);
370
+ while (j < n)
371
+ out.push("+ " + bl[j++]);
372
+ return out.join("\n");
373
+ }
374
+ // ─── Identity guards (P3 — kill the phantom-actor silent-corruption bug) ─────
375
+ export function actorExists(db, handle) {
376
+ return db.prepare("SELECT 1 FROM actors WHERE handle = ? AND active = 1").get(handle) !== undefined;
377
+ }
378
+ export function listActorHandles(db) {
379
+ return db.prepare("SELECT handle FROM actors WHERE active = 1 ORDER BY handle").all()
380
+ .map((r) => r.handle);
381
+ }
382
+ export function logEvent(db, e) {
383
+ db.prepare("INSERT INTO events(project_id,ticket_id,actor,kind,data,created_at) VALUES (?,?,?,?,?,?)")
384
+ .run(e.project_id, e.ticket_id ?? null, e.actor, e.kind, JSON.stringify(e.data ?? {}), nowIso());
385
+ }
@@ -0,0 +1,110 @@
1
+ // Shared document store — the CAS + operator-publish invariants for hub product-docs, used by BOTH
2
+ // the MCP server (server.ts) and the read+write daemon (daemon.ts, DL-3). It is SIDE-EFFECT-FREE
3
+ // (no env read, no transport, no top-level db) so either entrypoint can import it; identity (actor)
4
+ // and scope (projectId) are passed in by the caller.
5
+ //
6
+ // §17 firewall (structural): every write in this module is an INSERT/UPDATE on the `documents` /
7
+ // `document_versions` tables keyed by a `kind` ∈ DOC_KINDS — there is NO filesystem path anywhere in
8
+ // here, so a doc write can never target a SKILL / conventions / code file. The operator-publish gate
9
+ // lives here ONCE (docPublish), so the MCP server and the daemon can never drift on who may publish.
10
+ import { randomUUID } from "node:crypto";
11
+ import { nowIso, logEvent } from "./db.js";
12
+ // DL split: `design` is the senior-dev's living per-module design tier (one doc per module slug).
13
+ // Two departures from the singleton kinds (handled in db.ts v3 + the read path): (1) MULTI-INSTANCE —
14
+ // the UNIQUE(project_id, kind) constraint is relaxed for `design` so many design rows coexist by slug;
15
+ // (2) NOT operator-publish-gated — a design draft IS the live design, so design-reads return the LATEST
16
+ // version (no `current` publish). docPublish's operator gate for strategy/roadmap is unchanged.
17
+ export const DOC_KINDS = ["strategy", "roadmap", "decisions", "notes", "design"];
18
+ // Map a docstore error message (the store returns prose, not codes) to the right HTTP status, so EVERY
19
+ // caller that surfaces a DocResult over HTTP — the DL-3 roadmap write routes AND the DL-43/DL-62 agent
20
+ // op-API — maps it IDENTICALLY from this one place (no drift): the operator gate → 403, a missing
21
+ // doc/version → 404, the create-precondition → 400, else a genuine CAS / kind-immutability conflict → 409.
22
+ export const statusForDocErr = (msg) => msg.startsWith("FORBIDDEN") ? 403
23
+ : /^no (document|version)\b/.test(msg) ? 404
24
+ : msg.includes("baseVersion must be 0") ? 400
25
+ : 409;
26
+ export const resolveDoc = (db, projectId, slug, kind) => slug ? db.prepare("SELECT * FROM documents WHERE project_id=? AND slug=?").get(projectId, slug)
27
+ : kind ? db.prepare("SELECT * FROM documents WHERE project_id=? AND kind=?").get(projectId, kind)
28
+ : undefined;
29
+ export const latestVersion = (db, docId) => db.prepare("SELECT max(version) v FROM document_versions WHERE doc_id=?").get(docId).v ?? 0;
30
+ // Create (baseVersion 0) or append a new DRAFT version. Optimistic CAS: baseVersion MUST equal the
31
+ // doc's latest version, else CONFLICT (never last-write-wins). NEVER publishes. The DL-9 kind-
32
+ // immutability guard and DL-6 actor semantics are preserved verbatim from the original MCP handler.
33
+ export function docSave(db, projectId, actor, a) {
34
+ const t = nowIso();
35
+ db.exec("BEGIN IMMEDIATE"); // RESERVED lock before the read → cross-process CAS is atomic (§7)
36
+ try {
37
+ const d = db.prepare("SELECT * FROM documents WHERE project_id=? AND slug=?").get(projectId, a.slug);
38
+ if (!d) {
39
+ if (a.baseVersion !== 0) {
40
+ db.exec("ROLLBACK");
41
+ return { ok: false, error: `baseVersion must be 0 to create a new doc '${a.slug}'` };
42
+ }
43
+ const id = randomUUID();
44
+ db.prepare("INSERT INTO documents(id,project_id,kind,slug,title,status,current_version,created_by,created_at,updated_at) VALUES (?,?,?,?,?,'draft',0,?,?,?)").run(id, projectId, a.kind, a.slug, a.title ?? a.slug, actor, t, t);
45
+ db.prepare("INSERT INTO document_versions(id,doc_id,version,body,status,summary,base_version,author,created_at) VALUES (?,?,1,?,'draft',?,0,?,?)").run(randomUUID(), id, a.body, a.summary ?? "", actor, t);
46
+ logEvent(db, { project_id: projectId, actor, kind: "doc.save", data: { slug: a.slug, version: 1, base: 0 } });
47
+ db.exec("COMMIT");
48
+ return { ok: true, data: { doc: a.slug, kind: a.kind, version: 1, status: "draft" } };
49
+ }
50
+ // A document's kind is immutable identity: a save whose kind contradicts the stored doc at this
51
+ // slug is targeting the WRONG document, so refuse it (DL-9) instead of silently appending into /
52
+ // clobbering the existing doc. Checked BEFORE the CAS — a baseVersion comparison against the
53
+ // wrong doc is meaningless. (Keeps slug effectively unique per project: two kinds can never
54
+ // share a slug, so resolveDoc-by-slug stays correct.)
55
+ if (a.kind !== d.kind) {
56
+ db.exec("ROLLBACK");
57
+ return { ok: false, error: `CONFLICT: slug '${a.slug}' is a '${d.kind}' document — refusing a '${a.kind}' save (a document's kind is immutable; use a distinct slug)` };
58
+ }
59
+ const latest = latestVersion(db, d.id);
60
+ if (a.baseVersion !== latest) {
61
+ db.exec("ROLLBACK");
62
+ return { ok: false, error: `CONFLICT: '${a.slug}' is at version ${latest}, your baseVersion ${a.baseVersion} is stale — re-read (doc.get) and re-apply` };
63
+ }
64
+ const nv = latest + 1;
65
+ db.prepare("INSERT INTO document_versions(id,doc_id,version,body,status,summary,base_version,author,created_at) VALUES (?,?,?,?,'draft',?,?,?,?)").run(randomUUID(), d.id, nv, a.body, a.summary ?? "", a.baseVersion, actor, t);
66
+ db.prepare("UPDATE documents SET title=?, updated_at=? WHERE id=?").run(a.title ?? d.title, t, d.id);
67
+ logEvent(db, { project_id: projectId, actor, kind: "doc.save", data: { slug: a.slug, version: nv, base: a.baseVersion } });
68
+ db.exec("COMMIT");
69
+ return { ok: true, data: { doc: a.slug, kind: d.kind, version: nv, status: "draft" } };
70
+ }
71
+ catch (e) {
72
+ try {
73
+ db.exec("ROLLBACK");
74
+ }
75
+ catch { /* */ }
76
+ throw e;
77
+ }
78
+ }
79
+ // OPERATOR-ONLY: publish a draft version → current (the live doc). Cooperative role-gate
80
+ // (actor === "operator"), not anti-spoof — see §18 / HUB-ARCHITECTURE §16. This single gate is the
81
+ // human-authorization point of the §17 firewall, so it lives in exactly one place.
82
+ export function docPublish(db, projectId, actor, a) {
83
+ if (actor !== "operator")
84
+ return { ok: false, error: "FORBIDDEN: only the operator may publish a doc draft→current" };
85
+ const d = resolveDoc(db, projectId, a.slug, a.kind);
86
+ if (!d)
87
+ return { ok: false, error: `no document ${a.slug ?? a.kind}` };
88
+ const v = db.prepare("SELECT version FROM document_versions WHERE doc_id=? AND version=?").get(d.id, a.version);
89
+ if (!v)
90
+ return { ok: false, error: `no version ${a.version} of ${d.slug} to publish` };
91
+ const t = nowIso();
92
+ // single-current invariant (Codex review): publishing vN after vM must leave EXACTLY one version
93
+ // row marked 'current' — reset all to draft, then mark the chosen one, atomically.
94
+ db.exec("BEGIN IMMEDIATE");
95
+ try {
96
+ db.prepare("UPDATE document_versions SET status='draft' WHERE doc_id=? AND status='current'").run(d.id);
97
+ db.prepare("UPDATE document_versions SET status='current' WHERE doc_id=? AND version=?").run(d.id, a.version);
98
+ db.prepare("UPDATE documents SET status='current', current_version=?, updated_at=? WHERE id=?").run(a.version, t, d.id);
99
+ logEvent(db, { project_id: projectId, actor, kind: "doc.publish", data: { slug: d.slug, version: a.version } });
100
+ db.exec("COMMIT");
101
+ }
102
+ catch (e) {
103
+ try {
104
+ db.exec("ROLLBACK");
105
+ }
106
+ catch { /* */ }
107
+ throw e;
108
+ }
109
+ return { ok: true, data: { doc: d.slug, status: "current", current_version: a.version } };
110
+ }