@hegemonart/get-design-done 1.52.0 → 1.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +49 -0
- package/README.md +2 -0
- package/agents/design-context-reviewer-gate.md +102 -0
- package/agents/design-context-reviewer.md +186 -0
- package/dist/claude-code/.claude/skills/discover/SKILL.md +7 -1
- package/dist/claude-code/.claude/skills/explore/SKILL.md +3 -1
- package/package.json +1 -1
- package/scripts/lib/explore-parallel-runner/index.ts +58 -0
- package/scripts/lib/explore-parallel-runner/types.ts +58 -0
- package/scripts/lib/manifest/skills.json +2 -2
- package/scripts/lib/mappers/compute-batches.mjs +625 -0
- package/scripts/lib/mappers/graph-adjacency.mjs +129 -0
- package/scripts/lib/mappers/incremental-discover.cjs +617 -0
- package/scripts/lib/mappers/incremental-discover.d.cts +133 -0
- package/scripts/lib/mappers/neighbor-map.mjs +0 -0
- package/sdk/cli/index.js +369 -2
- package/sdk/fingerprint/classify.cjs +406 -0
- package/sdk/fingerprint/index.ts +405 -0
- package/sdk/fingerprint/store.cjs +523 -0
- package/sdk/index.ts +1 -0
- package/skills/discover/SKILL.md +7 -1
- package/skills/explore/SKILL.md +3 -1
|
@@ -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
package/skills/discover/SKILL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: discover
|
|
3
3
|
description: "Stage 1.5 of 4 orchestrator that probes Figma / Refero / Pinterest connections, spawns design-context-builder (auto-detect + interview) and (via lazy gate) design-context-checker (6-dimension validator), producing .design/DESIGN-CONTEXT.md. Use after /gdd:scan when a fast-path context build is wanted instead of the full /gdd:explore. Activates for requests involving detecting an existing design system, inventorying tokens and components, or onboarding a brownfield repo."
|
|
4
|
-
argument-hint: "[--auto]"
|
|
4
|
+
argument-hint: "[--auto] [--incremental] [--full]"
|
|
5
5
|
user-invocable: true
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -30,6 +30,12 @@ When `--auto` is passed to the builder: if `tailwind.config.{js,cjs,mjs,ts}` exi
|
|
|
30
30
|
|
|
31
31
|
---
|
|
32
32
|
|
|
33
|
+
## Incremental Mode (Phase 53, default)
|
|
34
|
+
|
|
35
|
+
`--incremental` is the DEFAULT after Phase 53; pass `--full` to opt out and re-map everything. Incremental runs the change classifier FIRST (via the fingerprint store at `.design/fingerprints/`): it fingerprints the current DesignContext graph, diffs each node against the prior cycle, and dispatches mappers per the verdict. SKIP (cosmetic-only / no-op) dispatches 0 mappers; PARTIAL re-maps only the affected community batches; ARCHITECTURE re-batches the dir-reshaped subset; FULL (or `--full`, or a first run with no prior store) re-maps all batches. The batching + classifier engine lives in `explore` (`scripts/lib/explore-parallel-runner` + `scripts/lib/mappers/incremental-discover.cjs`); this flag selects the path. Detail: `./discover-procedure.md` §Incremental Mode.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
33
39
|
## Step 1 - Spawn design-context-builder
|
|
34
40
|
|
|
35
41
|
Spawn `design-context-builder` -> `.design/DESIGN-CONTEXT.md`. The agent auto-detects via grep/glob first and interviews only for areas where auto-detect returned no confident answer. Baseline audit directory chain: `src/` -> `app/` -> `pages/` -> `lib/` -> flag "layout unknown". Common gray areas to probe (Area 7): font-change risk, token-layer introduction risk, component rebuild-vs-restyle. Wait for `## CONTEXT COMPLETE`, then update STATE.md `task_progress = 0.5`. Full prompt: `./discover-procedure.md` §Step 1.
|
package/skills/explore/SKILL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: gdd-explore
|
|
3
3
|
description: "Stage 2 of 5 - unified exploration merging inventory grep + design interview. Probes 6 connections, scans the codebase, conducts the AskUserQuestion interview, and writes .design/DESIGN.md + DESIGN-DEBT.md + DESIGN-CONTEXT.md. Use after /gdd:brief to map the existing system and lock decisions before planning. Activates for requests involving researching design direction, gathering references, or exploring visual options."
|
|
4
|
-
argument-hint: "[--skip-interview] [--skip-scan]"
|
|
4
|
+
argument-hint: "[--skip-interview] [--skip-scan] [--incremental] [--full]"
|
|
5
5
|
tools: Read, Write, Bash, Grep, Glob, Task, AskUserQuestion, mcp__gdd_state__get, mcp__gdd_state__transition_stage, mcp__gdd_state__probe_connections, mcp__gdd_state__update_progress, mcp__gdd_state__set_status, mcp__gdd_state__add_blocker, mcp__gdd_state__checkpoint, mcp__gdd_state__add_decision
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -49,6 +49,8 @@ For each greenfield component in scope: `21st_magic_component_search(component_n
|
|
|
49
49
|
|
|
50
50
|
**Parallelism decision**: read `.design/config.json` + `reference/parallelism-rules.md`. Record verdict via `mcp__gdd_state__set_status` (`"explore_parallel"` / `"explore_serial"`). Parallel -> multiple `Task()` in one response; serial -> sequential.
|
|
51
51
|
|
|
52
|
+
**Incremental batching (Phase 53, default)**: `--incremental` (default; `--full` opts out) runs the change classifier first via the fingerprint store, groups the DesignContext graph into Louvain community batches, and dispatches mappers per the verdict (SKIP=0, PARTIAL=affected batches only, FULL=all). Engine: `scripts/lib/explore-parallel-runner` + `scripts/lib/mappers/incremental-discover.cjs`; dispatch concurrency comes from `concurrency-tuner.cjs`. Detail: `./explore-procedure.md` §Incremental Batching.
|
|
53
|
+
|
|
52
54
|
Run the canonical scan grep/glob inventory (POSIX ERE, preserving PLAT-01/02): component detection (Glob `**/*.{tsx,jsx,vue,svelte}`), color extraction (hex / rgb / hsl / Tailwind arbitrary), typography scan (font-family / Tailwind `font-*` / `text-*`), motion scan (`transition` / `animate-` / `@keyframes` / `framer-motion`), token detection (tailwind.config / CSS custom properties / token JSON), layout detection (ordered fallback `src/` -> `app/` -> `pages/` -> `lib/` -> unknown). Write `.design/DESIGN.md` + `.design/DESIGN-DEBT.md`. Then `mcp__gdd_state__update_progress` for scan progress. Detail: `./explore-procedure.md` §Step 2.
|
|
53
55
|
|
|
54
56
|
**Step 2.x - i18n readiness probe (informational, per D-04)**: check `package.json` deps against `{react-intl, next-intl, i18next, vue-i18n, formatjs, lingui}` -> `framework-managed`; else grep `Intl.(DateTimeFormat|NumberFormat|...)` in `src/` -> `partial`; else `none`. Emit single line `Localization readiness: <state>` in the report. NO gate, NO blocking - surface signal only (D-07). Detail: `./explore-procedure.md` §Step 2.x.
|