@hegemonart/get-design-done 1.56.0 → 1.57.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.
@@ -13,10 +13,32 @@
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
+ statSync,
37
+ } from 'node:fs';
38
+ import { dirname, join, resolve } from 'node:path';
39
+ import { pathToFileURL } from 'node:url';
18
40
 
19
- import { acquire } from './lockfile.ts';
41
+ import { acquire, acquireSqliteLock } from './lockfile.ts';
20
42
  import { parse } from './parser.ts';
21
43
  import { serialize } from './mutator.ts';
22
44
  import { gateFor } from './gates.ts';
@@ -31,15 +53,173 @@ import {
31
53
  export type { ParsedState, Stage } from './types.ts';
32
54
  export { TransitionGateFailed, LockAcquisitionError, ParseError } from './types.ts';
33
55
 
56
+ // ---------------------------------------------------------------------------
57
+ // Phase 57: package-root resolver (walk-up from sdk/state/index.ts location).
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Walk up from `startDir` to find the package root (directory with
62
+ * package.json named '@hegemonart/get-design-done').
63
+ * Falls back to the first directory that has any package.json, then null.
64
+ */
65
+ function _findPackageRoot(startDir: string): string | null {
66
+ let dir = resolve(startDir);
67
+ let firstWithPkg: string | null = null;
68
+ for (let i = 0; i < 12; i++) {
69
+ const pkgPath = join(dir, 'package.json');
70
+ if (existsSync(pkgPath)) {
71
+ try {
72
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
73
+ const pkg = require(pkgPath) as { name?: string };
74
+ if (firstWithPkg === null) firstWithPkg = dir;
75
+ if (pkg.name === '@hegemonart/get-design-done') return dir;
76
+ } catch {
77
+ if (firstWithPkg === null) firstWithPkg = dir;
78
+ }
79
+ }
80
+ const parent = dirname(dir);
81
+ if (parent === dir) break;
82
+ dir = parent;
83
+ }
84
+ return firstWithPkg;
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Phase 57: backend probe (loaded once via require, memoized).
89
+ // state-backend.cjs is a CommonJS module; require() works from .ts under
90
+ // Node 22 --experimental-strip-types (only type-erasable syntax is used).
91
+ // ---------------------------------------------------------------------------
92
+
93
+ interface StateBackendMod {
94
+ Database: ((...args: unknown[]) => unknown) | null;
95
+ BACKEND: 'sqlite' | 'markdown';
96
+ sqlitePath: (projectRoot: string) => string;
97
+ openStateDb: (p: string, opts?: { readonly?: boolean }) => StateDb;
98
+ }
99
+
100
+ interface StateDb {
101
+ close(): void;
102
+ prepare(sql: string): { get(...args: unknown[]): unknown; all(...args: unknown[]): unknown[]; run(...args: unknown[]): unknown };
103
+ transaction(fn: () => void): () => void;
104
+ pragma(s: string): unknown;
105
+ exec(s: string): void;
106
+ }
107
+
108
+ /** null = not yet loaded, false = failed to load, StateBackendMod = loaded */
109
+ let _backendCache: StateBackendMod | null | false = null;
110
+
111
+ function _loadBackend(): StateBackendMod | null {
112
+ if (_backendCache !== null) return _backendCache === false ? null : _backendCache as StateBackendMod;
113
+ try {
114
+ const pkgRoot = _findPackageRoot(__dirname);
115
+ if (pkgRoot === null) { _backendCache = false; return null; }
116
+ const backendPath = join(pkgRoot, 'scripts', 'lib', 'state', 'state-backend.cjs');
117
+ if (!existsSync(backendPath)) { _backendCache = false; return null; }
118
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
119
+ _backendCache = require(backendPath) as StateBackendMod;
120
+ return _backendCache as StateBackendMod;
121
+ } catch {
122
+ _backendCache = false;
123
+ return null;
124
+ }
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Phase 57: state-store loader (async, cached after first successful load).
129
+ // state-store.cjs itself does dynamic imports of .ts SDK files, so we load
130
+ // it via dynamic import (not require) to stay in async context.
131
+ // ---------------------------------------------------------------------------
132
+
133
+ interface StateStoreMod {
134
+ appendDecision: (...args: unknown[]) => Promise<unknown>;
135
+ getDecisions: (...args: unknown[]) => unknown[];
136
+ setPosition: (...args: unknown[]) => Promise<unknown>;
137
+ getPosition: (...args: unknown[]) => unknown;
138
+ backendName: () => string;
139
+ [key: string]: unknown;
140
+ }
141
+
142
+ let _storeCache: StateStoreMod | null | false = null;
143
+
144
+ async function _loadStore(): Promise<StateStoreMod | null> {
145
+ if (_storeCache !== null) return _storeCache === false ? null : _storeCache as StateStoreMod;
146
+ try {
147
+ const pkgRoot = _findPackageRoot(__dirname);
148
+ if (pkgRoot === null) { _storeCache = false; return null; }
149
+ const storePath = join(pkgRoot, 'scripts', 'lib', 'state', 'state-store.cjs');
150
+ if (!existsSync(storePath)) { _storeCache = false; return null; }
151
+ const mod = await import(pathToFileURL(storePath).href);
152
+ _storeCache = mod as StateStoreMod;
153
+ return _storeCache as StateStoreMod;
154
+ } catch {
155
+ _storeCache = false;
156
+ return null;
157
+ }
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Phase 57: migration-active gate.
162
+ //
163
+ // migrationActive(statePath) === true iff:
164
+ // 1. BACKEND === 'sqlite' (better-sqlite3 + FTS5 available, env not forcing markdown)
165
+ // 2. existsSync(join(dirname(statePath), 'state.sqlite'))
166
+ //
167
+ // The sibling check uses dirname(statePath) directly so every temp-dir test
168
+ // that creates a STATE.md WITHOUT a sibling state.sqlite is correctly NOT
169
+ // considered migrated — this is the universal default.
170
+ //
171
+ // This gate is what keeps all existing Phase-20 tests green with better-sqlite3
172
+ // present: they use temp paths with no state.sqlite sibling, so migrationActive
173
+ // returns false and the pure-markdown path runs byte-identically.
174
+ // ---------------------------------------------------------------------------
175
+
176
+ function migrationActive(statePath: string): boolean {
177
+ const backend = _loadBackend();
178
+ if (backend === null || backend.BACKEND !== 'sqlite') return false;
179
+ const sqliteSibling = join(dirname(statePath), 'state.sqlite');
180
+ if (!existsSync(sqliteSibling)) return false;
181
+ // BUG-07: a DIRECTORY named state.sqlite would cause existsSync to return true,
182
+ // and then every mutate() would throw when trying to open it as a database.
183
+ // Guard: if the path is a directory, treat migration as inactive.
184
+ try {
185
+ if (statSync(sqliteSibling).isDirectory()) return false;
186
+ } catch {
187
+ return false;
188
+ }
189
+ return true;
190
+ }
191
+
192
+ /**
193
+ * Return the path to the sibling state.sqlite for a given STATE.md path.
194
+ * This is always `<dirname(statePath)>/state.sqlite`.
195
+ * Exported for use by tests and lockfile helpers.
196
+ */
197
+ export function sqlitePathFor(statePath: string): string {
198
+ return join(dirname(statePath), 'state.sqlite');
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Public API
203
+ // ---------------------------------------------------------------------------
204
+
34
205
  /**
35
206
  * Read STATE.md from disk and return the parsed state.
36
207
  *
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).
208
+ * Phase 57 dual-write: when migration is active, reads the on-disk STATE.md
209
+ * (which the dual-write path keeps byte-equal with SQLite state), then parses
210
+ * it. This matches the pre-Phase-57 behavior of this function and keeps the
211
+ * returned ParsedState shape identical whether reading via SQLite or markdown.
212
+ *
213
+ * Shared-read: no lock is taken. Reads are snapshot-safe (atomic rename by
214
+ * `mutate()` means we either see the old file or the new file, never a torn
215
+ * write).
41
216
  */
42
217
  export async function read(path: string): Promise<ParsedState> {
218
+ // Phase 57: both paths parse the on-disk STATE.md (the dual-write path
219
+ // maintains STATE.md as a byte-equal render of SQLite state). No behavioral
220
+ // difference; the branch is here for future extension (e.g., reading directly
221
+ // from SQLite without touching disk). The returned ParsedState is identical
222
+ // whether migration is active or not.
43
223
  const raw: string = readFileSync(path, 'utf8');
44
224
  return parse(raw).state;
45
225
  }
@@ -47,13 +227,19 @@ export async function read(path: string): Promise<ParsedState> {
47
227
  /**
48
228
  * Atomic read-modify-write on STATE.md.
49
229
  *
50
- * Flow:
230
+ * Phase 57 dual-write: when migration is active for this statePath, acquires
231
+ * the SQLite sibling lock BEFORE the STATE.md lock (R9 deadlock-free
232
+ * ordering), then after applying fn() serializes to markdown and writes
233
+ * atomically (same .tmp+rename path). The on-disk STATE.md is always the
234
+ * byte-equal rendered form, so SQLite stays consistent via the R8 freshness
235
+ * guard on the next state-store operation.
236
+ *
237
+ * When migration is inactive, behavior is EXACTLY as pre-Phase-57:
51
238
  * 1. Acquire sibling `.lock` file (PID+timestamp advisory lock).
52
239
  * 2. Read current contents.
53
240
  * 3. Apply `fn`.
54
241
  * 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.
242
+ * 5. `renameSync(.tmp, path)` — POSIX-atomic; Windows EPERM retry once.
57
243
  * 6. Release the lock (in `finally` — released even on mid-fn throw).
58
244
  *
59
245
  * Crash between write and rename is benign: STATE.md is untouched; the
@@ -62,6 +248,20 @@ export async function read(path: string): Promise<ParsedState> {
62
248
  export async function mutate(
63
249
  path: string,
64
250
  fn: (s: ParsedState) => ParsedState,
251
+ ): Promise<ParsedState> {
252
+ if (migrationActive(path)) {
253
+ return _mutateSqliteActive(path, fn);
254
+ }
255
+ return _mutateMarkdown(path, fn);
256
+ }
257
+
258
+ /**
259
+ * Pure markdown mutate path (pre-Phase-57 behavior, unchanged).
260
+ * Called when migrationActive() === false.
261
+ */
262
+ async function _mutateMarkdown(
263
+ path: string,
264
+ fn: (s: ParsedState) => ParsedState,
65
265
  ): Promise<ParsedState> {
66
266
  const release = await acquire(path);
67
267
  const tmpPath: string = `${path}.tmp`;
@@ -69,8 +269,6 @@ export async function mutate(
69
269
  const raw: string = readFileSync(path, 'utf8');
70
270
  const { state, raw_bodies, raw_frontmatter, block_gaps, line_ending } =
71
271
  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
272
  const clone = structuredClone(state);
75
273
  const next = fn(clone);
76
274
  const out = serialize(next, {
@@ -97,11 +295,10 @@ export async function mutate(
97
295
  }
98
296
  return next;
99
297
  } catch (err) {
100
- // Clean up the orphaned tmp file on failure so we don't pollute.
101
298
  try {
102
299
  if (existsSync(tmpPath)) unlinkSync(tmpPath);
103
300
  } catch {
104
- // best-effort; a leftover tmp file does not corrupt STATE.md.
301
+ // best-effort cleanup.
105
302
  }
106
303
  throw err;
107
304
  } finally {
@@ -109,6 +306,69 @@ export async function mutate(
109
306
  }
110
307
  }
111
308
 
309
+ /**
310
+ * SQLite-active mutate path (Phase 57, migrationActive() === true).
311
+ *
312
+ * Lock ordering per R9: acquire state.sqlite.lock BEFORE STATE.md.lock
313
+ * (deadlock-free). Both released in finally.
314
+ *
315
+ * Write strategy: serialize next state to markdown, write atomically
316
+ * (.tmp + rename). This keeps STATE.md byte-equal with the last parsed
317
+ * state. The R8 freshness guard in state-store detects the sha-change on
318
+ * the next state-store call and re-syncs SQLite from the markdown SoT.
319
+ * This avoids duplicating state-store's row-level write logic here while
320
+ * still keeping SQLite eventually consistent.
321
+ */
322
+ async function _mutateSqliteActive(
323
+ path: string,
324
+ fn: (s: ParsedState) => ParsedState,
325
+ ): Promise<ParsedState> {
326
+ const sqlitePath = join(dirname(path), 'state.sqlite');
327
+ // Acquire SQLite lock first (R9: state.sqlite.lock before STATE.md.lock).
328
+ const releaseSqliteLock = await acquireSqliteLock(sqlitePath);
329
+ const release = await acquire(path);
330
+ const tmpPath: string = `${path}.tmp`;
331
+ try {
332
+ const raw: string = readFileSync(path, 'utf8');
333
+ const { state, raw_bodies, raw_frontmatter, block_gaps, line_ending } =
334
+ parse(raw);
335
+ const clone = structuredClone(state);
336
+ const next = fn(clone);
337
+ const out = serialize(next, {
338
+ raw_frontmatter,
339
+ raw_bodies,
340
+ block_gaps,
341
+ line_ending,
342
+ });
343
+ writeFileSync(tmpPath, out, 'utf8');
344
+ try {
345
+ renameSync(tmpPath, path);
346
+ } catch (err) {
347
+ const code =
348
+ typeof err === 'object' && err !== null && 'code' in err
349
+ ? (err as { code?: unknown }).code
350
+ : undefined;
351
+ if (code === 'EPERM' || code === 'EBUSY') {
352
+ await new Promise((r) => setTimeout(r, 50));
353
+ renameSync(tmpPath, path);
354
+ } else {
355
+ throw err;
356
+ }
357
+ }
358
+ return next;
359
+ } catch (err) {
360
+ try {
361
+ if (existsSync(tmpPath)) unlinkSync(tmpPath);
362
+ } catch {
363
+ // best-effort cleanup.
364
+ }
365
+ throw err;
366
+ } finally {
367
+ await release();
368
+ await releaseSqliteLock();
369
+ }
370
+ }
371
+
112
372
  /**
113
373
  * Advance to `toStage` under the locked RMW protocol.
114
374
  *
@@ -125,6 +385,10 @@ export async function mutate(
125
385
  * - frontmatter.last_checkpoint = now (ISO)
126
386
  * - timestamps[`${toStage}_started_at`] = now (ISO)
127
387
  *
388
+ * Phase 57: the gate evaluation + stamping logic is unchanged. The
389
+ * persistence path branches via `mutate()` which applies the
390
+ * migration-active gate internally.
391
+ *
128
392
  * Returns the updated state plus the gate response (for callers that
129
393
  * want to log blockers — on pass, `blockers` is always `[]`).
130
394
  */
@@ -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