@hegemonart/get-design-done 1.56.0 → 1.57.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.
@@ -13,10 +13,31 @@
13
13
  // (TransitionGateFailed, LockAcquisitionError, ParseError) to the
14
14
  // unified `gdd-errors` taxonomy — `types.ts` re-exports them verbatim
15
15
  // so consumers of `gdd-state` need no changes.
16
+ //
17
+ // Phase 57 (Plan 57-06 Round 2-D): dual-write to SQLite when migration
18
+ // is active for this state file. The public API signatures are FROZEN
19
+ // (SC#5) — only the persistence path branches internally.
20
+ //
21
+ // Migration-active gate:
22
+ // migrationActive(statePath) === true
23
+ // iff BACKEND==='sqlite' AND existsSync(<dir(statePath)>/state.sqlite)
24
+ //
25
+ // When false (the universal default for un-migrated projects and all
26
+ // existing Phase-20 tests that use temp STATE.md paths), every function
27
+ // behaves EXACTLY as before Phase 57: pure markdown path, byte-identical,
28
+ // same lockfile, same events.
16
29
 
17
- import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync } from 'node:fs';
30
+ import {
31
+ readFileSync,
32
+ writeFileSync,
33
+ renameSync,
34
+ unlinkSync,
35
+ existsSync,
36
+ } from 'node:fs';
37
+ import { dirname, join, resolve } from 'node:path';
38
+ import { pathToFileURL } from 'node:url';
18
39
 
19
- import { acquire } from './lockfile.ts';
40
+ import { acquire, acquireSqliteLock } from './lockfile.ts';
20
41
  import { parse } from './parser.ts';
21
42
  import { serialize } from './mutator.ts';
22
43
  import { gateFor } from './gates.ts';
