@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +73 -0
- package/README.md +2 -0
- package/SKILL.md +1 -0
- package/dist/claude-code/.claude/skills/state/SKILL.md +106 -0
- package/hooks/budget-enforcer.ts +5 -4
- package/hooks/gdd-fact-force.js +92 -3
- package/hooks/gdd-protected-paths.js +25 -2
- package/hooks/gdd-read-injection-scanner.ts +9 -1
- package/hooks/gdd-risk-gate.js +17 -7
- package/package.json +1 -1
- package/reference/skill-graph.md +2 -1
- package/scripts/lib/design-search.cjs +20 -2
- package/scripts/lib/manifest/skills.json +8 -0
- package/scripts/lib/state/migrate-to-sqlite.cjs +680 -0
- package/scripts/lib/state/query-surface.cjs +403 -0
- package/scripts/lib/state/render-markdown.cjs +729 -0
- package/scripts/lib/state/state-backend.cjs +345 -0
- package/scripts/lib/state/state-store.cjs +766 -0
- package/sdk/cli/index.js +199 -96
- package/sdk/dashboard/data/_pkg-root.cjs +4 -4
- package/sdk/dashboard/data/risk-surface.cjs +54 -19
- package/sdk/dashboard/data/source.cjs +44 -5
- package/sdk/dashboard/tui/index.cjs +28 -2
- package/sdk/mcp/gdd-state/server.js +133 -30
- package/sdk/mcp/gdd-state/tools/get.ts +8 -0
- package/sdk/state/index.ts +277 -13
- package/sdk/state/lockfile.ts +48 -0
- package/sdk/state/schema.sql +218 -0
- package/skills/state/SKILL.md +106 -0
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* scripts/lib/state/state-store.cjs - Phase 57 SQLite State Backbone.
|
|
4
|
+
*
|
|
5
|
+
* Dual-backend dispatch layer. All state mutations and queries route through
|
|
6
|
+
* this module. When BACKEND==='sqlite', operations write to SQLite AND render
|
|
7
|
+
* a fresh STATE.md inside a single better-sqlite3 transaction (R7 dual-write).
|
|
8
|
+
* When BACKEND==='markdown', operations delegate to the SDK markdown path.
|
|
9
|
+
*
|
|
10
|
+
* Public API:
|
|
11
|
+
* appendDecision(decision, opts) - add a decision row
|
|
12
|
+
* getDecisions(opts) - return decision rows
|
|
13
|
+
* appendBlocker(blocker, opts) - add a blocker row
|
|
14
|
+
* getBlockers(opts) - return blocker rows
|
|
15
|
+
* setPosition(position, opts) - upsert state_position
|
|
16
|
+
* getPosition(opts) - return current position
|
|
17
|
+
* queryDecisions(ftsQuery, opts) - FTS5 search over decisions (or JS fallback)
|
|
18
|
+
* migrate(migrateOpts) - lazy-require migrate-to-sqlite.cjs (Executor B)
|
|
19
|
+
* render(projectRoot) - lazy-require render-markdown.cjs (Executor C)
|
|
20
|
+
* backendName() - return BACKEND string
|
|
21
|
+
*
|
|
22
|
+
* R7 dual-write: every SQLite MUTATION wraps writeStructured() + renderMarkdown()
|
|
23
|
+
* in a single db.transaction(). If the markdown render throws, the transaction
|
|
24
|
+
* rolls back SQLite too (no divergence).
|
|
25
|
+
*
|
|
26
|
+
* The SDK (sdk/state) is loaded asynchronously ONCE via lazy async loader,
|
|
27
|
+
* BEFORE entering the synchronous db.transaction() callback. This is required
|
|
28
|
+
* because better-sqlite3 transactions are synchronous and cannot contain
|
|
29
|
+
* async code (dynamic import returns a Promise, which would cause
|
|
30
|
+
* "Received an instance of Promise" errors when passed to writeFileSync).
|
|
31
|
+
*
|
|
32
|
+
* R8 freshness guard: before any mutation, compare on-disk STATE.md sha256 to
|
|
33
|
+
* _meta.last_render_sha256. If they differ (user hand-edited), re-parse markdown
|
|
34
|
+
* + upsert SQLite first (mini-migration), then proceed with the intended mutation.
|
|
35
|
+
*
|
|
36
|
+
* NEVER throws on a missing better-sqlite3 module. Always degrades.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const fs = require('node:fs');
|
|
40
|
+
const path = require('node:path');
|
|
41
|
+
const crypto = require('node:crypto');
|
|
42
|
+
const { pathToFileURL } = require('node:url');
|
|
43
|
+
|
|
44
|
+
const { BACKEND, Database, openStateDb, sqlitePath, loadSchema } = require('./state-backend.cjs');
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Package-root walk-up for SDK resolution (same pattern as render-markdown).
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
function findPackageRoot(startDir) {
|
|
51
|
+
let dir = path.resolve(startDir);
|
|
52
|
+
let firstWithPkg = null;
|
|
53
|
+
for (let i = 0; i < 12; i++) {
|
|
54
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
55
|
+
let pkg = null;
|
|
56
|
+
try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); } catch { pkg = null; }
|
|
57
|
+
if (pkg) {
|
|
58
|
+
if (firstWithPkg === null) firstWithPkg = dir;
|
|
59
|
+
if (pkg.name === '@hegemonart/get-design-done') return dir;
|
|
60
|
+
}
|
|
61
|
+
const parent = path.dirname(dir);
|
|
62
|
+
if (parent === dir) break;
|
|
63
|
+
dir = parent;
|
|
64
|
+
}
|
|
65
|
+
return firstWithPkg || path.resolve(startDir);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const PKG_ROOT = findPackageRoot(__dirname);
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Lazy async SDK loader (cached after first call).
|
|
72
|
+
// Loads sdk/state (serialize + parse) via dynamic import (never require a .ts).
|
|
73
|
+
// Must be called BEFORE entering db.transaction() so the loaded sdk can be
|
|
74
|
+
// used synchronously inside the transaction callback.
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/** @type {{ serialize: Function, parse: Function } | null} */
|
|
78
|
+
let _sdk = null;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Load the SDK async, cache after first load.
|
|
82
|
+
* Returns { serialize, parse } or null if unavailable.
|
|
83
|
+
* @returns {Promise<{ serialize: Function, parse: Function } | null>}
|
|
84
|
+
*/
|
|
85
|
+
async function loadSdk() {
|
|
86
|
+
if (_sdk !== null) return _sdk;
|
|
87
|
+
try {
|
|
88
|
+
const mutatorPath = path.join(PKG_ROOT, 'sdk', 'state', 'mutator.ts');
|
|
89
|
+
const parserPath = path.join(PKG_ROOT, 'sdk', 'state', 'parser.ts');
|
|
90
|
+
const [mutator, parser] = await Promise.all([
|
|
91
|
+
import(pathToFileURL(mutatorPath).href),
|
|
92
|
+
import(pathToFileURL(parserPath).href),
|
|
93
|
+
]);
|
|
94
|
+
if (mutator && parser && typeof mutator.serialize === 'function' && typeof parser.parse === 'function') {
|
|
95
|
+
_sdk = { serialize: mutator.serialize, parse: parser.parse };
|
|
96
|
+
} else {
|
|
97
|
+
_sdk = null;
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
_sdk = null;
|
|
101
|
+
}
|
|
102
|
+
return _sdk;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Lazy-require helpers for Executor B and C modules (PINNED names).
|
|
107
|
+
// Do NOT call require() on these at module load - they may not exist yet.
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/** @type {any|null} Cached migrate-to-sqlite module (Executor B). */
|
|
111
|
+
let _migrateModule = null;
|
|
112
|
+
/** @type {any|null} Cached render-markdown module (Executor C). */
|
|
113
|
+
let _renderModule = null;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Lazy-require ./migrate-to-sqlite.cjs (Executor B's module).
|
|
117
|
+
* Returns null if not yet available.
|
|
118
|
+
* @returns {any|null}
|
|
119
|
+
*/
|
|
120
|
+
function _requireMigrate() {
|
|
121
|
+
if (_migrateModule) return _migrateModule;
|
|
122
|
+
try {
|
|
123
|
+
_migrateModule = require('./migrate-to-sqlite.cjs');
|
|
124
|
+
} catch {
|
|
125
|
+
_migrateModule = null;
|
|
126
|
+
}
|
|
127
|
+
return _migrateModule;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Lazy-require ./render-markdown.cjs (Executor C's module).
|
|
132
|
+
* Returns null if not yet available.
|
|
133
|
+
* @returns {any|null}
|
|
134
|
+
*/
|
|
135
|
+
function _requireRender() {
|
|
136
|
+
if (_renderModule) return _renderModule;
|
|
137
|
+
try {
|
|
138
|
+
_renderModule = require('./render-markdown.cjs');
|
|
139
|
+
} catch {
|
|
140
|
+
_renderModule = null;
|
|
141
|
+
}
|
|
142
|
+
return _renderModule;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Atomic write helper (mirrors instinct-store .tmp+rename pattern).
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Write content to filePath atomically via a .tmp sibling + rename.
|
|
151
|
+
* Creates parent directories if needed.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} filePath
|
|
154
|
+
* @param {string} content
|
|
155
|
+
*/
|
|
156
|
+
function _writeFileAtomic(filePath, content) {
|
|
157
|
+
const dir = path.dirname(filePath);
|
|
158
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
159
|
+
const tmp = filePath + '.tmp';
|
|
160
|
+
fs.writeFileSync(tmp, content, 'utf8');
|
|
161
|
+
fs.renameSync(tmp, filePath);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// SHA256 helper for R8 freshness guard.
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Compute the sha256 hex digest of a string.
|
|
170
|
+
* @param {string} content
|
|
171
|
+
* @returns {string}
|
|
172
|
+
*/
|
|
173
|
+
function _sha256(content) {
|
|
174
|
+
return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Read STATE.md from disk and return its sha256. Returns null if not found.
|
|
179
|
+
* @param {string} statePath
|
|
180
|
+
* @returns {string|null}
|
|
181
|
+
*/
|
|
182
|
+
function _onDiskSha(statePath) {
|
|
183
|
+
try {
|
|
184
|
+
const content = fs.readFileSync(statePath, 'utf8');
|
|
185
|
+
return _sha256(content);
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// R8 freshness guard - check if STATE.md has been hand-edited since last render.
|
|
193
|
+
// If so, run a mini-migration to upsert SQLite from the current markdown state.
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Compare on-disk STATE.md sha256 to the stored _meta.last_render_sha256.
|
|
198
|
+
* If they differ, the user has hand-edited STATE.md since the last SQLite write.
|
|
199
|
+
* In that case, run a mini-migration (upsert SQLite from markdown) before proceeding.
|
|
200
|
+
*
|
|
201
|
+
* This is ASYNC - must be awaited before entering the db.transaction().
|
|
202
|
+
*
|
|
203
|
+
* @param {import('better-sqlite3').Database} db
|
|
204
|
+
* @param {string} statePath absolute path to STATE.md
|
|
205
|
+
*/
|
|
206
|
+
async function _applyFreshnessGuard(db, statePath) {
|
|
207
|
+
try {
|
|
208
|
+
const onDisk = _onDiskSha(statePath);
|
|
209
|
+
if (onDisk === null) return; // STATE.md doesn't exist yet - skip
|
|
210
|
+
const metaRow = db.prepare('SELECT value FROM _meta WHERE key = ?').get('last_render_sha256');
|
|
211
|
+
const stored = metaRow ? metaRow.value : null;
|
|
212
|
+
if (stored === onDisk) return; // No drift - proceed normally
|
|
213
|
+
// Drift detected: user hand-edited STATE.md.
|
|
214
|
+
// Run mini-migration (upsertOnly) to fold the hand-edit into SQLite before
|
|
215
|
+
// proceeding with the intended mutation.
|
|
216
|
+
const migrate = _requireMigrate();
|
|
217
|
+
if (migrate && typeof migrate.migrateToSqlite === 'function') {
|
|
218
|
+
try {
|
|
219
|
+
// Close db first if needed - migrateToSqlite opens its own connection.
|
|
220
|
+
// We pass the resolved projectRoot (dirname of .design/STATE.md's parent).
|
|
221
|
+
const projectRoot = path.resolve(path.dirname(statePath), '..');
|
|
222
|
+
await migrate.migrateToSqlite({ statePath, projectRoot, force: true, upsertOnly: true });
|
|
223
|
+
} catch {
|
|
224
|
+
// Migration failed - log and continue rather than blocking the write.
|
|
225
|
+
// Still update the sha to prevent infinite re-triggering.
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// Update stored sha to reflect the current on-disk content.
|
|
229
|
+
db.prepare('INSERT OR REPLACE INTO _meta(key, value) VALUES (?, ?)').run('last_render_sha256', onDisk);
|
|
230
|
+
} catch {
|
|
231
|
+
/* Freshness guard must never break a write path */
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Resolve STATE.md path from projectRoot or current sqlite db location.
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Resolve the STATE.md path for a given project root (or process.cwd()).
|
|
241
|
+
* @param {string} [projectRoot]
|
|
242
|
+
* @returns {string}
|
|
243
|
+
*/
|
|
244
|
+
function _statePath(projectRoot) {
|
|
245
|
+
const root = projectRoot || process.cwd();
|
|
246
|
+
return path.join(root, '.design', 'STATE.md');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// SQLite mutation helper - R7 dual-write inside a single transaction.
|
|
251
|
+
// Pattern: BEFORE transaction: await loadSdk().
|
|
252
|
+
// INSIDE transaction (sync): writeStructured() + renderMarkdown(sdk).
|
|
253
|
+
// If the markdown render throws, the transaction rolls back SQLite too.
|
|
254
|
+
//
|
|
255
|
+
// sdk must be pre-loaded (passed in) because db.transaction() is SYNCHRONOUS
|
|
256
|
+
// and cannot contain await or dynamic import calls.
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Execute a structured write + markdown render in a single SQLite transaction.
|
|
261
|
+
*
|
|
262
|
+
* @param {import('better-sqlite3').Database} db
|
|
263
|
+
* @param {string} statePath path to STATE.md
|
|
264
|
+
* @param {string} cycleId cycle_id for the current state
|
|
265
|
+
* @param {Function} writeStructured function that does all SQLite writes (sync)
|
|
266
|
+
* @param {{ serialize: Function, parse: Function } | null} sdk pre-loaded SDK (or null)
|
|
267
|
+
*/
|
|
268
|
+
function _dualWrite(db, statePath, cycleId, writeStructured, sdk) {
|
|
269
|
+
const txn = db.transaction(() => {
|
|
270
|
+
writeStructured();
|
|
271
|
+
// Render markdown from SQLite and write atomically.
|
|
272
|
+
// MUST be synchronous: no await, no dynamic import here.
|
|
273
|
+
const renderMod = _requireRender();
|
|
274
|
+
if (renderMod && typeof renderMod.renderStateMarkdown === 'function' && sdk) {
|
|
275
|
+
// renderStateMarkdown is synchronous when sdk is injected.
|
|
276
|
+
const md = renderMod.renderStateMarkdown(db, cycleId, sdk);
|
|
277
|
+
_writeFileAtomic(statePath, md);
|
|
278
|
+
// Update last_render_sha256 in _meta.
|
|
279
|
+
const newSha = _sha256(md);
|
|
280
|
+
db.prepare('INSERT OR REPLACE INTO _meta(key, value) VALUES (?, ?)').run('last_render_sha256', newSha);
|
|
281
|
+
}
|
|
282
|
+
// If render module not available or sdk not loaded, still write the structured data.
|
|
283
|
+
// STATE.md will be stale until Executor C's render module is present.
|
|
284
|
+
});
|
|
285
|
+
txn();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// SQLite path: resolve from process.cwd() for store-level operations.
|
|
290
|
+
// Callers may pass { dbPath } or { projectRoot } to override.
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Resolve the dbPath from opts or default to sqlitePath(projectRoot or cwd).
|
|
295
|
+
* @param {{ dbPath?: string, projectRoot?: string }} [opts]
|
|
296
|
+
* @returns {string}
|
|
297
|
+
*/
|
|
298
|
+
function _resolveDbPath(opts = {}) {
|
|
299
|
+
if (opts.dbPath) return opts.dbPath;
|
|
300
|
+
return sqlitePath(opts.projectRoot || process.cwd());
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// appendDecision - add or upsert a decision row.
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Append (or upsert) a decision record.
|
|
309
|
+
*
|
|
310
|
+
* SQLite path: INSERT OR REPLACE into decisions + dual-write STATE.md.
|
|
311
|
+
* Markdown path: no-op write path (sdk/state owns markdown writes);
|
|
312
|
+
* returns { backend: 'markdown', skipped: true }.
|
|
313
|
+
*
|
|
314
|
+
* @param {{ id: string, cycleId?: string, bodyMd: string, status?: string,
|
|
315
|
+
* tags?: string[], ordinal?: number, rawLine?: string,
|
|
316
|
+
* createdAt?: string }} decision
|
|
317
|
+
* @param {{ dbPath?: string, projectRoot?: string }} [opts]
|
|
318
|
+
* @returns {Promise<{ backend: string, id: string }>}
|
|
319
|
+
*/
|
|
320
|
+
async function appendDecision(decision, opts = {}) {
|
|
321
|
+
if (BACKEND !== 'sqlite') {
|
|
322
|
+
return { backend: 'markdown', skipped: true, id: decision.id };
|
|
323
|
+
}
|
|
324
|
+
// Load SDK async BEFORE the transaction (transaction is sync).
|
|
325
|
+
const sdk = await loadSdk();
|
|
326
|
+
const dbPath = _resolveDbPath(opts);
|
|
327
|
+
const statePath = _statePath(opts.projectRoot);
|
|
328
|
+
const db = openStateDb(dbPath);
|
|
329
|
+
try {
|
|
330
|
+
await _applyFreshnessGuard(db, statePath);
|
|
331
|
+
const cycleId = decision.cycleId || _currentCycleId(db);
|
|
332
|
+
// BUG-10: if no cycle is active, skip rather than inserting cycle_id=null (NOT NULL throw).
|
|
333
|
+
if (!cycleId) {
|
|
334
|
+
return { backend: 'sqlite', skipped: true, reason: 'no active cycle_id - call setPosition first' };
|
|
335
|
+
}
|
|
336
|
+
_dualWrite(db, statePath, cycleId, () => {
|
|
337
|
+
db.prepare(`
|
|
338
|
+
INSERT INTO decisions
|
|
339
|
+
(id, cycle_id, phase_id, status, body_md, tags, ordinal, raw_line, created_at)
|
|
340
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
341
|
+
ON CONFLICT(cycle_id, id) DO UPDATE SET
|
|
342
|
+
body_md = excluded.body_md,
|
|
343
|
+
status = excluded.status,
|
|
344
|
+
raw_line = excluded.raw_line,
|
|
345
|
+
last_referenced_at = excluded.created_at
|
|
346
|
+
`).run(
|
|
347
|
+
decision.id,
|
|
348
|
+
cycleId || null,
|
|
349
|
+
decision.phaseId || null,
|
|
350
|
+
decision.status || 'tentative',
|
|
351
|
+
decision.bodyMd || '',
|
|
352
|
+
decision.tags ? JSON.stringify(decision.tags) : null,
|
|
353
|
+
typeof decision.ordinal === 'number' ? decision.ordinal : 0,
|
|
354
|
+
decision.rawLine || null,
|
|
355
|
+
decision.createdAt || new Date().toISOString(),
|
|
356
|
+
);
|
|
357
|
+
// BUG-05: populate decisions_fts so FTS5 queries return hits.
|
|
358
|
+
// FTS5 virtual tables do not support ON CONFLICT — use DELETE + INSERT pattern.
|
|
359
|
+
// Guard: if FTS5 tables are absent (no-fts5 build), skip without throwing.
|
|
360
|
+
try {
|
|
361
|
+
db.prepare(`DELETE FROM decisions_fts WHERE id = ?`).run(decision.id);
|
|
362
|
+
db.prepare(`INSERT INTO decisions_fts (id, body_md, tags) VALUES (?, ?, ?)`).run(
|
|
363
|
+
decision.id,
|
|
364
|
+
decision.bodyMd || '',
|
|
365
|
+
decision.tags ? JSON.stringify(decision.tags) : null,
|
|
366
|
+
);
|
|
367
|
+
} catch { /* FTS5 table absent in no-fts5 build - skip */ }
|
|
368
|
+
}, sdk);
|
|
369
|
+
return { backend: 'sqlite', id: decision.id };
|
|
370
|
+
} finally {
|
|
371
|
+
db.close();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// getDecisions - return decision rows.
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Return all decision rows for the current cycle, ordered by ordinal.
|
|
381
|
+
*
|
|
382
|
+
* SQLite path: SELECT from decisions.
|
|
383
|
+
* Markdown path: returns [] (read path; callers use sdk/state read() for markdown).
|
|
384
|
+
*
|
|
385
|
+
* @param {{ dbPath?: string, projectRoot?: string, cycleId?: string }} [opts]
|
|
386
|
+
* @returns {Array<object>}
|
|
387
|
+
*/
|
|
388
|
+
function getDecisions(opts = {}) {
|
|
389
|
+
if (BACKEND !== 'sqlite') {
|
|
390
|
+
return [];
|
|
391
|
+
}
|
|
392
|
+
// BUG-06: wrap in try/catch — openStateDb(readonly) throws on a missing file.
|
|
393
|
+
try {
|
|
394
|
+
const dbPath = _resolveDbPath(opts);
|
|
395
|
+
const db = openStateDb(dbPath, { readonly: true });
|
|
396
|
+
try {
|
|
397
|
+
const cycleId = opts.cycleId || _currentCycleId(db);
|
|
398
|
+
if (!cycleId) return [];
|
|
399
|
+
return db.prepare(
|
|
400
|
+
'SELECT * FROM decisions WHERE cycle_id = ? ORDER BY ordinal ASC'
|
|
401
|
+
).all(cycleId);
|
|
402
|
+
} finally {
|
|
403
|
+
db.close();
|
|
404
|
+
}
|
|
405
|
+
} catch {
|
|
406
|
+
return [];
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
// appendBlocker - add a blocker row.
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Append a blocker record.
|
|
416
|
+
*
|
|
417
|
+
* @param {{ cycleId?: string, stage: string, date: string, bodyMd: string,
|
|
418
|
+
* severity?: string, ordinal?: number, rawLine?: string }} blocker
|
|
419
|
+
* @param {{ dbPath?: string, projectRoot?: string }} [opts]
|
|
420
|
+
* @returns {Promise<{ backend: string, rowid: number|null }>}
|
|
421
|
+
*/
|
|
422
|
+
async function appendBlocker(blocker, opts = {}) {
|
|
423
|
+
if (BACKEND !== 'sqlite') {
|
|
424
|
+
return { backend: 'markdown', skipped: true, rowid: null };
|
|
425
|
+
}
|
|
426
|
+
// Load SDK async BEFORE the transaction (transaction is sync).
|
|
427
|
+
const sdk = await loadSdk();
|
|
428
|
+
const dbPath = _resolveDbPath(opts);
|
|
429
|
+
const statePath = _statePath(opts.projectRoot);
|
|
430
|
+
const db = openStateDb(dbPath);
|
|
431
|
+
try {
|
|
432
|
+
await _applyFreshnessGuard(db, statePath);
|
|
433
|
+
const cycleId = blocker.cycleId || _currentCycleId(db);
|
|
434
|
+
let rowid = null;
|
|
435
|
+
_dualWrite(db, statePath, cycleId, () => {
|
|
436
|
+
const result = db.prepare(`
|
|
437
|
+
INSERT INTO blockers (cycle_id, stage, date, severity, body_md, ordinal, raw_line)
|
|
438
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
439
|
+
`).run(
|
|
440
|
+
cycleId || null,
|
|
441
|
+
blocker.stage || null,
|
|
442
|
+
blocker.date || null,
|
|
443
|
+
blocker.severity || null,
|
|
444
|
+
blocker.bodyMd || '',
|
|
445
|
+
typeof blocker.ordinal === 'number' ? blocker.ordinal : 0,
|
|
446
|
+
blocker.rawLine || null,
|
|
447
|
+
);
|
|
448
|
+
rowid = result.lastInsertRowid;
|
|
449
|
+
}, sdk);
|
|
450
|
+
return { backend: 'sqlite', rowid };
|
|
451
|
+
} finally {
|
|
452
|
+
db.close();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
// getBlockers - return blocker rows.
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Return all (unresolved) blocker rows for the current cycle.
|
|
462
|
+
*
|
|
463
|
+
* @param {{ dbPath?: string, projectRoot?: string, cycleId?: string,
|
|
464
|
+
* includeResolved?: boolean }} [opts]
|
|
465
|
+
* @returns {Array<object>}
|
|
466
|
+
*/
|
|
467
|
+
function getBlockers(opts = {}) {
|
|
468
|
+
if (BACKEND !== 'sqlite') {
|
|
469
|
+
return [];
|
|
470
|
+
}
|
|
471
|
+
// BUG-06: wrap in try/catch — openStateDb(readonly) throws on a missing file.
|
|
472
|
+
try {
|
|
473
|
+
const dbPath = _resolveDbPath(opts);
|
|
474
|
+
const db = openStateDb(dbPath, { readonly: true });
|
|
475
|
+
try {
|
|
476
|
+
const cycleId = opts.cycleId || _currentCycleId(db);
|
|
477
|
+
if (!cycleId) return [];
|
|
478
|
+
if (opts.includeResolved) {
|
|
479
|
+
return db.prepare(
|
|
480
|
+
'SELECT * FROM blockers WHERE cycle_id = ? ORDER BY ordinal ASC'
|
|
481
|
+
).all(cycleId);
|
|
482
|
+
}
|
|
483
|
+
return db.prepare(
|
|
484
|
+
'SELECT * FROM blockers WHERE cycle_id = ? AND resolved_at IS NULL ORDER BY ordinal ASC'
|
|
485
|
+
).all(cycleId);
|
|
486
|
+
} finally {
|
|
487
|
+
db.close();
|
|
488
|
+
}
|
|
489
|
+
} catch {
|
|
490
|
+
return [];
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// setPosition - upsert the state_position row.
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Upsert the state_position row for a cycle.
|
|
500
|
+
*
|
|
501
|
+
* @param {{ cycleId: string, stage?: string, wave?: number, taskProgress?: string,
|
|
502
|
+
* status?: string, branch?: string, rawFrontmatter?: string,
|
|
503
|
+
* bodyTrailer?: string }} position
|
|
504
|
+
* @param {{ dbPath?: string, projectRoot?: string }} [opts]
|
|
505
|
+
* @returns {Promise<{ backend: string, cycleId: string }>}
|
|
506
|
+
*/
|
|
507
|
+
async function setPosition(position, opts = {}) {
|
|
508
|
+
if (BACKEND !== 'sqlite') {
|
|
509
|
+
return { backend: 'markdown', skipped: true, cycleId: position.cycleId };
|
|
510
|
+
}
|
|
511
|
+
// Load SDK async BEFORE the transaction (transaction is sync).
|
|
512
|
+
const sdk = await loadSdk();
|
|
513
|
+
const dbPath = _resolveDbPath(opts);
|
|
514
|
+
const statePath = _statePath(opts.projectRoot);
|
|
515
|
+
const db = openStateDb(dbPath);
|
|
516
|
+
try {
|
|
517
|
+
await _applyFreshnessGuard(db, statePath);
|
|
518
|
+
_dualWrite(db, statePath, position.cycleId, () => {
|
|
519
|
+
db.prepare(`
|
|
520
|
+
INSERT INTO state_position
|
|
521
|
+
(cycle_id, stage, wave, task_progress, status, branch,
|
|
522
|
+
raw_frontmatter, body_trailer, updated_at)
|
|
523
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
524
|
+
ON CONFLICT(cycle_id) DO UPDATE SET
|
|
525
|
+
stage = excluded.stage,
|
|
526
|
+
wave = excluded.wave,
|
|
527
|
+
task_progress = excluded.task_progress,
|
|
528
|
+
status = excluded.status,
|
|
529
|
+
raw_frontmatter = excluded.raw_frontmatter,
|
|
530
|
+
body_trailer = excluded.body_trailer,
|
|
531
|
+
updated_at = excluded.updated_at
|
|
532
|
+
`).run(
|
|
533
|
+
position.cycleId,
|
|
534
|
+
position.stage || null,
|
|
535
|
+
typeof position.wave === 'number' ? position.wave : null,
|
|
536
|
+
position.taskProgress || null,
|
|
537
|
+
position.status || null,
|
|
538
|
+
position.branch || null,
|
|
539
|
+
position.rawFrontmatter || null,
|
|
540
|
+
position.bodyTrailer || null,
|
|
541
|
+
new Date().toISOString(),
|
|
542
|
+
);
|
|
543
|
+
}, sdk);
|
|
544
|
+
return { backend: 'sqlite', cycleId: position.cycleId };
|
|
545
|
+
} finally {
|
|
546
|
+
db.close();
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
// getPosition - return the current state_position row.
|
|
552
|
+
// ---------------------------------------------------------------------------
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Return the most recent state_position row (by updated_at).
|
|
556
|
+
*
|
|
557
|
+
* @param {{ dbPath?: string, projectRoot?: string, cycleId?: string }} [opts]
|
|
558
|
+
* @returns {object|null}
|
|
559
|
+
*/
|
|
560
|
+
function getPosition(opts = {}) {
|
|
561
|
+
if (BACKEND !== 'sqlite') {
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
// BUG-06: wrap in try/catch — openStateDb(readonly) throws on a missing file.
|
|
565
|
+
try {
|
|
566
|
+
const dbPath = _resolveDbPath(opts);
|
|
567
|
+
const db = openStateDb(dbPath, { readonly: true });
|
|
568
|
+
try {
|
|
569
|
+
if (opts.cycleId) {
|
|
570
|
+
return db.prepare('SELECT * FROM state_position WHERE cycle_id = ?').get(opts.cycleId) || null;
|
|
571
|
+
}
|
|
572
|
+
return db.prepare(
|
|
573
|
+
'SELECT * FROM state_position ORDER BY updated_at DESC LIMIT 1'
|
|
574
|
+
).get() || null;
|
|
575
|
+
} finally {
|
|
576
|
+
db.close();
|
|
577
|
+
}
|
|
578
|
+
} catch {
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ---------------------------------------------------------------------------
|
|
584
|
+
// queryDecisions - FTS5 search (or JS fallback) over decisions.
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Query decisions by full-text search (trigram FTS5) or substring fallback.
|
|
589
|
+
*
|
|
590
|
+
* SQLite path: uses decisions_fts virtual table when available, else
|
|
591
|
+
* LIKE substring scan over body_md.
|
|
592
|
+
* Markdown path: returns [].
|
|
593
|
+
*
|
|
594
|
+
* @param {string} ftsQuery search terms
|
|
595
|
+
* @param {{ dbPath?: string, projectRoot?: string, cycleId?: string, limit?: number }} [opts]
|
|
596
|
+
* @returns {Array<object>}
|
|
597
|
+
*/
|
|
598
|
+
function queryDecisions(ftsQuery, opts = {}) {
|
|
599
|
+
if (BACKEND !== 'sqlite') {
|
|
600
|
+
return [];
|
|
601
|
+
}
|
|
602
|
+
if (typeof ftsQuery !== 'string' || !ftsQuery.trim()) return [];
|
|
603
|
+
const dbPath = _resolveDbPath(opts);
|
|
604
|
+
const db = openStateDb(dbPath, { readonly: true });
|
|
605
|
+
try {
|
|
606
|
+
const limit = opts.limit || 10;
|
|
607
|
+
const cycleId = opts.cycleId || _currentCycleId(db);
|
|
608
|
+
// Try FTS5 first, fall back to LIKE scan.
|
|
609
|
+
try {
|
|
610
|
+
const rows = db.prepare(`
|
|
611
|
+
SELECT d.* FROM decisions d
|
|
612
|
+
JOIN decisions_fts fts ON d.id = fts.id
|
|
613
|
+
WHERE decisions_fts MATCH ?
|
|
614
|
+
${cycleId ? 'AND d.cycle_id = ?' : ''}
|
|
615
|
+
ORDER BY rank
|
|
616
|
+
LIMIT ?
|
|
617
|
+
`).all(...(cycleId ? [ftsQuery, cycleId, limit] : [ftsQuery, limit]));
|
|
618
|
+
return rows;
|
|
619
|
+
} catch {
|
|
620
|
+
// FTS5 not available or query failed - fall back to LIKE scan.
|
|
621
|
+
const pattern = '%' + ftsQuery.replace(/[%_\\]/g, '\\$&') + '%';
|
|
622
|
+
if (cycleId) {
|
|
623
|
+
return db.prepare(
|
|
624
|
+
'SELECT * FROM decisions WHERE cycle_id = ? AND body_md LIKE ? ESCAPE ? ORDER BY ordinal LIMIT ?'
|
|
625
|
+
).all(cycleId, pattern, '\\', limit);
|
|
626
|
+
}
|
|
627
|
+
return db.prepare(
|
|
628
|
+
'SELECT * FROM decisions WHERE body_md LIKE ? ESCAPE ? ORDER BY ordinal LIMIT ?'
|
|
629
|
+
).all(pattern, '\\', limit);
|
|
630
|
+
}
|
|
631
|
+
} finally {
|
|
632
|
+
db.close();
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
// _currentCycleId - get the most recently active cycle_id from state_position.
|
|
638
|
+
// ---------------------------------------------------------------------------
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Return the most recent cycle_id from state_position, or null.
|
|
642
|
+
* @param {import('better-sqlite3').Database} db
|
|
643
|
+
* @returns {string|null}
|
|
644
|
+
*/
|
|
645
|
+
function _currentCycleId(db) {
|
|
646
|
+
try {
|
|
647
|
+
const row = db.prepare(
|
|
648
|
+
'SELECT cycle_id FROM state_position ORDER BY updated_at DESC LIMIT 1'
|
|
649
|
+
).get();
|
|
650
|
+
return row ? row.cycle_id : null;
|
|
651
|
+
} catch {
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ---------------------------------------------------------------------------
|
|
657
|
+
// migrate - lazy delegate to migrate-to-sqlite.cjs (Executor B).
|
|
658
|
+
// ---------------------------------------------------------------------------
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Run migration from markdown STATE.md to SQLite.
|
|
662
|
+
* Delegates to ./migrate-to-sqlite.cjs (created by Executor B).
|
|
663
|
+
* If that module is not yet present, logs a clear message and returns.
|
|
664
|
+
*
|
|
665
|
+
* @param {{ statePath?: string, dbPath?: string, projectRoot?: string,
|
|
666
|
+
* dryRun?: boolean, upsertOnly?: boolean }} [migrateOpts]
|
|
667
|
+
* @returns {{ migrated: boolean, backend: string, message?: string }}
|
|
668
|
+
*/
|
|
669
|
+
function migrate(migrateOpts = {}) {
|
|
670
|
+
if (BACKEND !== 'sqlite') {
|
|
671
|
+
return {
|
|
672
|
+
migrated: false,
|
|
673
|
+
backend: 'markdown',
|
|
674
|
+
message: 'migration is a no-op when BACKEND===markdown (better-sqlite3 not available)',
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
const mod = _requireMigrate();
|
|
678
|
+
if (!mod) {
|
|
679
|
+
return {
|
|
680
|
+
migrated: false,
|
|
681
|
+
backend: 'sqlite',
|
|
682
|
+
message: 'migrate-to-sqlite.cjs not yet available (Executor B pending)',
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
if (typeof mod.migrateToSqlite === 'function') {
|
|
686
|
+
return mod.migrateToSqlite(migrateOpts);
|
|
687
|
+
}
|
|
688
|
+
if (typeof mod.migrate === 'function') {
|
|
689
|
+
return mod.migrate(migrateOpts);
|
|
690
|
+
}
|
|
691
|
+
if (typeof mod.migrateState === 'function') {
|
|
692
|
+
return mod.migrateState(migrateOpts);
|
|
693
|
+
}
|
|
694
|
+
return {
|
|
695
|
+
migrated: false,
|
|
696
|
+
backend: 'sqlite',
|
|
697
|
+
message: 'migrate-to-sqlite.cjs loaded but expected function not found',
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// ---------------------------------------------------------------------------
|
|
702
|
+
// render - lazy delegate to render-markdown.cjs (Executor C).
|
|
703
|
+
// ---------------------------------------------------------------------------
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Re-render STATE.md from SQLite state (reverse of migration).
|
|
707
|
+
* Delegates to ./render-markdown.cjs (created by Executor C).
|
|
708
|
+
* If that module is not yet present, logs a clear message and returns null.
|
|
709
|
+
*
|
|
710
|
+
* @param {string} [projectRoot]
|
|
711
|
+
* @returns {Promise<string|null>} rendered markdown string, or null if unavailable
|
|
712
|
+
*/
|
|
713
|
+
async function render(projectRoot) {
|
|
714
|
+
if (BACKEND !== 'sqlite') {
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
const mod = _requireRender();
|
|
718
|
+
if (!mod) {
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
if (typeof mod.renderStateMarkdown !== 'function') {
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
const sdk = await loadSdk();
|
|
725
|
+
if (!sdk) {
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
const dbPath = sqlitePath(projectRoot || process.cwd());
|
|
729
|
+
const db = openStateDb(dbPath, { readonly: true });
|
|
730
|
+
try {
|
|
731
|
+
const cycleId = _currentCycleId(db);
|
|
732
|
+
return mod.renderStateMarkdown(db, cycleId, sdk);
|
|
733
|
+
} finally {
|
|
734
|
+
db.close();
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ---------------------------------------------------------------------------
|
|
739
|
+
// backendName - return the active backend string.
|
|
740
|
+
// ---------------------------------------------------------------------------
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Return the active backend name: 'sqlite' or 'markdown'.
|
|
744
|
+
* Mirrors the pattern from instinct-store.cjs backendName().
|
|
745
|
+
* @returns {'sqlite'|'markdown'}
|
|
746
|
+
*/
|
|
747
|
+
function backendName() {
|
|
748
|
+
return BACKEND;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ---------------------------------------------------------------------------
|
|
752
|
+
// Exports
|
|
753
|
+
// ---------------------------------------------------------------------------
|
|
754
|
+
|
|
755
|
+
module.exports = {
|
|
756
|
+
appendDecision,
|
|
757
|
+
getDecisions,
|
|
758
|
+
appendBlocker,
|
|
759
|
+
getBlockers,
|
|
760
|
+
setPosition,
|
|
761
|
+
getPosition,
|
|
762
|
+
queryDecisions,
|
|
763
|
+
migrate,
|
|
764
|
+
render,
|
|
765
|
+
backendName,
|
|
766
|
+
};
|