@hegemonart/get-design-done 1.57.0 → 1.57.2

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.
Files changed (123) hide show
  1. package/.claude-plugin/marketplace.json +26 -41
  2. package/.claude-plugin/plugin.json +23 -48
  3. package/CHANGELOG.md +119 -0
  4. package/README.md +166 -511
  5. package/SKILL.md +2 -0
  6. package/agents/README.md +33 -36
  7. package/agents/a11y-mapper.md +3 -3
  8. package/agents/component-benchmark-harvester.md +6 -6
  9. package/agents/component-benchmark-synthesizer.md +3 -3
  10. package/agents/compose-executor.md +3 -3
  11. package/agents/cost-forecaster.md +2 -2
  12. package/agents/design-auditor.md +7 -7
  13. package/agents/design-authority-watcher.md +15 -15
  14. package/agents/design-context-builder.md +4 -4
  15. package/agents/design-context-checker-gate.md +1 -1
  16. package/agents/design-discussant.md +2 -2
  17. package/agents/design-doc-writer.md +1 -1
  18. package/agents/design-executor.md +2 -2
  19. package/agents/design-figma-writer.md +2 -2
  20. package/agents/design-fixer.md +7 -7
  21. package/agents/design-integration-checker-gate.md +1 -1
  22. package/agents/design-integration-checker.md +1 -1
  23. package/agents/design-paper-writer.md +3 -3
  24. package/agents/design-pencil-writer.md +1 -1
  25. package/agents/design-planner.md +21 -0
  26. package/agents/design-reflector.md +39 -39
  27. package/agents/design-research-synthesizer.md +1 -0
  28. package/agents/design-start-writer.md +1 -1
  29. package/agents/design-update-checker.md +5 -5
  30. package/agents/design-verifier-gate.md +1 -1
  31. package/agents/design-verifier.md +52 -48
  32. package/agents/ds-generator.md +2 -2
  33. package/agents/ds-migration-planner.md +4 -4
  34. package/agents/email-executor.md +9 -9
  35. package/agents/experiment-result-ingester.md +3 -3
  36. package/agents/flutter-executor.md +5 -5
  37. package/agents/gdd-graph-refresh.md +3 -3
  38. package/agents/gdd-intel-updater.md +2 -2
  39. package/agents/motion-mapper.md +2 -2
  40. package/agents/motion-verifier.md +4 -4
  41. package/agents/pdf-executor.md +8 -8
  42. package/agents/perf-analyzer.md +17 -17
  43. package/agents/pr-commenter.md +9 -9
  44. package/agents/prototype-gate.md +2 -2
  45. package/agents/quality-gate-runner.md +1 -1
  46. package/agents/rollout-coordinator.md +3 -3
  47. package/agents/swift-executor.md +4 -4
  48. package/agents/ticket-sync-agent.md +6 -6
  49. package/agents/user-research-synthesizer.md +2 -2
  50. package/connections/connections.md +44 -45
  51. package/connections/cursor.md +73 -0
  52. package/connections/preview.md +3 -3
  53. package/dist/claude-code/.claude/skills/cache-manager/SKILL.md +3 -3
  54. package/dist/claude-code/.claude/skills/cache-manager/cache-policy.md +1 -1
  55. package/dist/claude-code/.claude/skills/design/SKILL.md +19 -0
  56. package/dist/claude-code/.claude/skills/explore/SKILL.md +11 -0
  57. package/dist/claude-code/.claude/skills/figma-write/SKILL.md +13 -2
  58. package/dist/claude-code/.claude/skills/paper-write/SKILL.md +54 -0
  59. package/dist/claude-code/.claude/skills/pencil-write/SKILL.md +54 -0
  60. package/dist/claude-code/.claude/skills/report-issue/SKILL.md +2 -2
  61. package/dist/claude-code/.claude/skills/router/SKILL.md +2 -2
  62. package/dist/claude-code/.claude/skills/verify/verify-procedure.md +10 -11
  63. package/dist/claude-code/.claude/skills/warm-cache/SKILL.md +1 -1
  64. package/hooks/budget-enforcer.ts +5 -4
  65. package/hooks/first-run-nudge.cjs +171 -0
  66. package/hooks/gdd-intel-trigger.js +243 -0
  67. package/hooks/gdd-mcp-circuit-breaker.js +62 -7
  68. package/hooks/gdd-precompact-snapshot.js +50 -29
  69. package/hooks/gdd-protected-paths.js +175 -20
  70. package/hooks/gdd-read-injection-scanner.ts +9 -1
  71. package/hooks/gdd-risk-gate.js +110 -8
  72. package/hooks/gdd-sessionstart-recap.js +59 -24
  73. package/hooks/hooks.json +13 -4
  74. package/hooks/inject-using-gdd.cjs +188 -0
  75. package/hooks/update-check.cjs +511 -0
  76. package/package.json +9 -2
  77. package/reference/STATE-TEMPLATE.md +10 -13
  78. package/reference/audit-scoring.md +1 -1
  79. package/reference/cache-tier-doctrine.md +46 -0
  80. package/reference/config-schema.md +9 -9
  81. package/reference/i18n.md +1 -1
  82. package/reference/intel-schema.md +37 -2
  83. package/reference/meta-rules.md +4 -4
  84. package/reference/model-tiers.md +2 -2
  85. package/reference/registry.json +101 -94
  86. package/reference/runtime-models.md +11 -1
  87. package/reference/shared-preamble.md +13 -14
  88. package/reference/skill-graph.md +24 -1
  89. package/scripts/bootstrap.cjs +373 -0
  90. package/scripts/injection-patterns.cjs +58 -0
  91. package/scripts/lib/apply-reflections/incubator-proposals.cjs +57 -26
  92. package/scripts/lib/design-search.cjs +20 -2
  93. package/scripts/lib/install/converters/codex-plugin.cjs +5 -2
  94. package/scripts/lib/install/converters/cursor.cjs +20 -0
  95. package/scripts/lib/issue-reporter/report-flow.cjs +1 -1
  96. package/scripts/lib/manifest/skills.json +80 -13
  97. package/scripts/lib/state/migrate-to-sqlite.cjs +23 -7
  98. package/scripts/lib/state/query-surface.cjs +86 -16
  99. package/scripts/lib/state/render-markdown.cjs +26 -14
  100. package/scripts/lib/state/state-store.cjs +141 -68
  101. package/sdk/cli/commands/stage.ts +17 -0
  102. package/sdk/cli/index.js +21 -1
  103. package/sdk/dashboard/data/_pkg-root.cjs +4 -4
  104. package/sdk/dashboard/data/risk-surface.cjs +54 -19
  105. package/sdk/dashboard/tui/index.cjs +28 -2
  106. package/sdk/mcp/gdd-state/server.js +7 -1
  107. package/sdk/state/index.ts +11 -1
  108. package/skills/cache-manager/SKILL.md +3 -3
  109. package/skills/cache-manager/cache-policy.md +1 -1
  110. package/skills/design/SKILL.md +19 -0
  111. package/skills/explore/SKILL.md +11 -0
  112. package/skills/figma-write/SKILL.md +13 -2
  113. package/skills/paper-write/SKILL.md +54 -0
  114. package/skills/pencil-write/SKILL.md +54 -0
  115. package/skills/report-issue/SKILL.md +2 -2
  116. package/skills/router/SKILL.md +2 -2
  117. package/skills/verify/verify-procedure.md +10 -11
  118. package/skills/warm-cache/SKILL.md +1 -1
  119. package/hooks/first-run-nudge.sh +0 -82
  120. package/hooks/inject-using-gdd.sh +0 -72
  121. package/hooks/update-check.sh +0 -251
  122. package/scripts/lib/audit-aggregator/index.cjs +0 -219
  123. package/scripts/lib/hedge-ensemble.cjs +0 -217
