@hegemonart/get-design-done 1.55.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +90 -0
- package/README.md +6 -0
- package/SKILL.md +2 -0
- package/agents/design-fixer.md +16 -0
- package/dist/claude-code/.claude/skills/override/SKILL.md +86 -0
- package/dist/claude-code/.claude/skills/state/SKILL.md +106 -0
- package/hooks/gdd-decision-injector.js +58 -0
- package/hooks/gdd-fact-force.js +434 -0
- package/hooks/gdd-risk-gate.js +406 -0
- package/hooks/hooks.json +18 -0
- package/package.json +1 -1
- package/reference/schemas/events.schema.json +61 -1
- package/reference/skill-graph.md +3 -1
- package/scripts/lib/manifest/skills.json +16 -0
- package/scripts/lib/risk/calibration.cjs +385 -0
- package/scripts/lib/risk/compute-risk.cjs +229 -0
- package/scripts/lib/risk/consumers.cjs +211 -0
- package/scripts/lib/risk/override.cjs +87 -0
- package/scripts/lib/risk/route.cjs +59 -0
- package/scripts/lib/risk/tables.cjs +221 -0
- package/scripts/lib/state/migrate-to-sqlite.cjs +664 -0
- package/scripts/lib/state/query-surface.cjs +391 -0
- package/scripts/lib/state/render-markdown.cjs +717 -0
- package/scripts/lib/state/state-backend.cjs +345 -0
- package/scripts/lib/state/state-store.cjs +735 -0
- package/sdk/cli/index.js +193 -96
- package/sdk/dashboard/data/source.cjs +44 -5
- package/sdk/mcp/gdd-state/server.js +127 -30
- package/sdk/mcp/gdd-state/tools/get.ts +8 -0
- package/sdk/state/index.ts +267 -13
- package/sdk/state/lockfile.ts +48 -0
- package/sdk/state/schema.sql +218 -0
- package/skills/override/SKILL.md +86 -0
- package/skills/state/SKILL.md +106 -0
package/sdk/state/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
38
|
-
* (the
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
*
|
|
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;
|
|
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
|
|
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
|
*/
|
package/sdk/state/lockfile.ts
CHANGED
|
@@ -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
|