@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.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +90 -0
  4. package/README.md +6 -0
  5. package/SKILL.md +2 -0
  6. package/agents/design-fixer.md +16 -0
  7. package/dist/claude-code/.claude/skills/override/SKILL.md +86 -0
  8. package/dist/claude-code/.claude/skills/state/SKILL.md +106 -0
  9. package/hooks/gdd-decision-injector.js +58 -0
  10. package/hooks/gdd-fact-force.js +434 -0
  11. package/hooks/gdd-risk-gate.js +406 -0
  12. package/hooks/hooks.json +18 -0
  13. package/package.json +1 -1
  14. package/reference/schemas/events.schema.json +61 -1
  15. package/reference/skill-graph.md +3 -1
  16. package/scripts/lib/manifest/skills.json +16 -0
  17. package/scripts/lib/risk/calibration.cjs +385 -0
  18. package/scripts/lib/risk/compute-risk.cjs +229 -0
  19. package/scripts/lib/risk/consumers.cjs +211 -0
  20. package/scripts/lib/risk/override.cjs +87 -0
  21. package/scripts/lib/risk/route.cjs +59 -0
  22. package/scripts/lib/risk/tables.cjs +221 -0
  23. package/scripts/lib/state/migrate-to-sqlite.cjs +664 -0
  24. package/scripts/lib/state/query-surface.cjs +391 -0
  25. package/scripts/lib/state/render-markdown.cjs +717 -0
  26. package/scripts/lib/state/state-backend.cjs +345 -0
  27. package/scripts/lib/state/state-store.cjs +735 -0
  28. package/sdk/cli/index.js +193 -96
  29. package/sdk/dashboard/data/source.cjs +44 -5
  30. package/sdk/mcp/gdd-state/server.js +127 -30
  31. package/sdk/mcp/gdd-state/tools/get.ts +8 -0
  32. package/sdk/state/index.ts +267 -13
  33. package/sdk/state/lockfile.ts +48 -0
  34. package/sdk/state/schema.sql +218 -0
  35. package/skills/override/SKILL.md +86 -0
  36. package/skills/state/SKILL.md +106 -0
