@hegemonart/get-design-done 1.52.0 → 1.54.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 (60) 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 +4 -0
  5. package/SKILL.md +2 -1
  6. package/agents/component-taxonomy-mapper.md +3 -0
  7. package/agents/design-context-reviewer-gate.md +102 -0
  8. package/agents/design-context-reviewer.md +186 -0
  9. package/agents/motion-mapper.md +1 -0
  10. package/agents/token-mapper.md +3 -0
  11. package/dist/claude-code/.claude/skills/discover/SKILL.md +7 -1
  12. package/dist/claude-code/.claude/skills/explore/SKILL.md +3 -1
  13. package/dist/claude-code/.claude/skills/new-addendum/SKILL.md +81 -0
  14. package/package.json +1 -1
  15. package/reference/frameworks/astro.md +43 -0
  16. package/reference/frameworks/nextjs.md +44 -0
  17. package/reference/frameworks/remix.md +44 -0
  18. package/reference/frameworks/storybook.md +44 -0
  19. package/reference/frameworks/sveltekit.md +43 -0
  20. package/reference/frameworks/vite-react.md +43 -0
  21. package/reference/interaction.md +1 -0
  22. package/reference/motion/framer-motion.md +45 -0
  23. package/reference/motion/gsap.md +45 -0
  24. package/reference/motion/motion-one.md +44 -0
  25. package/reference/motion/react-spring.md +44 -0
  26. package/reference/motion.md +1 -0
  27. package/reference/registry.json +163 -1
  28. package/reference/registry.schema.json +18 -1
  29. package/reference/skill-graph.md +2 -1
  30. package/reference/systems/chakra.md +44 -0
  31. package/reference/systems/css-modules.md +44 -0
  32. package/reference/systems/mui.md +44 -0
  33. package/reference/systems/radix-themes.md +43 -0
  34. package/reference/systems/shadcn.md +45 -0
  35. package/reference/systems/styled-components.md +44 -0
  36. package/reference/systems/tailwind.md +44 -0
  37. package/reference/systems/vanilla-extract.md +44 -0
  38. package/scripts/lib/detect/stack.cjs +455 -0
  39. package/scripts/lib/detect/stack.d.cts +44 -0
  40. package/scripts/lib/explore-parallel-runner/index.ts +196 -1
  41. package/scripts/lib/explore-parallel-runner/types.ts +85 -0
  42. package/scripts/lib/health-mirror/index.cjs +73 -1
  43. package/scripts/lib/manifest/skills.json +10 -2
  44. package/scripts/lib/mapper-spawn.cjs +257 -0
  45. package/scripts/lib/mapper-spawn.d.cts +60 -0
  46. package/scripts/lib/mappers/compute-batches.mjs +625 -0
  47. package/scripts/lib/mappers/graph-adjacency.mjs +129 -0
  48. package/scripts/lib/mappers/incremental-discover.cjs +617 -0
  49. package/scripts/lib/mappers/incremental-discover.d.cts +133 -0
  50. package/scripts/lib/mappers/neighbor-map.mjs +0 -0
  51. package/scripts/lib/new-addendum.cjs +204 -0
  52. package/sdk/cli/index.js +1504 -3
  53. package/sdk/fingerprint/classify.cjs +406 -0
  54. package/sdk/fingerprint/index.ts +405 -0
  55. package/sdk/fingerprint/store.cjs +523 -0
  56. package/sdk/index.ts +1 -0
  57. package/sdk/mcp/gdd-mcp/server.js +1047 -0
  58. package/skills/discover/SKILL.md +7 -1
  59. package/skills/explore/SKILL.md +3 -1
  60. package/skills/new-addendum/SKILL.md +81 -0