@@ -31,15 +52,164 @@ import {
31
52
  export type { ParsedState, Stage } from './types.ts';
32
53
  export { TransitionGateFailed, LockAcquisitionError, ParseError } from './types.ts';
33
54
 
55
+ // ---------------------------------------------------------------------------
56
+ // Phase 57: package-root resolver (walk-up from sdk/state/index.ts location).
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Walk up from `startDir` to find the package root (directory with
61
+ * package.json named '@hegemonart/get-design-done').
62
+ * Falls back to the first directory that has any package.json, then null.
63
+ */
64
+ function _findPackageRoot(startDir: string): string | null {
65
+ let dir = resolve(startDir);
66
+ let firstWithPkg: string | null = null;
67
+ for (let i = 0; i < 12; i++) {
68
+ const pkgPath = join(dir, 'package.json');
69
+ if (existsSync(pkgPath)) {
70
+ try {
71
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
72
+ const pkg = require(pkgPath) as { name?: string };
73
+ if (firstWithPkg === null) firstWithPkg = dir;
74
+ if (pkg.name === '@hegemonart/get-design-done') return dir;
75
+ } catch {
76
+ if (firstWithPkg === null) firstWithPkg = dir;
77
+ }
78
+ }
79
+ const parent = dirname(dir);
80
+ if (parent === dir) break;
81
+ dir = parent;
82
+ }
83
+ return firstWithPkg;
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Phase 57: backend probe (loaded once via require, memoized).
88
+ // state-backend.cjs is a CommonJS module; require() works from .ts under
89
+ // Node 22 --experimental-strip-types (only type-erasable syntax is used).
90
+ // ---------------------------------------------------------------------------
91
+
92
+ interface StateBackendMod {
93
+ Database: ((...args: unknown[]) => unknown) | null;
94
+ BACKEND: 'sqlite' | 'markdown';
95
+ sqlitePath: (projectRoot: string) => string;
96
+ openStateDb: (p: string, opts?: { readonly?: boolean }) => StateDb;
97
+ }
98
+
99
+ interface StateDb {
100
+ close(): void;
101
+ prepare(sql: string): { get(...args: unknown[]): unknown; all(...args: unknown[]): unknown[]; run(...args: unknown[]): unknown };
102
+ transaction(fn: () => void): () => void;
103
+ pragma(s: string): unknown;
104
+ exec(s: string): void;
105
+ }
106
+
107
+ /** null = not yet loaded, false = failed to load, StateBackendMod = loaded */
108
+ let _backendCache: StateBackendMod | null | false = null;
109
+
110
+ function _loadBackend(): StateBackendMod | null {
111
+ if (_backendCache !== null) return _backendCache === false ? null : _backendCache as StateBackendMod;
112
+ try {
113
+ const pkgRoot = _findPackageRoot(__dirname);
114
+ if (pkgRoot === null) { _backendCache = false; return null; }
115
+ const backendPath = join(pkgRoot, 'scripts', 'lib', 'state', 'state-backend.cjs');
116
+ if (!existsSync(backendPath)) { _backendCache = false; return null; }
117
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
118
+ _backendCache = require(backendPath) as StateBackendMod;
119
+ return _backendCache as StateBackendMod;
120
+ } catch {
121
+ _backendCache = false;
122
+ return null;
123
+ }
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Phase 57: state-store loader (async, cached after first successful load).
128
+ // state-store.cjs itself does dynamic imports of .ts SDK files, so we load
129
+ // it via dynamic import (not require) to stay in async context.
130
+ // ---------------------------------------------------------------------------
131
+
132
+ interface StateStoreMod {
133
+ appendDecision: (...args: unknown[]) => Promise<unknown>;
134
+ getDecisions: (...args: unknown[]) => unknown[];
135
+ setPosition: (...args: unknown[]) => Promise<unknown>;
136
+ getPosition: (...args: unknown[]) => unknown;
137
+ backendName: () => string;
138
+ [key: string]: unknown;
139
+ }
140
+
141
+ let _storeCache: StateStoreMod | null | false = null;
142
+
143
+ async function _loadStore(): Promise<StateStoreMod | null> {
144
+ if (_storeCache !== null) return _storeCache === false ? null : _storeCache as StateStoreMod;
145
+ try {
146
+ const pkgRoot = _findPackageRoot(__dirname);
147
+ if (pkgRoot === null) { _storeCache = false; return null; }
148
+ const storePath = join(pkgRoot, 'scripts', 'lib', 'state', 'state-store.cjs');
149
+ if (!existsSync(storePath)) { _storeCache = false; return null; }
150
+ const mod = await import(pathToFileURL(storePath).href);
151
+ _storeCache = mod as StateStoreMod;
152
+ return _storeCache as StateStoreMod;
153
+ } catch {
154
+ _storeCache = false;
155
+ return null;
156
+ }
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Phase 57: migration-active gate.
161
+ //
162
+ // migrationActive(statePath) === true iff:
163
+ // 1. BACKEND === 'sqlite' (better-sqlite3 + FTS5 available, env not forcing markdown)
164
+ // 2. existsSync(join(dirname(statePath), 'state.sqlite'))
165
+ //
166
+ // The sibling check uses dirname(statePath) directly so every temp-dir test
167
+ // that creates a STATE.md WITHOUT a sibling state.sqlite is correctly NOT
168
+ // considered migrated — this is the universal default.
169
+ //
170
+ // This gate is what keeps all existing Phase-20 tests green with better-sqlite3
171
+ // present: they use temp paths with no state.sqlite sibling, so migrationActive
172
+ // returns false and the pure-markdown path runs byte-identically.
173
+ // ---------------------------------------------------------------------------
174
+
175
+ function migrationActive(statePath: string): boolean {
176
+ const backend = _loadBackend();
177
+ if (backend === null || backend.BACKEND !== 'sqlite') return false;
178
+ const sqliteSibling = join(dirname(statePath), 'state.sqlite');
179
+ return existsSync(sqliteSibling);
180
+ }
181
+
182
+ /**
183
+ * Return the path to the sibling state.sqlite for a given STATE.md path.
184
+ * This is always `<dirname(statePath)>/state.sqlite`.
185
+ * Exported for use by tests and lockfile helpers.
186
+ */
187
+ export function sqlitePathFor(statePath: string): string {
188
+ return join(dirname(statePath), 'state.sqlite');
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Public API
193
+ // ---------------------------------------------------------------------------
194
+
34
195
  /**
35
196
  * Read STATE.md from disk and return the parsed state.
36
197
  *
37
- * Shared-read: no lock is taken. Reads are snapshot-safe for markdown
38
- * (the OS guarantees a coherent view even if a writer is mid-rename —
39
- * we either see the old file or the new file, never a torn write,
40
- * because `mutate()` uses atomic rename).
198
+ * Phase 57 dual-write: when migration is active, reads the on-disk STATE.md
199
+ * (which the dual-write path keeps byte-equal with SQLite state), then parses
200
+ * it. This matches the pre-Phase-57 behavior of this function and keeps the
201
+ * returned ParsedState shape identical whether reading via SQLite or markdown.
202
+ *
203
+ * Shared-read: no lock is taken. Reads are snapshot-safe (atomic rename by
204
+ * `mutate()` means we either see the old file or the new file, never a torn
205
+ * write).
41
206
  */
42
207
  export async function read(path: string): Promise<ParsedState> {
208
+ // Phase 57: both paths parse the on-disk STATE.md (the dual-write path
209
+ // maintains STATE.md as a byte-equal render of SQLite state). No behavioral
210
+ // difference; the branch is here for future extension (e.g., reading directly
211
+ // from SQLite without touching disk). The returned ParsedState is identical
212
+ // whether migration is active or not.
43
213
  const raw: string = readFileSync(path, 'utf8');
44
214
  return parse(raw).state;
45
215
  }
@@ -47,13 +217,19 @@ export async function read(path: string): Promise<ParsedState> {
47
217
  /**
48
218
  * Atomic read-modify-write on STATE.md.
49
219
  *
50
- * Flow:
220
+ * Phase 57 dual-write: when migration is active for this statePath, acquires
221
+ * the SQLite sibling lock BEFORE the STATE.md lock (R9 deadlock-free
222
+ * ordering), then after applying fn() serializes to markdown and writes
223
+ * atomically (same .tmp+rename path). The on-disk STATE.md is always the
224
+ * byte-equal rendered form, so SQLite stays consistent via the R8 freshness
225
+ * guard on the next state-store operation.
226
+ *
227
+ * When migration is inactive, behavior is EXACTLY as pre-Phase-57:
51
228
  * 1. Acquire sibling `.lock` file (PID+timestamp advisory lock).
52
229
  * 2. Read current contents.
53
230
  * 3. Apply `fn`.
54
231
  * 4. Serialize to a `.tmp` file next to `path`.
55
- * 5. `renameSync(.tmp, path)` — POSIX-atomic; on Windows EPERM means
56
- * a scanner held it briefly, retry once.
232
+ * 5. `renameSync(.tmp, path)` — POSIX-atomic; Windows EPERM retry once.
57
233
  * 6. Release the lock (in `finally` — released even on mid-fn throw).
58
234
  *
59
235
  * Crash between write and rename is benign: STATE.md is untouched; the
@@ -62,6 +238,20 @@ export async function read(path: string): Promise<ParsedState> {
62
238
  export async function mutate(
63
239
  path: string,
64
240
  fn: (s: ParsedState) => ParsedState,
241
+ ): Promise<ParsedState> {
242
+ if (migrationActive(path)) {
243
+ return _mutateSqliteActive(path, fn);
244
+ }
245
+ return _mutateMarkdown(path, fn);
246
+ }
247
+
248
+ /**
249
+ * Pure markdown mutate path (pre-Phase-57 behavior, unchanged).
250
+ * Called when migrationActive() === false.
251
+ */
252
+ async function _mutateMarkdown(
253
+ path: string,
254
+ fn: (s: ParsedState) => ParsedState,
65
255
  ): Promise<ParsedState> {
66
256
  const release = await acquire(path);
67
257
  const tmpPath: string = `${path}.tmp`;
@@ -69,8 +259,6 @@ export async function mutate(
69
259
  const raw: string = readFileSync(path, 'utf8');
70
260
  const { state, raw_bodies, raw_frontmatter, block_gaps, line_ending } =
71
261
  parse(raw);
72
- // Deep-clone so the consumer's fn cannot mutate the state we just
73
- // parsed (defensive — apply() does this too for pure callers).
74
262
  const clone = structuredClone(state);
75
263
  const next = fn(clone);
76
264
  const out = serialize(next, {
@@ -97,15 +285,77 @@ export async function mutate(
97
285
  }
98
286
  return next;
99
287
  } catch (err) {
100
- // Clean up the orphaned tmp file on failure so we don't pollute.
101
288
  try {
102
289
  if (existsSync(tmpPath)) unlinkSync(tmpPath);
103
290
  } catch {
104
- // best-effort; a leftover tmp file does not corrupt STATE.md.
291
+ // best-effort cleanup.
292
+ }
293
+ throw err;
294
+ } finally {
295
+ await release();
296
+ }
297
+ }
298
+
299
+ /**
300
+ * SQLite-active mutate path (Phase 57, migrationActive() === true).
301
+ *
302
+ * Lock ordering per R9: acquire state.sqlite.lock BEFORE STATE.md.lock
303
+ * (deadlock-free). Both released in finally.
304
+ *
305
+ * Write strategy: serialize next state to markdown, write atomically
306
+ * (.tmp + rename). This keeps STATE.md byte-equal with the last parsed
307
+ * state. The R8 freshness guard in state-store detects the sha-change on
308
+ * the next state-store call and re-syncs SQLite from the markdown SoT.
309
+ * This avoids duplicating state-store's row-level write logic here while
310
+ * still keeping SQLite eventually consistent.
311
+ */
312
+ async function _mutateSqliteActive(
313
+ path: string,
314
+ fn: (s: ParsedState) => ParsedState,
315
+ ): Promise<ParsedState> {
316
+ const sqlitePath = join(dirname(path), 'state.sqlite');
317
+ // Acquire SQLite lock first (R9: state.sqlite.lock before STATE.md.lock).
318
+ const releaseSqliteLock = await acquireSqliteLock(sqlitePath);
319
+ const release = await acquire(path);
320
+ const tmpPath: string = `${path}.tmp`;
321
+ try {
322
+ const raw: string = readFileSync(path, 'utf8');
323
+ const { state, raw_bodies, raw_frontmatter, block_gaps, line_ending } =
324
+ parse(raw);
325
+ const clone = structuredClone(state);
326
+ const next = fn(clone);
327
+ const out = serialize(next, {
328
+ raw_frontmatter,
329
+ raw_bodies,
330
+ block_gaps,
331
+ line_ending,
332
+ });
333
+ writeFileSync(tmpPath, out, 'utf8');
334
+ try {
335
+ renameSync(tmpPath, path);
336
+ } catch (err) {
337
+ const code =
338
+ typeof err === 'object' && err !== null && 'code' in err
339
+ ? (err as { code?: unknown }).code
340
+ : undefined;
341
+ if (code === 'EPERM' || code === 'EBUSY') {
342
+ await new Promise((r) => setTimeout(r, 50));
343
+ renameSync(tmpPath, path);
344
+ } else {
345
+ throw err;
346
+ }
347
+ }
348
+ return next;
349
+ } catch (err) {
350
+ try {
351
+ if (existsSync(tmpPath)) unlinkSync(tmpPath);
352
+ } catch {
353
+ // best-effort cleanup.
105
354
  }
106
355
  throw err;
107
356
  } finally {
108
357
  await release();
358
+ await releaseSqliteLock();
109
359
  }
110
360
  }
111
361
 
@@ -125,6 +375,10 @@ export async function mutate(
125
375
  * - frontmatter.last_checkpoint = now (ISO)
126
376
  * - timestamps[`${toStage}_started_at`] = now (ISO)
127
377
  *
378
+ * Phase 57: the gate evaluation + stamping logic is unchanged. The
379
+ * persistence path branches via `mutate()` which applies the
380
+ * migration-active gate internally.
381
+ *
128
382
  * Returns the updated state plus the gate response (for callers that
129
383
  * want to log blockers — on pass, `blockers` is always `[]`).
130
384
  */
@@ -230,3 +230,51 @@ function getErrnoCode(err: unknown): string | undefined {
230
230
  function sleep(ms: number): Promise<void> {
231
231
  return new Promise<void>((resolve) => setTimeout(resolve, ms));
232
232
  }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Phase 57 (Plan 57-06 Round 2-D): SQLite sibling lock.
236
+ //
237
+ // R9 requirement: when both STATE.md.lock and state.sqlite.lock are needed,
238
+ // acquire state.sqlite.lock BEFORE STATE.md.lock (deadlock-free ordering).
239
+ //
240
+ // The SQLite lock file is `${sqlitePath}.lock` (i.e. state.sqlite.lock),
241
+ // using the SAME acquire algorithm and the SAME {pid,host,acquired_at}
242
+ // payload as the existing STATE.md.lock. This reuses the entire acquire()
243
+ // code path — callers just pass the SQLite path instead of the markdown path.
244
+ //
245
+ // Callers in sdk/state/index.ts follow the mandated ordering:
246
+ // 1. acquireSqliteLock(state.sqlite) → releaseSqlite
247
+ // 2. acquire(STATE.md) → releaseMarkdown
248
+ // 3. ... do work ...
249
+ // 4. releaseMarkdown()
250
+ // 5. releaseSqlite()
251
+ //
252
+ // This function is a thin alias for acquire() with documentation that
253
+ // enforces the ordering contract. It is exported separately so consumers
254
+ // can't accidentally use it without understanding the ordering requirement.
255
+ // ---------------------------------------------------------------------------
256
+
257
+ /**
258
+ * Acquire the advisory lock for `state.sqlite` at `${sqlitePath}.lock`.
259
+ *
260
+ * Uses the SAME acquire algorithm and `{pid, host, acquired_at}` payload
261
+ * as the existing `acquire()` for STATE.md.lock.
262
+ *
263
+ * ORDERING RULE (R9, deadlock-free): when both locks are needed, ALWAYS
264
+ * acquire `state.sqlite.lock` BEFORE `STATE.md.lock`. Never acquire them
265
+ * in the opposite order.
266
+ *
267
+ * @param sqlitePath absolute path to `state.sqlite` (NOT the `.lock` file —
268
+ * the lock file is derived as `${sqlitePath}.lock`).
269
+ * @param opts same AcquireOptions as `acquire()`.
270
+ * @throws LockAcquisitionError when `maxWaitMs` elapses without acquiring.
271
+ */
272
+ export async function acquireSqliteLock(
273
+ sqlitePath: string,
274
+ opts: AcquireOptions = {},
275
+ ): Promise<LockRelease> {
276
+ // Delegate entirely to the existing acquire() implementation. The lock
277
+ // file ends up at `${sqlitePath}.lock` (e.g. `.design/state.sqlite.lock`),
278
+ // exactly matching R9's specification.
279
+ return acquire(sqlitePath, opts);
280
+ }
@@ -0,0 +1,218 @@
1
+ -- sdk/state/schema.sql - Phase 57 SQLite State Backbone.
2
+ -- PINNED DDL: all executors align to this exact schema.
3
+ -- Created by Executor A; consumed by state-backend, migrate-to-sqlite, render-markdown,
4
+ -- and any downstream reader that opens state.sqlite.
5
+ --
6
+ -- All tables use CREATE TABLE IF NOT EXISTS so this file is safely re-executed
7
+ -- (openStateDb calls loadSchema on every open).
8
+ --
9
+ -- FTS5 virtual tables are in a separate section at the bottom.
10
+ -- state-backend.cjs executes that section ONLY when _sqliteOk (better-sqlite3 + fts5
11
+ -- probe passed). A no-fts5 build creates all base tables without issue.
12
+ --
13
+ -- Column sources annotated per CONTEXT.md "Shared contracts".
14
+
15
+ -- ---------------------------------------------------------------------------
16
+ -- Base tables
17
+ -- ---------------------------------------------------------------------------
18
+
19
+ -- state_position: mirrors the <position> block + frontmatter fields of STATE.md.
20
+ -- One row per cycle_id; the active cycle is the most recent updated_at.
21
+ CREATE TABLE IF NOT EXISTS state_position (
22
+ cycle_id TEXT PRIMARY KEY, -- cycle: field from frontmatter
23
+ stage TEXT, -- stage: (scan/explore/decide/build/verify/operate)
24
+ wave INTEGER, -- wave: (integer progress within stage)
25
+ task_progress TEXT, -- e.g. "3/7" from <position> block
26
+ status TEXT, -- status: field from <position>
27
+ branch TEXT, -- branch: field from <position>
28
+ raw_frontmatter TEXT, -- verbatim frontmatter text (for byte-equal round-trip)
29
+ body_preamble TEXT, -- text between frontmatter and first block (for round-trip)
30
+ body_trailer TEXT, -- text after last block (for round-trip)
31
+ line_ending TEXT DEFAULT '\n', -- '\n' or '\r\n' (for round-trip)
32
+ last_render_sha256 TEXT, -- sha256 of the last rendered STATE.md (R8 freshness guard)
33
+ updated_at TEXT -- ISO 8601 timestamp of last SQLite write
34
+ );
35
+
36
+ -- decisions: mirrors <decisions> block lines.
37
+ -- Composite PRIMARY KEY (cycle_id, id) so D-NN identifiers recur across cycles (R11).
38
+ CREATE TABLE IF NOT EXISTS decisions (
39
+ id TEXT NOT NULL, -- e.g. D-01, D-02 (stable within cycle)
40
+ cycle_id TEXT NOT NULL, -- FK -> state_position.cycle_id
41
+ phase_id TEXT, -- phase tag from the decision line (if any)
42
+ status TEXT CHECK(status IN ('locked', 'tentative')),
43
+ body_md TEXT, -- the decision text (markdown)
44
+ tags TEXT, -- JSON array of tag strings
45
+ ordinal INTEGER NOT NULL, -- explicit emit order (preserved on conflict per R11)
46
+ raw_line TEXT, -- verbatim source line (prevents reformat drift, R6)
47
+ created_at TEXT, -- ISO 8601 (preserved on conflict per R11)
48
+ last_referenced_at TEXT, -- ISO 8601 last time this decision was referenced
49
+ PRIMARY KEY (cycle_id, id),
50
+ FOREIGN KEY (cycle_id) REFERENCES state_position(cycle_id)
51
+ );
52
+ CREATE INDEX IF NOT EXISTS idx_decisions_cycle ON decisions(cycle_id);
53
+
54
+ -- blockers: mirrors <blockers> block lines.
55
+ -- AUTOINCREMENT id; stable composite is [stage][date] for UPSERT (R11).
56
+ CREATE TABLE IF NOT EXISTS blockers (
57
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
58
+ cycle_id TEXT, -- FK -> state_position.cycle_id
59
+ stage TEXT, -- stage label from "[stage][date]: text"
60
+ date TEXT, -- date string from blocker line (YYYY-MM-DD or ISO)
61
+ severity TEXT, -- optional severity tag
62
+ body_md TEXT, -- blocker text
63
+ ordinal INTEGER NOT NULL, -- emit order
64
+ raw_line TEXT, -- verbatim source line (R6 - parser THROWS on malformed)
65
+ resolved_at TEXT, -- ISO 8601 when resolved (NULL = unresolved)
66
+ FOREIGN KEY (cycle_id) REFERENCES state_position(cycle_id)
67
+ );
68
+ CREATE INDEX IF NOT EXISTS idx_blockers_cycle ON blockers(cycle_id);
69
+
70
+ -- must_haves: mirrors <must_haves> block checklist items.
71
+ -- Composite PRIMARY KEY (cycle_id, id) so M-NN identifiers recur across cycles (R11).
72
+ CREATE TABLE IF NOT EXISTS must_haves (
73
+ id TEXT NOT NULL, -- e.g. M-01, M-02 (stable within cycle)
74
+ cycle_id TEXT NOT NULL, -- FK -> state_position.cycle_id
75
+ body_md TEXT, -- item text
76
+ status TEXT CHECK(status IN ('pending', 'pass', 'fail')),
77
+ ordinal INTEGER NOT NULL, -- emit order
78
+ raw_line TEXT, -- verbatim source line
79
+ PRIMARY KEY (cycle_id, id),
80
+ FOREIGN KEY (cycle_id) REFERENCES state_position(cycle_id)
81
+ );
82
+ CREATE INDEX IF NOT EXISTS idx_must_haves_cycle ON must_haves(cycle_id);
83
+
84
+ -- plans: mirrors <plans> block entries (one row per plan file reference).
85
+ CREATE TABLE IF NOT EXISTS plans (
86
+ id TEXT PRIMARY KEY, -- plan filename as stable key
87
+ phase_id TEXT, -- phase identifier
88
+ status TEXT, -- pending/active/complete
89
+ body_md TEXT, -- plan description text
90
+ ordinal INTEGER, -- emit order
91
+ completed_at TEXT -- ISO 8601 (NULL = not yet complete)
92
+ );
93
+ CREATE INDEX IF NOT EXISTS idx_plans_phase ON plans(phase_id);
94
+
95
+ -- findings: Phase 22 / Phase 52 research findings stored across cycles.
96
+ CREATE TABLE IF NOT EXISTS findings (
97
+ id TEXT PRIMARY KEY, -- stable finding id
98
+ source_agent TEXT, -- agent that produced the finding
99
+ cycle_id TEXT, -- FK -> state_position.cycle_id
100
+ phase_id TEXT, -- phase the finding came from
101
+ pillar TEXT, -- pillar category (e.g. architecture/security/perf)
102
+ severity TEXT, -- critical/high/medium/low/info
103
+ confidence REAL, -- 0.0-1.0
104
+ body_md TEXT, -- finding body (markdown)
105
+ applied INTEGER DEFAULT 0, -- 0 = not yet applied, 1 = applied
106
+ created_at TEXT, -- ISO 8601
107
+ FOREIGN KEY (cycle_id) REFERENCES state_position(cycle_id)
108
+ );
109
+ CREATE INDEX IF NOT EXISTS idx_findings_cycle ON findings(cycle_id);
110
+
111
+ -- design_debt: mirrors <design_debt> block entries.
112
+ CREATE TABLE IF NOT EXISTS design_debt (
113
+ id TEXT PRIMARY KEY, -- stable id
114
+ category TEXT, -- debt category
115
+ instances TEXT, -- JSON array of affected instances/files
116
+ priority TEXT, -- critical/high/medium/low
117
+ status TEXT -- open/acknowledged/resolved
118
+ );
119
+
120
+ -- recall_records: Phase 19.5 recall store mirror (extended for Phase 57).
121
+ -- The standalone Phase 19.5 design-search store keeps working as the fallback;
122
+ -- this table is populated by migrate-to-sqlite and updated on dual-write.
123
+ CREATE TABLE IF NOT EXISTS recall_records (
124
+ id TEXT PRIMARY KEY, -- stable recall id
125
+ cycle_id TEXT, -- FK -> state_position.cycle_id
126
+ kind TEXT, -- recall kind (finding/decision/note/...)
127
+ body_md TEXT, -- recall body
128
+ tags TEXT, -- JSON array of tag strings
129
+ created_at TEXT, -- ISO 8601
130
+ FOREIGN KEY (cycle_id) REFERENCES state_position(cycle_id)
131
+ );
132
+ CREATE INDEX IF NOT EXISTS idx_recall_cycle ON recall_records(cycle_id);
133
+
134
+ -- instincts: Phase 51 instinct-store mirror (created empty in Wave A; populated by migrate).
135
+ -- The standalone Phase 51 instinct-store keeps working as the fallback;
136
+ -- merge-and-retire is a v1.58 concern (documented deferral).
137
+ CREATE TABLE IF NOT EXISTS instincts (
138
+ id TEXT PRIMARY KEY, -- stable instinct id
139
+ scope TEXT, -- project / global
140
+ domain TEXT, -- intake/explore/decide/build/verify/operate/utility
141
+ body_md TEXT, -- instinct body (markdown)
142
+ confidence REAL, -- 0.0-1.0 posterior mean
143
+ cycles_seen INTEGER, -- number of cycles this instinct has been observed
144
+ project_ids TEXT, -- JSON array of project ids (sha8)
145
+ last_seen TEXT -- ISO 8601 date last surfaced
146
+ );
147
+
148
+ -- sessions: runtime session records (Phase 22 / worktree awareness).
149
+ CREATE TABLE IF NOT EXISTS sessions (
150
+ id TEXT PRIMARY KEY, -- session id (UUID or opaque string)
151
+ runtime TEXT, -- runtime name (claude-code / gemini / codex / ...)
152
+ harness TEXT, -- harness identifier
153
+ started_at TEXT, -- ISO 8601
154
+ ended_at TEXT -- ISO 8601 (NULL = still active)
155
+ );
156
+
157
+ -- worktree_state: per-worktree active state (Phase 49 / parallel executor awareness).
158
+ CREATE TABLE IF NOT EXISTS worktree_state (
159
+ path TEXT PRIMARY KEY, -- absolute worktree path
160
+ branch TEXT, -- current branch in worktree
161
+ owns_session_id TEXT, -- FK -> sessions.id (advisory; may be NULL)
162
+ updated_at TEXT -- ISO 8601
163
+ );
164
+
165
+ -- conflict_incidents: detected concurrent-write conflict events.
166
+ CREATE TABLE IF NOT EXISTS conflict_incidents (
167
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
168
+ files TEXT, -- JSON array of conflicting file paths
169
+ session_ids TEXT, -- JSON array of session ids involved
170
+ detected_at TEXT -- ISO 8601
171
+ );
172
+
173
+ -- _meta: schema-level key/value store.
174
+ -- Holds schema_version and last_render_sha256 (mirror of state_position for fast lookup).
175
+ CREATE TABLE IF NOT EXISTS _meta (
176
+ key TEXT PRIMARY KEY,
177
+ value TEXT
178
+ );
179
+
180
+ -- _block_meta: per-cycle, per-block rendering metadata for byte-equal round-trip (R6).
181
+ -- Stores the gap (blank lines) before each block, the raw verbatim block body,
182
+ -- and emit ordinal so unstructured blocks (connections/timestamps/parallelism_decision/todos)
183
+ -- round-trip exactly.
184
+ CREATE TABLE IF NOT EXISTS _block_meta (
185
+ cycle_id TEXT NOT NULL, -- FK -> state_position.cycle_id
186
+ block TEXT NOT NULL, -- block name (decisions/blockers/must_haves/position/...)
187
+ gap TEXT, -- blank lines preceding the block open tag (for emit order)
188
+ raw_body TEXT, -- verbatim raw body of the block (for unstructured blocks)
189
+ ordinal INTEGER, -- emit ordinal (position in BLOCK_ORDER for this cycle)
190
+ PRIMARY KEY (cycle_id, block),
191
+ FOREIGN KEY (cycle_id) REFERENCES state_position(cycle_id)
192
+ );
193
+
194
+ -- ---------------------------------------------------------------------------
195
+ -- FTS5 virtual tables (execute ONLY when _sqliteOk - better-sqlite3 + fts5 probe passed)
196
+ -- state-backend.cjs splits on the marker comment below and executes this
197
+ -- section separately, guarded by the _sqliteOk flag.
198
+ -- ---------------------------------------------------------------------------
199
+ -- GDD_FTS5_SECTION_START
200
+
201
+ -- decisions_fts: full-text search over decision body and tags.
202
+ -- Uses trigram tokenizer for partial-match queries (like instinct-store FTS5).
203
+ CREATE VIRTUAL TABLE IF NOT EXISTS decisions_fts
204
+ USING fts5(id UNINDEXED, body_md, tags, tokenize='trigram');
205
+
206
+ -- findings_fts: full-text search over finding body and pillar.
207
+ CREATE VIRTUAL TABLE IF NOT EXISTS findings_fts
208
+ USING fts5(id UNINDEXED, body_md, pillar, tokenize='trigram');
209
+
210
+ -- recall_fts: full-text search over recall record body and tags.
211
+ CREATE VIRTUAL TABLE IF NOT EXISTS recall_fts
212
+ USING fts5(id UNINDEXED, body_md, tags, tokenize='trigram');
213
+
214
+ -- instincts_fts: full-text search over instinct body and domain (mirrors instinct-store).
215
+ CREATE VIRTUAL TABLE IF NOT EXISTS instincts_fts
216
+ USING fts5(id UNINDEXED, body_md, domain, tokenize='trigram');
217
+
218
+ -- GDD_FTS5_SECTION_END