@@ -0,0 +1,391 @@
1
+ 'use strict';
2
+ /**
3
+ * scripts/lib/state/query-surface.cjs - Phase 57 (SAFE-01 / CONS-03)
4
+ *
5
+ * Public query surface for the /gdd:state skill subcommands:
6
+ * query(sql, opts) - readonly SELECT-only execution with denylist
7
+ * recover(opts) - rotate corrupt .sqlite to .bak, rebuild from markdown
8
+ * demigrate(opts) - remove .design/state.sqlite so markdown becomes SoT
9
+ * rotateBak(dbPath) - shift .bak.0..9 (cap at 10); used internally
10
+ * backupCycle(opts) - take a named backup of the current sqlite
11
+ *
12
+ * All functions degrade gracefully when BACKEND==='markdown' (clear message, no throw).
13
+ *
14
+ * R10 compliance:
15
+ * - engine-level readonly:true via openQueryDb (SQLITE_OPEN_READONLY; engine rejects
16
+ * writes with SQLITE_READONLY even before denylist)
17
+ * - defense-in-depth: first-token Set denylist {DROP,DELETE,UPDATE,INSERT,ALTER,
18
+ * ATTACH,CREATE,PRAGMA,VACUUM,ANALYZE,REINDEX,REPLACE} via Set.has() - NO regex,
19
+ * no ReDoS. Throws on denied or non-SELECT first token.
20
+ */
21
+
22
+ const fs = require('node:fs');
23
+ const path = require('node:path');
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Package-root walk-up (Phase 53 lesson).
27
+ // ---------------------------------------------------------------------------
28
+ function _findPackageRoot(startDir) {
29
+ let dir = path.resolve(startDir);
30
+ let firstWithPkg = null;
31
+ for (let i = 0; i < 12; i++) {
32
+ const pkgPath = path.join(dir, 'package.json');
33
+ let pkg = null;
34
+ try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); } catch { pkg = null; }
35
+ if (pkg) {
36
+ if (firstWithPkg === null) firstWithPkg = dir;
37
+ if (pkg.name === '@hegemonart/get-design-done') return dir;
38
+ }
39
+ const parent = path.dirname(dir);
40
+ if (parent === dir) break;
41
+ dir = parent;
42
+ }
43
+ return firstWithPkg || path.resolve(startDir);
44
+ }
45
+
46
+ const PKG_ROOT = _findPackageRoot(__dirname);
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Lazy-require state-backend.cjs (Executor A).
50
+ // ---------------------------------------------------------------------------
51
+ let _backend = null;
52
+ function _requireBackend() {
53
+ if (_backend) return _backend;
54
+ try {
55
+ _backend = require('./state-backend.cjs');
56
+ } catch {
57
+ // Try from package root
58
+ try {
59
+ _backend = require(path.join(PKG_ROOT, 'scripts', 'lib', 'state', 'state-backend.cjs'));
60
+ } catch {
61
+ _backend = null;
62
+ }
63
+ }
64
+ return _backend;
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Lazy-require migrate-to-sqlite.cjs (Executor B) - used by recover().
69
+ // ---------------------------------------------------------------------------
70
+ let _migrate = null;
71
+ function _requireMigrate() {
72
+ if (_migrate) return _migrate;
73
+ try {
74
+ _migrate = require('./migrate-to-sqlite.cjs');
75
+ } catch {
76
+ try {
77
+ _migrate = require(path.join(PKG_ROOT, 'scripts', 'lib', 'state', 'migrate-to-sqlite.cjs'));
78
+ } catch {
79
+ _migrate = null;
80
+ }
81
+ }
82
+ return _migrate;
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // First-token Set denylist (R10, defense-in-depth).
87
+ // Set.has() — NO regex — no ReDoS.
88
+ // These tokens are denied even though engine readonly prevents them anyway.
89
+ // ---------------------------------------------------------------------------
90
+ const DENIED_TOKENS = new Set([
91
+ 'DROP',
92
+ 'DELETE',
93
+ 'UPDATE',
94
+ 'INSERT',
95
+ 'ALTER',
96
+ 'ATTACH',
97
+ 'CREATE',
98
+ 'PRAGMA',
99
+ 'VACUUM',
100
+ 'ANALYZE',
101
+ 'REINDEX',
102
+ 'REPLACE',
103
+ ]);
104
+
105
+ /**
106
+ * Extract the first SQL token (uppercase) from a query string.
107
+ * Strips leading whitespace and comments (-- and /* style).
108
+ * @param {string} sql
109
+ * @returns {string} uppercased first token, or '' if empty
110
+ */
111
+ function _firstToken(sql) {
112
+ if (typeof sql !== 'string') return '';
113
+ // Strip leading whitespace.
114
+ let s = sql.trimStart();
115
+ // Strip single-line comments (-- ...).
116
+ while (s.startsWith('--')) {
117
+ const nl = s.indexOf('\n');
118
+ s = (nl === -1 ? '' : s.slice(nl + 1)).trimStart();
119
+ }
120
+ // Strip block comments (/* ... */).
121
+ if (s.startsWith('/*')) {
122
+ const end = s.indexOf('*/');
123
+ s = (end === -1 ? '' : s.slice(end + 2)).trimStart();
124
+ }
125
+ // Extract first alphanumeric token.
126
+ const m = s.match(/^([A-Za-z_][A-Za-z0-9_]*)/);
127
+ return m ? m[1].toUpperCase() : '';
128
+ }
129
+
130
+ /**
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.
133
+ * @param {string} sql
134
+ */
135
+ function _assertReadonly(sql) {
136
+ const token = _firstToken(sql);
137
+ if (token === '') {
138
+ throw new Error('query-surface: empty query rejected');
139
+ }
140
+ if (DENIED_TOKENS.has(token)) {
141
+ throw new Error(
142
+ `query-surface: statement type '${token}' is not allowed (denylist). Only SELECT is permitted.`
143
+ );
144
+ }
145
+ if (token !== 'SELECT') {
146
+ throw new Error(
147
+ `query-surface: first token '${token}' is not SELECT. Only SELECT queries are permitted.`
148
+ );
149
+ }
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // query(sql, opts) - readonly SELECT-only execution.
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Execute a readonly SQL SELECT query against the state SQLite database.
158
+ *
159
+ * Guards:
160
+ * 1. BACKEND must be 'sqlite' (else returns degraded message object)
161
+ * 2. First-token denylist check (throws on denied)
162
+ * 3. Engine-level readonly:true connection (engine rejects writes)
163
+ *
164
+ * @param {string} sql
165
+ * @param {{ projectRoot?: string, dbPath?: string }} [opts]
166
+ * @returns {{ rows: Array<object>, backend: string } | { degraded: true, message: string }}
167
+ */
168
+ function query(sql, opts = {}) {
169
+ const backend = _requireBackend();
170
+ if (!backend || backend.BACKEND !== 'sqlite') {
171
+ return {
172
+ degraded: true,
173
+ message: 'query-surface: BACKEND is not sqlite (better-sqlite3 not available or GDD_STATE_BACKEND=markdown). ' +
174
+ 'Query is a no-op on the markdown floor.',
175
+ };
176
+ }
177
+
178
+ // Defense-in-depth: denylist check before the engine connection.
179
+ // Throws on denied token or non-SELECT.
180
+ _assertReadonly(sql);
181
+
182
+ const dbPath = opts.dbPath || backend.sqlitePath(opts.projectRoot || process.cwd());
183
+ if (!fs.existsSync(dbPath)) {
184
+ return {
185
+ degraded: true,
186
+ message: `query-surface: state.sqlite not found at ${dbPath}. Run --migrate-state first.`,
187
+ };
188
+ }
189
+
190
+ const db = backend.openQueryDb(dbPath);
191
+ try {
192
+ const rows = db.prepare(sql).all();
193
+ return { rows, backend: 'sqlite' };
194
+ } finally {
195
+ db.close();
196
+ }
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // rotateBak(dbPath) - shift .bak.0..9, cap at 10.
201
+ // ---------------------------------------------------------------------------
202
+
203
+ /**
204
+ * Rotate backup files for dbPath, keeping at most 10 (indices 0..9).
205
+ * Shift: .bak.8 -> .bak.9, .bak.7 -> .bak.8, ..., .bak.0 -> .bak.1
206
+ * The slot .bak.0 is then free for the caller to write the current db.
207
+ *
208
+ * @param {string} dbPath absolute path to the .sqlite file
209
+ */
210
+ function rotateBak(dbPath) {
211
+ // Cap: shift indices 8..0 up by 1 (9 is dropped if it exists), then free slot 0.
212
+ for (let i = 8; i >= 0; i--) {
213
+ const src = `${dbPath}.bak.${i}`;
214
+ const dst = `${dbPath}.bak.${i + 1}`;
215
+ if (fs.existsSync(src)) {
216
+ // Remove destination if it exists (cap at 10 means .bak.9 is overwritten).
217
+ try { fs.unlinkSync(dst); } catch { /* ok if missing */ }
218
+ try { fs.renameSync(src, dst); } catch { /* best-effort */ }
219
+ }
220
+ }
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // backupCycle(opts) - copy current sqlite to .bak.0 after rotation.
225
+ // ---------------------------------------------------------------------------
226
+
227
+ /**
228
+ * Take a backup of the current state.sqlite (rotate existing backups first).
229
+ *
230
+ * @param {{ projectRoot?: string, dbPath?: string }} [opts]
231
+ * @returns {{ backed_up: boolean, path?: string, message?: string }}
232
+ */
233
+ function backupCycle(opts = {}) {
234
+ const backend = _requireBackend();
235
+ if (!backend || backend.BACKEND !== 'sqlite') {
236
+ return { backed_up: false, message: 'backupCycle: BACKEND is not sqlite; skipped.' };
237
+ }
238
+ const dbPath = opts.dbPath || backend.sqlitePath(opts.projectRoot || process.cwd());
239
+ if (!fs.existsSync(dbPath)) {
240
+ return { backed_up: false, message: `backupCycle: ${dbPath} does not exist; nothing to back up.` };
241
+ }
242
+ rotateBak(dbPath);
243
+ const bak0 = `${dbPath}.bak.0`;
244
+ try {
245
+ fs.copyFileSync(dbPath, bak0);
246
+ return { backed_up: true, path: bak0 };
247
+ } catch (err) {
248
+ return { backed_up: false, message: `backupCycle: copy failed: ${err.message}` };
249
+ }
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // demigrate(opts) - remove .design/state.sqlite so markdown becomes SoT.
254
+ // Idempotent: no-op if the file does not exist.
255
+ // ---------------------------------------------------------------------------
256
+
257
+ /**
258
+ * Remove .design/state.sqlite so the markdown STATE.md becomes the SoT again.
259
+ * Idempotent: if state.sqlite does not exist, returns a clear message without error.
260
+ *
261
+ * @param {{ projectRoot?: string, dbPath?: string }} [opts]
262
+ * @returns {{ demigrated: boolean, message: string }}
263
+ */
264
+ function demigrate(opts = {}) {
265
+ const backend = _requireBackend();
266
+ if (!backend) {
267
+ // state-backend.cjs not available at all; no sqlite to remove.
268
+ return { demigrated: false, message: 'demigrate: state-backend.cjs not available; nothing to remove.' };
269
+ }
270
+ // sqlitePath is always safe to call regardless of BACKEND.
271
+ const dbPath = opts.dbPath || backend.sqlitePath(opts.projectRoot || process.cwd());
272
+ if (!fs.existsSync(dbPath)) {
273
+ return {
274
+ demigrated: false,
275
+ message: `demigrate: ${dbPath} does not exist; markdown is already the SoT (no-op).`,
276
+ };
277
+ }
278
+ // Take a backup before removing.
279
+ rotateBak(dbPath);
280
+ const bak0 = `${dbPath}.bak.0`;
281
+ try { fs.copyFileSync(dbPath, bak0); } catch { /* best-effort backup */ }
282
+ try {
283
+ fs.unlinkSync(dbPath);
284
+ } catch (err) {
285
+ return { demigrated: false, message: `demigrate: failed to remove ${dbPath}: ${err.message}` };
286
+ }
287
+ return {
288
+ demigrated: true,
289
+ message: `demigrate: removed ${dbPath}. Markdown STATE.md is now the SoT. ` +
290
+ `A backup was saved to ${bak0}.`,
291
+ };
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // recover(opts) - rotate current sqlite to .bak.0, rebuild via migrate-to-sqlite
296
+ // force-mode from the markdown STATE.md.
297
+ // ---------------------------------------------------------------------------
298
+
299
+ /**
300
+ * Recover a corrupt or missing state.sqlite by rebuilding it from the markdown STATE.md.
301
+ *
302
+ * Steps:
303
+ * 1. If state.sqlite exists, rotate it to .bak.0 (backup of the corrupt file).
304
+ * 2. Remove the corrupt .sqlite so migrate-to-sqlite can write a fresh one.
305
+ * 3. Invoke migrate-to-sqlite with force:true to rebuild from markdown.
306
+ * 4. Run integrity_check on the new database.
307
+ *
308
+ * @param {{ projectRoot?: string, dbPath?: string }} [opts]
309
+ * @returns {{ recovered: boolean, message: string, integrity?: boolean }}
310
+ */
311
+ function recover(opts = {}) {
312
+ const backend = _requireBackend();
313
+ if (!backend || backend.BACKEND !== 'sqlite') {
314
+ return {
315
+ recovered: false,
316
+ message: 'recover: BACKEND is not sqlite (better-sqlite3 not available or GDD_STATE_BACKEND=markdown). ' +
317
+ 'Markdown STATE.md is already the SoT; no SQLite to recover.',
318
+ };
319
+ }
320
+
321
+ const dbPath = opts.dbPath || backend.sqlitePath(opts.projectRoot || process.cwd());
322
+
323
+ // Step 1: Rotate existing (possibly corrupt) file to .bak.0.
324
+ if (fs.existsSync(dbPath)) {
325
+ rotateBak(dbPath);
326
+ const bak0 = `${dbPath}.bak.0`;
327
+ try { fs.copyFileSync(dbPath, bak0); } catch { /* best-effort */ }
328
+ try { fs.unlinkSync(dbPath); } catch (err) {
329
+ return { recovered: false, message: `recover: could not remove corrupt ${dbPath}: ${err.message}` };
330
+ }
331
+ }
332
+
333
+ // Step 2: Rebuild from markdown.
334
+ const migrate = _requireMigrate();
335
+ if (!migrate) {
336
+ return {
337
+ recovered: false,
338
+ message: 'recover: migrate-to-sqlite.cjs not available (Executor B not yet present). ' +
339
+ 'Cannot rebuild SQLite from markdown.',
340
+ };
341
+ }
342
+
343
+ let migrateResult = null;
344
+ try {
345
+ if (typeof migrate.migrateToSqlite === 'function') {
346
+ migrateResult = migrate.migrateToSqlite({
347
+ projectRoot: opts.projectRoot,
348
+ dbPath,
349
+ force: true,
350
+ });
351
+ } else if (typeof migrate.migrate === 'function') {
352
+ migrateResult = migrate.migrate({ projectRoot: opts.projectRoot, dbPath, force: true });
353
+ } else {
354
+ return { recovered: false, message: 'recover: migrate-to-sqlite.cjs has no recognized export.' };
355
+ }
356
+ } catch (err) {
357
+ return { recovered: false, message: `recover: migration threw: ${err.message}` };
358
+ }
359
+
360
+ // Step 3: Integrity check on the newly written database.
361
+ let integrity = false;
362
+ if (fs.existsSync(dbPath)) {
363
+ try {
364
+ const db = backend.openStateDb(dbPath, { readonly: true });
365
+ try { integrity = backend.checkIntegrity(db); } finally { db.close(); }
366
+ } catch { integrity = false; }
367
+ }
368
+
369
+ return {
370
+ recovered: true,
371
+ integrity,
372
+ migration: migrateResult,
373
+ message: `recover: rebuilt ${dbPath} from markdown STATE.md. integrity_check=${integrity ? 'ok' : 'FAILED'}.`,
374
+ };
375
+ }
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // Exports
379
+ // ---------------------------------------------------------------------------
380
+
381
+ module.exports = {
382
+ query,
383
+ recover,
384
+ demigrate,
385
+ rotateBak,
386
+ backupCycle,
387
+ // Expose internals for testing.
388
+ _assertReadonly,
389
+ _firstToken,
390
+ DENIED_TOKENS,
391
+ };