@@ -0,0 +1,523 @@
1
+ 'use strict';
2
+ /**
3
+ * sdk/fingerprint/store.cjs — Phase 53 (Semantic Mapper Engine), FP-02.
4
+ *
5
+ * Persistence for the per-node fingerprint set across design cycles:
6
+ *
7
+ * .design/fingerprints/current.json — the latest fingerprint snapshot
8
+ * .design/fingerprints/cycle-NNN.json — rolling history (newest N=5 kept)
9
+ *
10
+ * The store is JSON-canonical and writes are ATOMIC (tmp + rename in the SAME
11
+ * directory — the Windows-safe pattern from `scripts/lib/graph/atomic-write.mjs`,
12
+ * inlined here as the sanctioned CJS twin exactly as `scripts/lib/live/session-store.cjs`
13
+ * and `scripts/lib/design-context/integration-map.mjs` already do, so this module
14
+ * stays synchronous + dep-free + `require()`-able from `.cjs` CLIs and skills).
15
+ *
16
+ * The store ROOT follows the Phase 49 worktree-redirect: writes resolve through
17
+ * `scripts/lib/worktree-resolve.cjs#resolveRepoRoot()` so a GDD run inside a git
18
+ * worktree persists fingerprints in the MAIN checkout, not the throwaway
19
+ * worktree. Tests (and any caller that wants an explicit location) pass a
20
+ * `{ root }` override that bypasses git resolution entirely — see ROOT OVERRIDE.
21
+ *
22
+ * `sinceCycle` answers "which node ids changed since cycle X" with an OPTIONAL
23
+ * FTS5 acceleration via `probeOptional('better-sqlite3')` and a dependency-free
24
+ * JSON-scan fallback (the three-tier optional-SQLite pattern from
25
+ * `design-search.cjs` / `instinct-store.cjs`). The native module is NEVER
26
+ * required: with better-sqlite3 absent, the JSON-scan path answers the same
27
+ * query, so the suite passes on a clean install.
28
+ *
29
+ * NEVER throws on an absent store: reads return an empty/bootstrap shape and
30
+ * `sinceCycle` treats "no prior cycle" as "everything is new" (returns the
31
+ * current id set). Determinism is a hard contract (D6): ids are returned sorted;
32
+ * no `Math.random` / `Date.now` leaks into stored content (the atomic tmp suffix
33
+ * uses pid+random for filename uniqueness only and never lands in the payload).
34
+ *
35
+ * ---------------------------------------------------------------------------
36
+ * STORED SHAPE
37
+ * ---------------------------------------------------------------------------
38
+ * current.json / cycle-NNN.json:
39
+ * {
40
+ * schema_version: '53.0',
41
+ * cycle: number|null, // the cycle this snapshot represents (null for current-before-roll)
42
+ * fingerprints: Record<nodeId, { full: string, structural: string, type?: string }>
43
+ * }
44
+ *
45
+ * `fingerprints` is whatever the caller hands `writeCurrent` — typically the
46
+ * per-node `{ full, structural }` from `sdk/fingerprint/index.ts#fingerprint`.
47
+ * The store treats values opaquely except that `sinceCycle` compares the
48
+ * `full` (and falls back to `structural`, then a stable stringify) to detect
49
+ * change. A plain `Record<nodeId, string>` of hashes also works.
50
+ *
51
+ * ---------------------------------------------------------------------------
52
+ * ROOT OVERRIDE (the `{ root }` convention — used by tests + explicit callers)
53
+ * ---------------------------------------------------------------------------
54
+ * Every function takes an options bag whose `root` field, when a non-empty
55
+ * string, is used VERBATIM as the repo root (the store lives at
56
+ * `<root>/.design/fingerprints/`). No git is consulted. When `root` is absent,
57
+ * the root is `resolveRepoRoot(opts.cwd, opts.exec)` — worktree-redirected.
58
+ * `opts.exec` is the injectable git runner from worktree-resolve.cjs (tests
59
+ * simulate a worktree without a real one). So:
60
+ *
61
+ * writeCurrent(fps, { root: '/tmp/fake-repo' }) // hermetic, no git
62
+ * writeCurrent(fps) // resolveRepoRoot(cwd)
63
+ * writeCurrent(fps, { cwd, exec }) // simulated worktree
64
+ */
65
+
66
+ const fs = require('node:fs');
67
+ const path = require('node:path');
68
+
69
+ const { probeOptional } = require('../../scripts/lib/probe-optional.cjs');
70
+ const { resolveRepoRoot } = require('../../scripts/lib/worktree-resolve.cjs');
71
+
72
+ const SCHEMA_VERSION = '53.0';
73
+
74
+ /** Rolling history depth — newest N cycle snapshots are kept, older pruned. */
75
+ const ROLLING_HISTORY = 5;
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // better-sqlite3 + FTS5 backend probe (evaluated once at module load).
79
+ // Mirrors design-search.cjs / instinct-store.cjs backend selection. The
80
+ // native module is purely an OPTIONAL accelerator for sinceCycle; the JSON
81
+ // scan below answers the identical query when it is absent.
82
+ // ---------------------------------------------------------------------------
83
+
84
+ const Database = probeOptional('better-sqlite3');
85
+
86
+ let _fts5Supported = false;
87
+ if (Database) {
88
+ try {
89
+ const probe = new Database(':memory:');
90
+ probe.exec('CREATE VIRTUAL TABLE _p USING fts5(t)');
91
+ probe.close();
92
+ _fts5Supported = true;
93
+ } catch {
94
+ /* fts5 extension not compiled in — the JSON scan answers sinceCycle */
95
+ }
96
+ }
97
+
98
+ /** 'fts5' when better-sqlite3+fts5 is available, else the dep-free 'json-scan'. */
99
+ function backendName() {
100
+ return _fts5Supported ? 'fts5' : 'json-scan';
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Atomic JSON write — sanctioned synchronous CJS twin of
105
+ // scripts/lib/graph/atomic-write.mjs#atomicWriteJson (same tmp+rename in the
106
+ // SAME directory; the Windows atomicity guarantee). Inlined exactly as
107
+ // session-store.cjs / integration-map.mjs do, because that ESM helper cannot
108
+ // be `require()`d from CJS without forcing this whole module async.
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /**
112
+ * @param {string} target absolute path to the final JSON file
113
+ * @param {unknown} payload JSON-serializable value (pretty 2-space + trailing \n)
114
+ */
115
+ function atomicWriteJson(target, payload) {
116
+ const parent = path.dirname(target);
117
+ const base = path.basename(target);
118
+ const tmp = path.join(
119
+ parent,
120
+ `.${base}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`,
121
+ );
122
+ // Invariant: tmp must live in the SAME dir as target (cross-device rename is
123
+ // not atomic on Windows). Resolve both to normalize slash style.
124
+ if (path.resolve(path.dirname(tmp)) !== path.resolve(parent)) {
125
+ throw new Error(
126
+ `atomicWriteJson invariant: tmp not in same dir as target (tmp=${tmp}, target=${target})`,
127
+ );
128
+ }
129
+ fs.mkdirSync(parent, { recursive: true });
130
+ const body = JSON.stringify(payload, null, 2) + '\n';
131
+ try {
132
+ fs.writeFileSync(tmp, body, 'utf8');
133
+ fs.renameSync(tmp, target);
134
+ } catch (err) {
135
+ if (fs.existsSync(tmp)) {
136
+ try {
137
+ fs.unlinkSync(tmp);
138
+ } catch {
139
+ /* swallow cleanup error — original throw wins */
140
+ }
141
+ }
142
+ throw err;
143
+ }
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Paths — resolve the store dir + file locations from the {root} override or
148
+ // the worktree-redirected repo root.
149
+ // ---------------------------------------------------------------------------
150
+
151
+ /**
152
+ * Resolve the absolute repo root for a call. A non-empty `opts.root` string is
153
+ * used verbatim (hermetic; no git). Otherwise `resolveRepoRoot(cwd, exec)`
154
+ * applies the worktree redirect (and degrades to cwd when git is unavailable).
155
+ *
156
+ * @param {{ root?: string, cwd?: string, exec?: Function }} [opts]
157
+ * @returns {string} absolute repo root
158
+ */
159
+ function resolveRoot(opts = {}) {
160
+ if (typeof opts.root === 'string' && opts.root.length) {
161
+ return path.resolve(opts.root);
162
+ }
163
+ const cwd = typeof opts.cwd === 'string' && opts.cwd.length ? opts.cwd : process.cwd();
164
+ return resolveRepoRoot(cwd, opts.exec);
165
+ }
166
+
167
+ /**
168
+ * The store directory `<root>/.design/fingerprints` and the well-known files
169
+ * within it.
170
+ *
171
+ * @param {{ root?: string, cwd?: string, exec?: Function }} [opts]
172
+ * @returns {{ root: string, dir: string, currentFile: string, ftsPath: string }}
173
+ */
174
+ function paths(opts = {}) {
175
+ const root = resolveRoot(opts);
176
+ const dir = path.join(root, '.design', 'fingerprints');
177
+ return {
178
+ root,
179
+ dir,
180
+ currentFile: path.join(dir, 'current.json'),
181
+ ftsPath: path.join(dir, 'fingerprints.fts.db'),
182
+ };
183
+ }
184
+
185
+ /** Zero-pad a cycle number to 3 digits for the `cycle-NNN.json` filename. */
186
+ function cycleFileName(n) {
187
+ const num = Math.max(0, Math.floor(Number(n) || 0));
188
+ return `cycle-${String(num).padStart(3, '0')}.json`;
189
+ }
190
+
191
+ /** Absolute path to a specific cycle snapshot file. */
192
+ function cyclePath(n, opts = {}) {
193
+ return path.join(paths(opts).dir, cycleFileName(n));
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Shape helpers — normalize whatever the caller stores into the canonical
198
+ // envelope, and read it back tolerantly (never throw on absent/corrupt).
199
+ // ---------------------------------------------------------------------------
200
+
201
+ /** The empty/bootstrap snapshot returned when nothing is on disk. */
202
+ function emptySnapshot() {
203
+ return { schema_version: SCHEMA_VERSION, cycle: null, fingerprints: {} };
204
+ }
205
+
206
+ /**
207
+ * Coerce arbitrary input into the canonical `{ schema_version, cycle,
208
+ * fingerprints }` envelope. Accepts either a bare `fingerprints` map or an
209
+ * already-enveloped object.
210
+ *
211
+ * @param {object} input
212
+ * @param {number|null} cycle
213
+ * @returns {{ schema_version: string, cycle: number|null, fingerprints: object }}
214
+ */
215
+ function toEnvelope(input, cycle) {
216
+ let fingerprints = {};
217
+ if (input && typeof input === 'object') {
218
+ if (input.fingerprints && typeof input.fingerprints === 'object') {
219
+ fingerprints = input.fingerprints;
220
+ } else {
221
+ // bare map of nodeId -> fingerprint
222
+ fingerprints = input;
223
+ }
224
+ }
225
+ return {
226
+ schema_version: SCHEMA_VERSION,
227
+ cycle: Number.isFinite(cycle) ? Math.floor(cycle) : null,
228
+ fingerprints: fingerprints && typeof fingerprints === 'object' ? fingerprints : {},
229
+ };
230
+ }
231
+
232
+ /** Read + parse a JSON snapshot file, tolerant of absence/corruption. */
233
+ function readSnapshotFile(file) {
234
+ if (!fs.existsSync(file)) return emptySnapshot();
235
+ try {
236
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
237
+ if (!data || typeof data !== 'object') return emptySnapshot();
238
+ if (!data.fingerprints || typeof data.fingerprints !== 'object') {
239
+ data.fingerprints = {};
240
+ }
241
+ if (typeof data.schema_version !== 'string') data.schema_version = SCHEMA_VERSION;
242
+ if (!('cycle' in data)) data.cycle = null;
243
+ return data;
244
+ } catch {
245
+ return emptySnapshot();
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Extract a stable, comparable signature for one stored fingerprint value.
251
+ * Prefers `full`, then `structural`, then a deterministic stringify (sorted
252
+ * keys) so a plain string or an arbitrary object both compare correctly.
253
+ *
254
+ * @param {unknown} v
255
+ * @returns {string}
256
+ */
257
+ function sigOf(v) {
258
+ if (v == null) return '';
259
+ if (typeof v === 'string') return v;
260
+ if (typeof v === 'object') {
261
+ if (typeof v.full === 'string') return v.full;
262
+ if (typeof v.structural === 'string') return v.structural;
263
+ try {
264
+ const keys = Object.keys(v).sort();
265
+ return keys.map((k) => `${k}=${JSON.stringify(v[k])}`).join('|');
266
+ } catch {
267
+ return '';
268
+ }
269
+ }
270
+ return String(v);
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // writeCurrent / readCurrent — the live snapshot.
275
+ // ---------------------------------------------------------------------------
276
+
277
+ /**
278
+ * Atomically write the current fingerprint snapshot to
279
+ * `<root>/.design/fingerprints/current.json`. Returns the absolute path written.
280
+ *
281
+ * @param {object} fingerprints bare map nodeId->fp OR an enveloped snapshot
282
+ * @param {{ root?: string, cwd?: string, exec?: Function, cycle?: number }} [opts]
283
+ * @returns {{ path: string, snapshot: object }}
284
+ */
285
+ function writeCurrent(fingerprints, opts = {}) {
286
+ const { currentFile } = paths(opts);
287
+ const cycle =
288
+ Number.isFinite(opts.cycle) ? opts.cycle
289
+ : fingerprints && typeof fingerprints === 'object' && Number.isFinite(fingerprints.cycle)
290
+ ? fingerprints.cycle
291
+ : null;
292
+ const snapshot = toEnvelope(fingerprints, cycle);
293
+ atomicWriteJson(currentFile, snapshot);
294
+ return { path: currentFile, snapshot };
295
+ }
296
+
297
+ /**
298
+ * Read the current snapshot, or the empty/bootstrap shape when none exists.
299
+ *
300
+ * @param {{ root?: string, cwd?: string, exec?: Function }} [opts]
301
+ * @returns {{ schema_version: string, cycle: number|null, fingerprints: object }}
302
+ */
303
+ function readCurrent(opts = {}) {
304
+ return readSnapshotFile(paths(opts).currentFile);
305
+ }
306
+
307
+ /**
308
+ * Read a specific cycle snapshot, or the empty/bootstrap shape when absent.
309
+ *
310
+ * @param {number} n cycle number
311
+ * @param {{ root?: string, cwd?: string, exec?: Function }} [opts]
312
+ * @returns {{ schema_version: string, cycle: number|null, fingerprints: object }}
313
+ */
314
+ function readCycle(n, opts = {}) {
315
+ return readSnapshotFile(cyclePath(n, opts));
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // rollCycle — snapshot current → cycle-NNN.json, then prune to rolling N=5.
320
+ // ---------------------------------------------------------------------------
321
+
322
+ /** List existing cycle snapshot files as { n, file }, sorted by n ascending. */
323
+ function listCycles(opts = {}) {
324
+ const { dir } = paths(opts);
325
+ let entries;
326
+ try {
327
+ entries = fs.readdirSync(dir);
328
+ } catch {
329
+ return [];
330
+ }
331
+ const out = [];
332
+ for (const name of entries) {
333
+ const m = /^cycle-(\d+)\.json$/.exec(name);
334
+ if (m) out.push({ n: parseInt(m[1], 10), file: path.join(dir, name) });
335
+ }
336
+ out.sort((a, b) => a.n - b.n);
337
+ return out;
338
+ }
339
+
340
+ /**
341
+ * Snapshot the current fingerprint set into `cycle-NNN.json`, stamping the
342
+ * envelope's `cycle` field, then prune the history to the newest ROLLING_HISTORY
343
+ * (=5) snapshots (oldest deleted). Idempotent per cycle number (re-rolling the
344
+ * same N overwrites that snapshot atomically). Never throws on an absent
345
+ * current.json — it rolls an empty snapshot (a degenerate but valid cycle).
346
+ *
347
+ * @param {number} cycleN the cycle number to stamp this snapshot with
348
+ * @param {{ root?: string, cwd?: string, exec?: Function }} [opts]
349
+ * @returns {{ path: string, cycle: number, pruned: string[], kept: number[] }}
350
+ */
351
+ function rollCycle(cycleN, opts = {}) {
352
+ const n = Math.max(0, Math.floor(Number(cycleN) || 0));
353
+ const current = readCurrent(opts);
354
+ const snapshot = toEnvelope(current, n);
355
+ const dest = cyclePath(n, opts);
356
+ atomicWriteJson(dest, snapshot);
357
+
358
+ // Prune to the newest ROLLING_HISTORY snapshots. Sort by cycle number so the
359
+ // pruning is deterministic regardless of readdir order.
360
+ const cycles = listCycles(opts);
361
+ const pruned = [];
362
+ if (cycles.length > ROLLING_HISTORY) {
363
+ const excess = cycles.length - ROLLING_HISTORY;
364
+ for (let i = 0; i < excess; i++) {
365
+ const victim = cycles[i]; // oldest first (ascending sort)
366
+ try {
367
+ fs.rmSync(victim.file, { force: true });
368
+ pruned.push(victim.file);
369
+ } catch {
370
+ /* best-effort prune; a locked file simply remains */
371
+ }
372
+ }
373
+ }
374
+ const kept = listCycles(opts).map((c) => c.n);
375
+ return { path: dest, cycle: n, pruned, kept };
376
+ }
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // sinceCycle — ids whose fingerprint changed since cycle X.
380
+ // FTS5 fast path when available; dep-free JSON scan otherwise. Both compare
381
+ // the stored signature of each node in current.json against the cycle-X
382
+ // snapshot and return the SORTED set of changed (added / modified) ids. A node
383
+ // present in cycle-X but absent from current is treated as REMOVED and its id
384
+ // is also included (a removal is a change the caller must react to).
385
+ // ---------------------------------------------------------------------------
386
+
387
+ /**
388
+ * Pure diff between two fingerprint maps → sorted array of changed ids
389
+ * (added, modified, or removed). Shared by both backends so they agree.
390
+ *
391
+ * @param {object} baseFps fingerprints at cycle X
392
+ * @param {object} currFps current fingerprints
393
+ * @returns {string[]} sorted changed ids
394
+ */
395
+ function diffFingerprintMaps(baseFps, currFps) {
396
+ const base = baseFps && typeof baseFps === 'object' ? baseFps : {};
397
+ const curr = currFps && typeof currFps === 'object' ? currFps : {};
398
+ const changed = new Set();
399
+ // added or modified
400
+ for (const id of Object.keys(curr)) {
401
+ if (!(id in base) || sigOf(curr[id]) !== sigOf(base[id])) changed.add(id);
402
+ }
403
+ // removed
404
+ for (const id of Object.keys(base)) {
405
+ if (!(id in curr)) changed.add(id);
406
+ }
407
+ return [...changed].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
408
+ }
409
+
410
+ /**
411
+ * FTS5-accelerated diff. We do NOT use full-text ranking here (the query is an
412
+ * exact id+signature set-difference, not a relevance search); FTS5 is used as a
413
+ * fast keyed store to materialize the base snapshot and probe membership. This
414
+ * keeps parity with the JSON scan while exercising the native module when it is
415
+ * present. Falls back to the JSON scan on any sqlite error.
416
+ *
417
+ * @param {object} baseFps
418
+ * @param {object} currFps
419
+ * @param {string} ftsPath
420
+ * @returns {string[]}
421
+ */
422
+ function _sinceCycleFts5(baseFps, currFps, ftsPath) {
423
+ // The FTS5 path materializes base (id, sig) into a trigram-indexed table and
424
+ // diffs against current. For the exact-membership query an ordinary table
425
+ // would do, but we keep the fts5 vtable so the optional-backend wiring is
426
+ // genuinely exercised when better-sqlite3 is installed. On ANY failure we
427
+ // degrade to the identical JSON-scan diff — recall must never break.
428
+ let db;
429
+ try {
430
+ fs.mkdirSync(path.dirname(ftsPath), { recursive: true });
431
+ db = new Database(ftsPath);
432
+ db.exec('DROP TABLE IF EXISTS base_fp');
433
+ db.exec("CREATE VIRTUAL TABLE base_fp USING fts5(id UNINDEXED, sig UNINDEXED, tokenize='trigram')");
434
+ const insert = db.prepare('INSERT INTO base_fp(id, sig) VALUES (?, ?)');
435
+ const rows = Object.keys(baseFps || {}).map((id) => ({ id, sig: sigOf(baseFps[id]) }));
436
+ const txn = db.transaction((rs) => {
437
+ for (const r of rs) insert.run(r.id, r.sig);
438
+ });
439
+ txn(rows);
440
+
441
+ const lookup = db.prepare('SELECT sig FROM base_fp WHERE id = ? LIMIT 1');
442
+ const changed = new Set();
443
+ for (const id of Object.keys(currFps || {})) {
444
+ const row = lookup.get(id);
445
+ if (!row || row.sig !== sigOf(currFps[id])) changed.add(id);
446
+ }
447
+ // removed: ids in base not in current
448
+ const currKeys = new Set(Object.keys(currFps || {}));
449
+ for (const r of rows) if (!currKeys.has(r.id)) changed.add(r.id);
450
+
451
+ return [...changed].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
452
+ } catch {
453
+ return diffFingerprintMaps(baseFps, currFps);
454
+ } finally {
455
+ if (db) {
456
+ try {
457
+ db.close();
458
+ } catch {
459
+ /* ignore */
460
+ }
461
+ }
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Return the sorted set of node ids whose fingerprint changed since cycle
467
+ * `cycleN` (added, modified, or removed relative to that snapshot), comparing
468
+ * against the CURRENT snapshot.
469
+ *
470
+ * Bootstrap / absent-baseline behavior: when the requested cycle snapshot does
471
+ * not exist (or is empty), there is no prior baseline, so EVERY current id is
472
+ * "changed" — the full current id set is returned (sorted). When BOTH the cycle
473
+ * and current snapshots are empty, returns []. Never throws.
474
+ *
475
+ * Uses the FTS5 backend when better-sqlite3+fts5 is available, else the
476
+ * dep-free JSON scan; both return the identical sorted id set.
477
+ *
478
+ * @param {number} cycleN the cycle to diff against
479
+ * @param {{ root?: string, cwd?: string, exec?: Function }} [opts]
480
+ * @returns {string[]} sorted changed ids
481
+ */
482
+ function sinceCycle(cycleN, opts = {}) {
483
+ const baseSnap = readCycle(cycleN, opts);
484
+ const currSnap = readCurrent(opts);
485
+ const baseFps = baseSnap.fingerprints || {};
486
+ const currFps = currSnap.fingerprints || {};
487
+
488
+ // No baseline at that cycle ⇒ everything current is new (bootstrap).
489
+ if (!Object.keys(baseFps).length) {
490
+ return Object.keys(currFps).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
491
+ }
492
+
493
+ if (_fts5Supported) {
494
+ try {
495
+ return _sinceCycleFts5(baseFps, currFps, paths(opts).ftsPath);
496
+ } catch {
497
+ return diffFingerprintMaps(baseFps, currFps);
498
+ }
499
+ }
500
+ return diffFingerprintMaps(baseFps, currFps);
501
+ }
502
+
503
+ module.exports = {
504
+ // live snapshot
505
+ writeCurrent,
506
+ readCurrent,
507
+ // history
508
+ rollCycle,
509
+ readCycle,
510
+ listCycles,
511
+ // since-cycle query
512
+ sinceCycle,
513
+ diffFingerprintMaps,
514
+ // backend + paths (for tests / display)
515
+ backendName,
516
+ paths,
517
+ cyclePath,
518
+ cycleFileName,
519
+ resolveRoot,
520
+ // constants
521
+ SCHEMA_VERSION,
522
+ ROLLING_HISTORY,
523
+ };
package/sdk/index.ts CHANGED
@@ -17,3 +17,4 @@ export * from './state/index.ts';
17
17
  export * from './event-stream/index.ts';
18
18
  export * from './errors/index.ts';
19
19
  export * from './cli/index.ts';
20
+ export * from './fingerprint/index.ts';