@@ -46,7 +46,8 @@ function _findPackageRoot(startDir) {
46
46
  const PKG_ROOT = _findPackageRoot(__dirname);
47
47
 
48
48
  // ---------------------------------------------------------------------------
49
- // Lazy-require state-backend.cjs (Executor A).
49
+ // Lazy-require state-backend.cjs (loaded on first call so optional better-sqlite3
50
+ // binding does not crash module load).
50
51
  // ---------------------------------------------------------------------------
51
52
  let _backend = null;
52
53
  function _requireBackend() {
@@ -65,7 +66,7 @@ function _requireBackend() {
65
66
  }
66
67
 
67
68
  // ---------------------------------------------------------------------------
68
- // Lazy-require migrate-to-sqlite.cjs (Executor B) - used by recover().
69
+ // Lazy-require migrate-to-sqlite.cjs - used by recover().
69
70
  // ---------------------------------------------------------------------------
70
71
  let _migrate = null;
71
72
  function _requireMigrate() {
@@ -128,8 +129,13 @@ function _firstToken(sql) {
128
129
  }
129
130
 
130
131
  /**
131
- * Assert that the SQL query is a safe readonly SELECT.
132
- * Throws with a descriptive message when the first token is denied or not SELECT.
132
+ * Assert that the SQL query is a safe readonly SELECT (or CTE: WITH ... SELECT).
133
+ * Throws with a descriptive message when the first token is denied or not SELECT/WITH.
134
+ *
135
+ * BUG-11: allow a leading WITH token for CTEs (WITH ... SELECT ...).
136
+ * The engine-level readonly connection already blocks any write CTE.
137
+ * We keep blocking all other non-SELECT first tokens.
138
+ *
133
139
  * @param {string} sql
134
140
  */
135
141
  function _assertReadonly(sql) {
@@ -142,7 +148,8 @@ function _assertReadonly(sql) {
142
148
  `query-surface: statement type '${token}' is not allowed (denylist). Only SELECT is permitted.`
143
149
  );
144
150
  }
145
- if (token !== 'SELECT') {
151
+ // Allow WITH for CTEs (WITH ... SELECT ...) — engine readonly blocks any write CTE.
152
+ if (token !== 'SELECT' && token !== 'WITH') {
146
153
  throw new Error(
147
154
  `query-surface: first token '${token}' is not SELECT. Only SELECT queries are permitted.`
148
155
  );
@@ -196,6 +203,42 @@ function query(sql, opts = {}) {
196
203
  }
197
204
  }
198
205
 
206
+ // ---------------------------------------------------------------------------
207
+ // _safeBackup(srcPath, bakPath) - H5 backup-guard.
208
+ //
209
+ // Copy srcPath to bakPath, then verify the backup exists AND is non-empty
210
+ // AFTER the copy. Returns true only when the backup is a faithful non-empty
211
+ // copy of the source. Callers MUST check the return value before unlinking
212
+ // the source - the dangerous pattern is `copy → unconditional unlink`, where
213
+ // a silent copy failure (or zero-byte destination) means the unlink deletes
214
+ // the only remaining data.
215
+ //
216
+ // Defensive: never throws. Returns false on any error or empty backup.
217
+ //
218
+ // @param {string} srcPath path to source file (must exist)
219
+ // @param {string} bakPath path for the backup (created/overwritten)
220
+ // @returns {boolean} true iff bakPath exists and is non-empty after copy
221
+ // ---------------------------------------------------------------------------
222
+
223
+ function _safeBackup(srcPath, bakPath) {
224
+ try {
225
+ fs.copyFileSync(srcPath, bakPath);
226
+ } catch {
227
+ return false;
228
+ }
229
+ // Post-copy verification: the backup must EXIST and be NON-EMPTY.
230
+ // copyFileSync can silently produce a 0-byte file in some failure modes
231
+ // (interrupted IO, full disk after open). An empty backup is not a backup;
232
+ // unlinking the source after one would destroy the data.
233
+ try {
234
+ const st = fs.statSync(bakPath);
235
+ if (!st.isFile() || st.size === 0) return false;
236
+ return true;
237
+ } catch {
238
+ return false;
239
+ }
240
+ }
241
+
199
242
  // ---------------------------------------------------------------------------
200
243
  // rotateBak(dbPath) - shift .bak.0..9, cap at 10.
201
244
  // ---------------------------------------------------------------------------
@@ -241,12 +284,10 @@ function backupCycle(opts = {}) {
241
284
  }
242
285
  rotateBak(dbPath);
243
286
  const bak0 = `${dbPath}.bak.0`;
244
- try {
245
- fs.copyFileSync(dbPath, bak0);
287
+ if (_safeBackup(dbPath, bak0)) {
246
288
  return { backed_up: true, path: bak0 };
247
- } catch (err) {
248
- return { backed_up: false, message: `backupCycle: copy failed: ${err.message}` };
249
289
  }
290
+ return { backed_up: false, message: `backupCycle: copy failed or backup is empty at ${bak0}` };
250
291
  }
251
292
 
252
293
  // ---------------------------------------------------------------------------
@@ -276,9 +317,18 @@ function demigrate(opts = {}) {
276
317
  };
277
318
  }
278
319
  // Take a backup before removing.
320
+ // H5 backup-guard: only unlink when the backup is a faithful non-empty copy.
321
+ // If the copy failed (or produced a 0-byte file), refuse to unlink the source -
322
+ // we'd be deleting the only remaining data.
279
323
  rotateBak(dbPath);
280
324
  const bak0 = `${dbPath}.bak.0`;
281
- try { fs.copyFileSync(dbPath, bak0); } catch { /* best-effort backup */ }
325
+ if (!_safeBackup(dbPath, bak0)) {
326
+ return {
327
+ demigrated: false,
328
+ message: `demigrate: refusing to remove ${dbPath} - backup at ${bak0} ` +
329
+ `is missing or empty after copyFileSync (would lose data).`,
330
+ };
331
+ }
282
332
  try {
283
333
  fs.unlinkSync(dbPath);
284
334
  } catch (err) {
@@ -305,10 +355,13 @@ function demigrate(opts = {}) {
305
355
  * 3. Invoke migrate-to-sqlite with force:true to rebuild from markdown.
306
356
  * 4. Run integrity_check on the new database.
307
357
  *
358
+ * BUG-03/08: recover() is now async — it awaits migrateToSqlite() so that
359
+ * state.sqlite actually exists before the integrity check is run.
360
+ *
308
361
  * @param {{ projectRoot?: string, dbPath?: string }} [opts]
309
- * @returns {{ recovered: boolean, message: string, integrity?: boolean }}
362
+ * @returns {Promise<{ recovered: boolean, message: string, integrity?: boolean }>}
310
363
  */
311
- function recover(opts = {}) {
364
+ async function recover(opts = {}) {
312
365
  const backend = _requireBackend();
313
366
  if (!backend || backend.BACKEND !== 'sqlite') {
314
367
  return {
@@ -321,10 +374,23 @@ function recover(opts = {}) {
321
374
  const dbPath = opts.dbPath || backend.sqlitePath(opts.projectRoot || process.cwd());
322
375
 
323
376
  // Step 1: Rotate existing (possibly corrupt) file to .bak.0.
377
+ // H5 backup-guard: only unlink the source after a verified non-empty backup.
378
+ // For recover() the source MAY already be corrupt - so an empty/failed backup
379
+ // is still significant signal. We refuse to unlink when the backup is missing
380
+ // OR zero bytes, so the operator retains a copy of the corrupt file for
381
+ // diagnostics. The caller can manually delete and retry once the backup
382
+ // location is writable.
324
383
  if (fs.existsSync(dbPath)) {
325
384
  rotateBak(dbPath);
326
385
  const bak0 = `${dbPath}.bak.0`;
327
- try { fs.copyFileSync(dbPath, bak0); } catch { /* best-effort */ }
386
+ if (!_safeBackup(dbPath, bak0)) {
387
+ return {
388
+ recovered: false,
389
+ message: `recover: refusing to remove ${dbPath} - backup at ${bak0} ` +
390
+ `is missing or empty after copyFileSync (would lose corrupt file ` +
391
+ `before rebuild). Resolve disk/permission issues and retry.`,
392
+ };
393
+ }
328
394
  try { fs.unlinkSync(dbPath); } catch (err) {
329
395
  return { recovered: false, message: `recover: could not remove corrupt ${dbPath}: ${err.message}` };
330
396
  }
@@ -335,7 +401,7 @@ function recover(opts = {}) {
335
401
  if (!migrate) {
336
402
  return {
337
403
  recovered: false,
338
- message: 'recover: migrate-to-sqlite.cjs not available (Executor B not yet present). ' +
404
+ message: 'recover: migrate-to-sqlite.cjs could not be loaded (require failed). ' +
339
405
  'Cannot rebuild SQLite from markdown.',
340
406
  };
341
407
  }
@@ -343,13 +409,16 @@ function recover(opts = {}) {
343
409
  let migrateResult = null;
344
410
  try {
345
411
  if (typeof migrate.migrateToSqlite === 'function') {
346
- migrateResult = migrate.migrateToSqlite({
412
+ // BUG-03/08: await the async migrateToSqlite so state.sqlite is written before
413
+ // the integrity check below. Without await, recover() returned before the DB
414
+ // existed, causing integrity:false on every call.
415
+ migrateResult = await migrate.migrateToSqlite({
347
416
  projectRoot: opts.projectRoot,
348
417
  dbPath,
349
418
  force: true,
350
419
  });
351
420
  } else if (typeof migrate.migrate === 'function') {
352
- migrateResult = migrate.migrate({ projectRoot: opts.projectRoot, dbPath, force: true });
421
+ migrateResult = await migrate.migrate({ projectRoot: opts.projectRoot, dbPath, force: true });
353
422
  } else {
354
423
  return { recovered: false, message: 'recover: migrate-to-sqlite.cjs has no recognized export.' };
355
424
  }
@@ -387,5 +456,6 @@ module.exports = {
387
456
  // Expose internals for testing.
388
457
  _assertReadonly,
389
458
  _firstToken,
459
+ _safeBackup,
390
460
  DENIED_TOKENS,
391
461
  };
@@ -376,21 +376,33 @@ function renderStateMarkdown(db, cycle_id, sdk) {
376
376
  }
377
377
 
378
378
  // --- blockers raw_body ---
379
- // Only unresolved blockers go in the STATE.md <blockers> block.
379
+ // activeBlockers is used both for raw_bodies reconstruction and the blockers state array below.
380
380
  const activeBlockers = blockerRows.filter((r) => !r.resolved_at);
381
- if (activeBlockers.length > 0) {
382
- const lines = activeBlockers.map((row) => {
383
- // ALWAYS prefer raw_line for blockers (date-format hazard).
384
- if (row.raw_line) return row.raw_line;
385
- return canonicalBlocker({ stage: row.stage || '', date: row.date || '', text: row.body_md || '' });
386
- });
387
- raw_bodies.blockers = lines.join('\n');
388
- } else if ('blockers' in blockGaps) {
389
- raw_bodies.blockers = blockRawBodies['blockers'] !== undefined
390
- ? blockRawBodies['blockers']
391
- : '';
392
- } else if (blockRawBodies['blockers'] !== undefined) {
393
- raw_bodies.blockers = blockRawBodies['blockers'];
381
+
382
+ // BUG-09: when _block_meta stores a non-null raw_body for 'blockers', emit it
383
+ // verbatim (like unstructured blocks). This preserves comment lines inside
384
+ // <blockers> that would otherwise be silently dropped when rebuilding from rows.
385
+ //
386
+ // Fall through to row-reconstruction only when raw_body is absent (null), which
387
+ // happens after an appendBlocker() call that doesn't update _block_meta.raw_body.
388
+ const blockersRawBody = blockRawBodies['blockers'];
389
+ if (blockersRawBody !== undefined && blockersRawBody !== null) {
390
+ // Verbatim round-trip: emit stored raw_body (preserves comments).
391
+ raw_bodies.blockers = blockersRawBody;
392
+ } else {
393
+ // Reconstruct from rows (no stored raw_body).
394
+ // Only unresolved blockers go in the STATE.md <blockers> block.
395
+ if (activeBlockers.length > 0) {
396
+ const lines = activeBlockers.map((row) => {
397
+ // ALWAYS prefer raw_line for blockers (date-format hazard).
398
+ if (row.raw_line) return row.raw_line;
399
+ return canonicalBlocker({ stage: row.stage || '', date: row.date || '', text: row.body_md || '' });
400
+ });
401
+ raw_bodies.blockers = lines.join('\n');
402
+ } else if ('blockers' in blockGaps) {
403
+ raw_bodies.blockers = '';
404
+ }
405
+ // If no blockGaps entry for blockers, raw_bodies.blockers stays null (block omitted).
394
406
  }
395
407
 
396
408
  // --- unstructured blocks: verbatim from _block_meta.raw_body ---
@@ -15,8 +15,8 @@
15
15
  * setPosition(position, opts) - upsert state_position
16
16
  * getPosition(opts) - return current position
17
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)
18
+ * migrate(migrateOpts) - lazy-require migrate-to-sqlite.cjs
19
+ * render(projectRoot) - lazy-require render-markdown.cjs
20
20
  * backendName() - return BACKEND string
21
21
  *
22
22
  * R7 dual-write: every SQLite MUTATION wraps writeStructured() + renderMarkdown()
@@ -56,7 +56,7 @@ function findPackageRoot(startDir) {
56
56
  try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); } catch { pkg = null; }
57
57
  if (pkg) {
58
58
  if (firstWithPkg === null) firstWithPkg = dir;
59
- if (pkg.name === 'get-design-done') return dir;
59
+ if (pkg.name === '@hegemonart/get-design-done') return dir;
60
60
  }
61
61
  const parent = path.dirname(dir);
62
62
  if (parent === dir) break;
@@ -103,18 +103,18 @@ async function loadSdk() {
103
103
  }
104
104
 
105
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.
106
+ // Lazy-require helpers for the migrate and render sibling modules (PINNED names).
107
+ // Loaded on first call so a missing better-sqlite3 binding does not crash module load.
108
108
  // ---------------------------------------------------------------------------
109
109
 
110
- /** @type {any|null} Cached migrate-to-sqlite module (Executor B). */
110
+ /** @type {any|null} Cached migrate-to-sqlite module. */
111
111
  let _migrateModule = null;
112
- /** @type {any|null} Cached render-markdown module (Executor C). */
112
+ /** @type {any|null} Cached render-markdown module. */
113
113
  let _renderModule = null;
114
114
 
115
115
  /**
116
- * Lazy-require ./migrate-to-sqlite.cjs (Executor B's module).
117
- * Returns null if not yet available.
116
+ * Lazy-require ./migrate-to-sqlite.cjs.
117
+ * Returns null if the require fails (e.g. better-sqlite3 missing).
118
118
  * @returns {any|null}
119
119
  */
120
120
  function _requireMigrate() {
@@ -128,8 +128,8 @@ function _requireMigrate() {
128
128
  }
129
129
 
130
130
  /**
131
- * Lazy-require ./render-markdown.cjs (Executor C's module).
132
- * Returns null if not yet available.
131
+ * Lazy-require ./render-markdown.cjs.
132
+ * Returns null if the require fails.
133
133
  * @returns {any|null}
134
134
  */
135
135
  function _requireRender() {
@@ -198,12 +198,12 @@ function _onDiskSha(statePath) {
198
198
  * If they differ, the user has hand-edited STATE.md since the last SQLite write.
199
199
  * In that case, run a mini-migration (upsert SQLite from markdown) before proceeding.
200
200
  *
201
- * This is called BEFORE the db.transaction() so it can be async.
201
+ * This is ASYNC - must be awaited before entering the db.transaction().
202
202
  *
203
203
  * @param {import('better-sqlite3').Database} db
204
204
  * @param {string} statePath absolute path to STATE.md
205
205
  */
206
- function _applyFreshnessGuard(db, statePath) {
206
+ async function _applyFreshnessGuard(db, statePath) {
207
207
  try {
208
208
  const onDisk = _onDiskSha(statePath);
209
209
  if (onDisk === null) return; // STATE.md doesn't exist yet - skip
@@ -211,20 +211,21 @@ function _applyFreshnessGuard(db, statePath) {
211
211
  const stored = metaRow ? metaRow.value : null;
212
212
  if (stored === onDisk) return; // No drift - proceed normally
213
213
  // Drift detected: user hand-edited STATE.md.
214
- // Run mini-migration to upsert SQLite from the current markdown state.
214
+ // Run mini-migration (upsertOnly) to fold the hand-edit into SQLite before
215
+ // proceeding with the intended mutation.
215
216
  const migrate = _requireMigrate();
216
217
  if (migrate && typeof migrate.migrateToSqlite === 'function') {
217
218
  try {
218
- // migrateToSqlite is async but we handle it best-effort here.
219
- // For the freshness guard we do a best-effort synchronous approach:
220
- // if the module is loaded, call it. If it's async we can't await here
221
- // (this is called outside the transaction but may be called from sync context).
222
- // The mini-migration is best-effort.
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
223
  } catch {
224
224
  // Migration failed - log and continue rather than blocking the write.
225
+ // Still update the sha to prevent infinite re-triggering.
225
226
  }
226
227
  }
227
- // Update stored sha to prevent re-triggering on every call even if migrate fails.
228
+ // Update stored sha to reflect the current on-disk content.
228
229
  db.prepare('INSERT OR REPLACE INTO _meta(key, value) VALUES (?, ?)').run('last_render_sha256', onDisk);
229
230
  } catch {
230
231
  /* Freshness guard must never break a write path */
@@ -279,7 +280,7 @@ function _dualWrite(db, statePath, cycleId, writeStructured, sdk) {
279
280
  db.prepare('INSERT OR REPLACE INTO _meta(key, value) VALUES (?, ?)').run('last_render_sha256', newSha);
280
281
  }
281
282
  // If render module not available or sdk not loaded, still write the structured data.
282
- // STATE.md will be stale until Executor C's render module is present.
283
+ // STATE.md will be stale until the render module loads successfully.
283
284
  });
284
285
  txn();
285
286
  }
@@ -326,8 +327,12 @@ async function appendDecision(decision, opts = {}) {
326
327
  const statePath = _statePath(opts.projectRoot);
327
328
  const db = openStateDb(dbPath);
328
329
  try {
329
- _applyFreshnessGuard(db, statePath);
330
+ await _applyFreshnessGuard(db, statePath);
330
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
+ }
331
336
  _dualWrite(db, statePath, cycleId, () => {
332
337
  db.prepare(`
333
338
  INSERT INTO decisions
@@ -349,6 +354,17 @@ async function appendDecision(decision, opts = {}) {
349
354
  decision.rawLine || null,
350
355
  decision.createdAt || new Date().toISOString(),
351
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 */ }
352
368
  }, sdk);
353
369
  return { backend: 'sqlite', id: decision.id };
354
370
  } finally {
@@ -373,16 +389,21 @@ function getDecisions(opts = {}) {
373
389
  if (BACKEND !== 'sqlite') {
374
390
  return [];
375
391
  }
376
- const dbPath = _resolveDbPath(opts);
377
- const db = openStateDb(dbPath, { readonly: true });
392
+ // BUG-06: wrap in try/catch — openStateDb(readonly) throws on a missing file.
378
393
  try {
379
- const cycleId = opts.cycleId || _currentCycleId(db);
380
- if (!cycleId) return [];
381
- return db.prepare(
382
- 'SELECT * FROM decisions WHERE cycle_id = ? ORDER BY ordinal ASC'
383
- ).all(cycleId);
384
- } finally {
385
- db.close();
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 [];
386
407
  }
387
408
  }
388
409
 
@@ -408,7 +429,7 @@ async function appendBlocker(blocker, opts = {}) {
408
429
  const statePath = _statePath(opts.projectRoot);
409
430
  const db = openStateDb(dbPath);
410
431
  try {
411
- _applyFreshnessGuard(db, statePath);
432
+ await _applyFreshnessGuard(db, statePath);
412
433
  const cycleId = blocker.cycleId || _currentCycleId(db);
413
434
  let rowid = null;
414
435
  _dualWrite(db, statePath, cycleId, () => {
@@ -447,21 +468,26 @@ function getBlockers(opts = {}) {
447
468
  if (BACKEND !== 'sqlite') {
448
469
  return [];
449
470
  }
450
- const dbPath = _resolveDbPath(opts);
451
- const db = openStateDb(dbPath, { readonly: true });
471
+ // BUG-06: wrap in try/catch — openStateDb(readonly) throws on a missing file.
452
472
  try {
453
- const cycleId = opts.cycleId || _currentCycleId(db);
454
- if (!cycleId) return [];
455
- if (opts.includeResolved) {
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
+ }
456
483
  return db.prepare(
457
- 'SELECT * FROM blockers WHERE cycle_id = ? ORDER BY ordinal ASC'
484
+ 'SELECT * FROM blockers WHERE cycle_id = ? AND resolved_at IS NULL ORDER BY ordinal ASC'
458
485
  ).all(cycleId);
486
+ } finally {
487
+ db.close();
459
488
  }
460
- return db.prepare(
461
- 'SELECT * FROM blockers WHERE cycle_id = ? AND resolved_at IS NULL ORDER BY ordinal ASC'
462
- ).all(cycleId);
463
- } finally {
464
- db.close();
489
+ } catch {
490
+ return [];
465
491
  }
466
492
  }
467
493
 
@@ -488,7 +514,7 @@ async function setPosition(position, opts = {}) {
488
514
  const statePath = _statePath(opts.projectRoot);
489
515
  const db = openStateDb(dbPath);
490
516
  try {
491
- _applyFreshnessGuard(db, statePath);
517
+ await _applyFreshnessGuard(db, statePath);
492
518
  _dualWrite(db, statePath, position.cycleId, () => {
493
519
  db.prepare(`
494
520
  INSERT INTO state_position
@@ -535,17 +561,22 @@ function getPosition(opts = {}) {
535
561
  if (BACKEND !== 'sqlite') {
536
562
  return null;
537
563
  }
538
- const dbPath = _resolveDbPath(opts);
539
- const db = openStateDb(dbPath, { readonly: true });
564
+ // BUG-06: wrap in try/catch — openStateDb(readonly) throws on a missing file.
540
565
  try {
541
- if (opts.cycleId) {
542
- return db.prepare('SELECT * FROM state_position WHERE cycle_id = ?').get(opts.cycleId) || null;
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();
543
577
  }
544
- return db.prepare(
545
- 'SELECT * FROM state_position ORDER BY updated_at DESC LIMIT 1'
546
- ).get() || null;
547
- } finally {
548
- db.close();
578
+ } catch {
579
+ return null;
549
580
  }
550
581
  }
551
582
 
@@ -623,19 +654,60 @@ function _currentCycleId(db) {
623
654
  }
624
655
 
625
656
  // ---------------------------------------------------------------------------
626
- // migrate - lazy delegate to migrate-to-sqlite.cjs (Executor B).
657
+ // migrate - async lazy delegate to migrate-to-sqlite.cjs.
627
658
  // ---------------------------------------------------------------------------
628
659
 
629
660
  /**
630
- * Run migration from markdown STATE.md to SQLite.
631
- * Delegates to ./migrate-to-sqlite.cjs (created by Executor B).
632
- * If that module is not yet present, logs a clear message and returns.
661
+ * Run migration from markdown `.design/STATE.md` to the SQLite state database.
662
+ *
663
+ * This function is the public store-level entry point for the
664
+ * `--migrate-state` flow. It lazy-loads `./migrate-to-sqlite.cjs` on first call
665
+ * and delegates to whichever export it exposes (`migrateToSqlite`,
666
+ * `migrate`, or `migrateState`), in priority order. The underlying
667
+ * `migrateToSqlite` is itself async (it dynamically imports
668
+ * `sdk/state/parser.ts` and uses node:fs/promises for IO), so this wrapper
669
+ * is async and always returns a Promise.
670
+ *
671
+ * The function NEVER throws on infrastructure failures:
672
+ * - `BACKEND === 'markdown'` (no better-sqlite3) → resolves with
673
+ * `{ migrated: false, backend: 'markdown', message: ... }`.
674
+ * - `require('./migrate-to-sqlite.cjs')` failed → resolves with
675
+ * `{ migrated: false, backend: 'sqlite', message: ... }`.
676
+ * - The delegate module loaded but exposes no recognized export → resolves
677
+ * with `{ migrated: false, backend: 'sqlite', message: ... }`.
678
+ *
679
+ * Errors thrown by the delegate (parser failure, schema mismatch, etc.) are
680
+ * propagated as a rejected Promise - callers should `await` and handle.
681
+ *
682
+ * Idempotent: calling `migrate()` repeatedly on a clean database is safe
683
+ * (the underlying migration uses `INSERT ... ON CONFLICT ... DO UPDATE`).
684
+ * Migration is opt-in: the delegate refuses to write unless `force:true` or
685
+ * the CLI `--migrate-state` flag is set (a notice is returned instead).
686
+ *
687
+ * Dual-channel result shapes:
688
+ * - markdown floor: { migrated: false, backend: 'markdown', message }
689
+ * - sqlite path: { migrated: boolean, tables: {...counts}, dryRun,
690
+ * skipped, reason }
691
+ * The caller MUST inspect `migrated` (the boolean) — never `backend` alone —
692
+ * to decide whether the operation actually performed writes.
633
693
  *
634
- * @param {{ statePath?: string, dbPath?: string, projectRoot?: string,
635
- * dryRun?: boolean, upsertOnly?: boolean }} [migrateOpts]
636
- * @returns {{ migrated: boolean, backend: string, message?: string }}
694
+ * @async
695
+ * @param {object} [migrateOpts] options forwarded to the delegate
696
+ * @param {string} [migrateOpts.statePath] explicit path to STATE.md
697
+ * @param {string} [migrateOpts.dbPath] explicit path to state.sqlite
698
+ * @param {string} [migrateOpts.projectRoot] project root for path lookup
699
+ * @param {boolean} [migrateOpts.dryRun] wrap writes in BEGIN/ROLLBACK
700
+ * @param {boolean} [migrateOpts.force] same as `--migrate-state` flag
701
+ * @param {boolean} [migrateOpts.upsertOnly] re-parse + UPSERT without wiping
702
+ * unrelated rows (used by the R8
703
+ * freshness guard)
704
+ * @returns {Promise<{ migrated: boolean, backend?: string, message?: string,
705
+ * tables?: object, dryRun?: boolean, skipped?: boolean, reason?: string }>}
706
+ * Resolves with the migration result; rejects only on delegate exceptions.
707
+ * @see migrate-to-sqlite.cjs for the underlying transactional implementation
708
+ * @see render for the reverse direction (SQLite → STATE.md)
637
709
  */
638
- function migrate(migrateOpts = {}) {
710
+ async function migrate(migrateOpts = {}) {
639
711
  if (BACKEND !== 'sqlite') {
640
712
  return {
641
713
  migrated: false,
@@ -648,17 +720,18 @@ function migrate(migrateOpts = {}) {
648
720
  return {
649
721
  migrated: false,
650
722
  backend: 'sqlite',
651
- message: 'migrate-to-sqlite.cjs not yet available (Executor B pending)',
723
+ message: 'migrate-to-sqlite.cjs could not be loaded (require failed)',
652
724
  };
653
725
  }
726
+ // Delegate is async; await so callers see the resolved result, not a Promise.
654
727
  if (typeof mod.migrateToSqlite === 'function') {
655
- return mod.migrateToSqlite(migrateOpts);
728
+ return await mod.migrateToSqlite(migrateOpts);
656
729
  }
657
730
  if (typeof mod.migrate === 'function') {
658
- return mod.migrate(migrateOpts);
731
+ return await mod.migrate(migrateOpts);
659
732
  }
660
733
  if (typeof mod.migrateState === 'function') {
661
- return mod.migrateState(migrateOpts);
734
+ return await mod.migrateState(migrateOpts);
662
735
  }
663
736
  return {
664
737
  migrated: false,
@@ -668,13 +741,13 @@ function migrate(migrateOpts = {}) {
668
741
  }
669
742
 
670
743
  // ---------------------------------------------------------------------------
671
- // render - lazy delegate to render-markdown.cjs (Executor C).
744
+ // render - lazy delegate to render-markdown.cjs.
672
745
  // ---------------------------------------------------------------------------
673
746
 
674
747
  /**
675
748
  * Re-render STATE.md from SQLite state (reverse of migration).
676
- * Delegates to ./render-markdown.cjs (created by Executor C).
677
- * If that module is not yet present, logs a clear message and returns null.
749
+ * Delegates to ./render-markdown.cjs.
750
+ * If that module cannot be loaded, returns null silently.
678
751
  *
679
752
  * @param {string} [projectRoot]
680
753
  * @returns {Promise<string|null>} rendered markdown string, or null if unavailable
@@ -110,6 +110,18 @@ const VALID_STAGE_NAMES = new Set<string>([
110
110
  'discuss',
111
111
  ]);
112
112
 
113
+ // Names of top-level `gdd-sdk` subcommands that users sometimes try to invoke
114
+ // as stages (e.g. `gdd-sdk stage audit`). When a stage name collides with one,
115
+ // surface a "did you mean" hint pointing at the real invocation.
116
+ const TOP_LEVEL_COMMANDS_OFTEN_CONFUSED_FOR_STAGES = new Set<string>([
117
+ 'audit',
118
+ 'build',
119
+ 'dashboard',
120
+ 'init',
121
+ 'query',
122
+ 'run',
123
+ ]);
124
+
113
125
  export async function stageCommand(
114
126
  args: ParsedArgs,
115
127
  deps: StageCommandDeps = {},
@@ -134,6 +146,11 @@ export async function stageCommand(
134
146
  stderr.write(
135
147
  `gdd-sdk stage: "${stageName}" is not one of brief|explore|plan|design|verify|discuss\n`,
136
148
  );
149
+ if (TOP_LEVEL_COMMANDS_OFTEN_CONFUSED_FOR_STAGES.has(stageName)) {
150
+ stderr.write(
151
+ `gdd-sdk stage: did you mean \`gdd-sdk ${stageName}\`? "${stageName}" is a top-level subcommand, not a pipeline stage.\n`,
152
+ );
153
+ }
137
154
  return 3;
138
155
  }
139
156