@hegemonart/get-design-done 1.50.1 → 1.52.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 (52) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +93 -0
  4. package/README.md +4 -0
  5. package/SKILL.md +4 -1
  6. package/agents/a11y-mapper.md +30 -1
  7. package/agents/component-taxonomy-mapper.md +30 -1
  8. package/agents/design-debt-crawler.md +60 -60
  9. package/agents/design-reflector.md +33 -0
  10. package/agents/design-research-synthesizer.md +27 -1
  11. package/agents/motion-mapper.md +35 -13
  12. package/agents/token-mapper.md +30 -1
  13. package/agents/visual-hierarchy-mapper.md +30 -1
  14. package/dist/claude-code/.claude/skills/apply-reflections/SKILL.md +17 -0
  15. package/dist/claude-code/.claude/skills/context/SKILL.md +137 -0
  16. package/dist/claude-code/.claude/skills/extract-learnings/SKILL.md +16 -0
  17. package/dist/claude-code/.claude/skills/instinct/SKILL.md +111 -0
  18. package/dist/claude-code/.claude/skills/migrate-context/SKILL.md +123 -0
  19. package/dist/claude-code/.claude/skills/progress/SKILL.md +4 -0
  20. package/hooks/gdd-decision-injector.js +115 -6
  21. package/package.json +3 -2
  22. package/reference/design-context-schema.md +159 -0
  23. package/reference/design-context-tag-vocab.md +82 -0
  24. package/reference/instinct-format.md +120 -0
  25. package/reference/registry.json +21 -0
  26. package/reference/schemas/design-context.schema.json +130 -0
  27. package/reference/schemas/events.schema.json +1 -1
  28. package/reference/schemas/instinct.schema.json +91 -0
  29. package/reference/schemas/mcp-gdd-tools.schema.json +34 -1
  30. package/reference/skill-graph.md +4 -1
  31. package/scripts/lib/design-context/extract-a11y.mjs +188 -0
  32. package/scripts/lib/design-context/extract-components.mjs +243 -0
  33. package/scripts/lib/design-context/extract-motion.mjs +248 -0
  34. package/scripts/lib/design-context/extract-tokens.mjs +234 -0
  35. package/scripts/lib/design-context/extract-visual-hierarchy.mjs +178 -0
  36. package/scripts/lib/design-context/integration-map.mjs +251 -0
  37. package/scripts/lib/design-context/merge-fragments.mjs +227 -0
  38. package/scripts/lib/design-context-query.cjs +0 -0
  39. package/scripts/lib/instinct-store.cjs +677 -0
  40. package/scripts/lib/manifest/skills.json +24 -0
  41. package/scripts/lib/mcp-tools-lint/index.cjs +3 -1
  42. package/sdk/mcp/gdd-mcp/schemas/gdd_context_query.schema.json +60 -0
  43. package/sdk/mcp/gdd-mcp/server.js +474 -158
  44. package/sdk/mcp/gdd-mcp/server.ts +9 -5
  45. package/sdk/mcp/gdd-mcp/tools/gdd_context_query.ts +35 -0
  46. package/sdk/mcp/gdd-mcp/tools/index.ts +18 -13
  47. package/skills/apply-reflections/SKILL.md +17 -0
  48. package/skills/context/SKILL.md +137 -0
  49. package/skills/extract-learnings/SKILL.md +16 -0
  50. package/skills/instinct/SKILL.md +111 -0
  51. package/skills/migrate-context/SKILL.md +123 -0
  52. package/skills/progress/SKILL.md +4 -0
@@ -0,0 +1,677 @@
1
+ 'use strict';
2
+ /**
3
+ * scripts/lib/instinct-store.cjs — Phase 51 (Instinct-Based Learnings) store.
4
+ *
5
+ * An "instinct" is an atomic, confidence-weighted YAML unit (a trigger sentence
6
+ * plus a 1-3 paragraph body) learned across design cycles. This module persists
7
+ * instincts, queries them by keyword, promotes project instincts to a global
8
+ * store once they earn cross-project trust, and decays stale ones. See
9
+ * reference/instinct-format.md for the unit format and the promotion / decay
10
+ * rules, and reference/schemas/instinct.schema.json for the frontmatter schema.
11
+ *
12
+ * No new dependency. better-sqlite3 stays a RUNTIME probe (probe-optional.cjs):
13
+ * when it is present AND its FTS5 extension is compiled in, `query` accelerates
14
+ * over a small full-text index; otherwise an in-memory token/substring scan
15
+ * answers the same query. Persistence is always JSON-canonical (an atomic
16
+ * .tmp+rename write), so the FTS5 index is a disposable accelerator, never the
17
+ * source of truth — exactly the Phase 19.5 three-tier optional-SQLite pattern
18
+ * used by design-search.cjs.
19
+ *
20
+ * Purity: no top-level Date.now() / Math.random(). Callers inject `now` (a
21
+ * Date or an ISO string) wherever a timestamp is recorded, so the suite is
22
+ * deterministic. Project-scoped writes resolve through worktree-resolve.cjs so
23
+ * they land in the MAIN checkout, not a throwaway worktree.
24
+ */
25
+
26
+ const fs = require('node:fs');
27
+ const os = require('node:os');
28
+ const path = require('node:path');
29
+ const { spawnSync } = require('node:child_process');
30
+ const crypto = require('node:crypto');
31
+
32
+ const { probeOptional } = require('./probe-optional.cjs');
33
+ const { resolveDesignRoot } = require('./worktree-resolve.cjs');
34
+ const { splitFrontmatter } = require('../generate-skill-frontmatter.cjs');
35
+
36
+ /**
37
+ * Normalize a git remote origin URL so the same logical origin across git@,
38
+ * https, and ssh shapes maps to one string: strip the protocol/host prefix,
39
+ * strip a trailing `.git`, lowercase. This mirrors pseudonymize.cjs's internal
40
+ * normalizeRepoOrigin (that helper is not exported), kept dependency-free here.
41
+ *
42
+ * @param {string} origin
43
+ * @returns {string}
44
+ */
45
+ function normalizeRepoOrigin(origin) {
46
+ if (typeof origin !== 'string' || origin.length === 0) return '';
47
+ let s = origin.trim();
48
+ s = s.replace(/^git@[^:]+:/i, '');
49
+ s = s.replace(/^https?:\/\/[^/]+\//i, '');
50
+ s = s.replace(/^ssh:\/\/(?:[^@]+@)?[^/]+\//i, '');
51
+ s = s.replace(/^git:\/\/[^/]+\//i, '');
52
+ s = s.replace(/\.git$/i, '');
53
+ return s.toLowerCase();
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Constants — the prior, the domain enum, the gate + decay knobs.
58
+ // ---------------------------------------------------------------------------
59
+
60
+ const SCHEMA_VERSION = '51.0';
61
+
62
+ /**
63
+ * Beta(2, 8) prior — posterior mean 0.2. An instinct EARNS trust from repeated
64
+ * cross-cycle observation; it is advisory until real outcomes shift it. Same
65
+ * conservative prior shape as the Phase 38 design_arms store (D-03).
66
+ */
67
+ const INSTINCT_PRIOR = Object.freeze({ alpha: 2, beta: 8 });
68
+
69
+ /** Lifecycle stages an instinct can apply to (aligned to the Phase 50 stages). */
70
+ const DOMAINS = Object.freeze([
71
+ 'intake',
72
+ 'explore',
73
+ 'decide',
74
+ 'build',
75
+ 'verify',
76
+ 'operate',
77
+ 'utility',
78
+ ]);
79
+
80
+ /** Confidence floor/ceiling — a fresh instinct is advisory, none is ever certain. */
81
+ const CONFIDENCE_FLOOR = 0.3;
82
+ const CONFIDENCE_CEILING = 0.9;
83
+
84
+ /** Promotion gate: seen across >=K cycles AND >=M distinct project ids. */
85
+ const PROMOTE_MIN_CYCLES = 2; // K
86
+ const PROMOTE_MIN_PROJECTS = 2; // M
87
+
88
+ /** TTL decay: unsurfaced for >= this many cycles -> confidence *= factor. */
89
+ const DECAY_CYCLES_WINDOW = 6;
90
+ const DECAY_FACTOR = 0.9;
91
+ const ARCHIVE_THRESHOLD = 0.2;
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // better-sqlite3 + FTS5 backend probe (evaluated once at module load).
95
+ // Mirrors design-search.cjs backend selection.
96
+ // ---------------------------------------------------------------------------
97
+
98
+ const Database = probeOptional('better-sqlite3');
99
+
100
+ let _fts5Supported = false;
101
+ if (Database) {
102
+ try {
103
+ const probe = new Database(':memory:');
104
+ probe.exec('CREATE VIRTUAL TABLE _p USING fts5(t)');
105
+ probe.close();
106
+ _fts5Supported = true;
107
+ } catch {
108
+ /* fts5 extension not compiled in — fall back to the JS scan */
109
+ }
110
+ }
111
+
112
+ /** 'fts5' when better-sqlite3+fts5 is available, else the in-memory 'js-scan'. */
113
+ function backendName() {
114
+ return _fts5Supported ? 'fts5' : 'js-scan';
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Time helper — every timestamp flows through an injected `now`.
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /**
122
+ * Coerce an injected `now` into a Date. Accepts a Date, an ISO string, or
123
+ * undefined; throws on undefined so a caller can never silently fall back to a
124
+ * hidden global clock (the purity contract). Tests always pass `now`.
125
+ *
126
+ * @param {Date|string|undefined} now
127
+ * @returns {Date}
128
+ */
129
+ function coerceNow(now) {
130
+ if (now instanceof Date) return now;
131
+ if (typeof now === 'string' && now.length) {
132
+ const d = new Date(now);
133
+ if (!Number.isNaN(d.getTime())) return d;
134
+ }
135
+ throw new Error('instinct-store: a `now` (Date or ISO string) must be injected — no global clock is used');
136
+ }
137
+
138
+ /** ISO date (YYYY-MM-DD) from an injected `now`. */
139
+ function isoDate(now) {
140
+ return coerceNow(now).toISOString().slice(0, 10);
141
+ }
142
+
143
+ /** Whole days between two ISO dates (b - a), floored at 0. */
144
+ function daysBetween(aIso, bIso) {
145
+ const a = Date.parse(aIso + 'T00:00:00Z');
146
+ const b = Date.parse(bIso + 'T00:00:00Z');
147
+ if (Number.isNaN(a) || Number.isNaN(b)) return 0;
148
+ return Math.max(0, Math.round((b - a) / 86400000));
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Paths — project store (worktree-safe) + global store + optional FTS index.
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /**
156
+ * Resolve the on-disk locations for a scope.
157
+ *
158
+ * project: <resolveDesignRoot(baseDir)>/instincts/instincts.json
159
+ * global: <os.homedir()>/.claude/gdd/global-instincts.json
160
+ *
161
+ * `dir` is the directory the store file lives in (where archive/ + the optional
162
+ * FTS index sit alongside). `exec` is forwarded to worktree-resolve for tests.
163
+ *
164
+ * @param {{ scope?: 'project'|'global', baseDir?: string, exec?: Function }} [opts]
165
+ * @returns {{ scope: string, dir: string, file: string, archiveDir: string, ftsPath: string }}
166
+ */
167
+ function paths(opts = {}) {
168
+ const scope = opts.scope || 'project';
169
+ if (scope === 'global') {
170
+ const dir = path.join(os.homedir(), '.claude', 'gdd');
171
+ return {
172
+ scope,
173
+ dir,
174
+ file: path.join(dir, 'global-instincts.json'),
175
+ archiveDir: path.join(dir, 'instincts', 'archive'),
176
+ ftsPath: path.join(dir, 'global-instincts.fts.db'),
177
+ };
178
+ }
179
+ const base = opts.baseDir || process.cwd();
180
+ const dir = path.join(resolveDesignRoot(base, opts.exec), 'instincts');
181
+ return {
182
+ scope,
183
+ dir,
184
+ file: path.join(dir, 'instincts.json'),
185
+ archiveDir: path.join(dir, 'archive'),
186
+ ftsPath: path.join(dir, 'instincts.fts.db'),
187
+ };
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Load / save — JSON-canonical, atomic .tmp+rename (mirrors ds-arms save()).
192
+ // ---------------------------------------------------------------------------
193
+
194
+ function load(opts = {}) {
195
+ const { file } = paths(opts);
196
+ if (!fs.existsSync(file)) return { schema_version: SCHEMA_VERSION, instincts: [] };
197
+ try {
198
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
199
+ if (!Array.isArray(data.instincts)) data.instincts = [];
200
+ return data;
201
+ } catch {
202
+ return { schema_version: SCHEMA_VERSION, instincts: [] };
203
+ }
204
+ }
205
+
206
+ function save(store, opts = {}) {
207
+ const { file, dir } = paths(opts);
208
+ fs.mkdirSync(dir, { recursive: true });
209
+ store.schema_version = SCHEMA_VERSION;
210
+ const tmp = file + '.tmp';
211
+ fs.writeFileSync(tmp, JSON.stringify(store, null, 2) + '\n');
212
+ fs.renameSync(tmp, file);
213
+ // The FTS index (if any) is now stale; drop it so the next query rebuilds it.
214
+ _invalidateFts(opts);
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // deriveProjectId — sha8 of the normalized git origin; never throws.
219
+ // Mirrors pseudonymize.stablePseudonym shape (normalizeRepoOrigin + sha256[:8]).
220
+ // ---------------------------------------------------------------------------
221
+
222
+ /**
223
+ * Default git runner: `git -C <cwd> remote get-url origin` -> trimmed stdout,
224
+ * or null on any failure (no repo, git missing, no origin remote).
225
+ *
226
+ * @param {string} cwd
227
+ * @returns {string|null}
228
+ */
229
+ function defaultOrigin(cwd) {
230
+ try {
231
+ const res = spawnSync('git', ['-C', cwd, 'remote', 'get-url', 'origin'], {
232
+ encoding: 'utf8',
233
+ windowsHide: true,
234
+ });
235
+ if (!res || res.status !== 0 || typeof res.stdout !== 'string') return null;
236
+ const out = res.stdout.trim();
237
+ return out.length ? out : null;
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Derive a stable 8-char hex project id from the git origin URL. The same
245
+ * logical origin across git@/https/ssh shapes maps to one id (normalizeRepoOrigin).
246
+ * Returns the sentinel 'unknown' when no origin can be resolved. NEVER throws.
247
+ *
248
+ * `exec` is an injectable `(cmd, args) => string` git runner (matching the
249
+ * worktree-resolve contract) so tests need no real repo.
250
+ *
251
+ * @param {string} [cwd=process.cwd()]
252
+ * @param {(cmd: string, args: string[]) => string} [exec]
253
+ * @returns {string} 8-char hex, or 'unknown'
254
+ */
255
+ function deriveProjectId(cwd = process.cwd(), exec) {
256
+ let origin = null;
257
+ try {
258
+ if (typeof exec === 'function') {
259
+ const out = exec('git', ['-C', cwd, 'remote', 'get-url', 'origin']);
260
+ origin = typeof out === 'string' && out.trim().length ? out.trim() : null;
261
+ } else {
262
+ origin = defaultOrigin(cwd);
263
+ }
264
+ } catch {
265
+ origin = null;
266
+ }
267
+ if (!origin) return 'unknown';
268
+ const normalized = normalizeRepoOrigin(origin);
269
+ if (!normalized) return 'unknown';
270
+ return crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 8);
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // add / get / list — the CRUD surface.
275
+ // ---------------------------------------------------------------------------
276
+
277
+ /** Find the index of a unit by id in a store's instincts array, or -1. */
278
+ function _indexOf(store, id) {
279
+ return store.instincts.findIndex((u) => u && u.id === id);
280
+ }
281
+
282
+ /**
283
+ * Persist an instinct. Stamps first_seen/last_seen from the injected `now` and
284
+ * seeds cycles_seen=1 when absent. Project-scoped units record their project_id
285
+ * in the project_ids set. Replaces an existing unit with the same id. Atomic.
286
+ *
287
+ * @param {object} unit the instinct frontmatter object (id/trigger/confidence/domain/...)
288
+ * @param {{ scope?: string, baseDir?: string, now?: Date|string, exec?: Function }} [opts]
289
+ * @returns {object} the stored unit
290
+ */
291
+ function add(unit, opts = {}) {
292
+ if (!unit || typeof unit !== 'object' || typeof unit.id !== 'string' || !unit.id.length) {
293
+ throw new Error('instinct-store.add: unit must be an object with a non-empty string id');
294
+ }
295
+ const scope = opts.scope || 'project';
296
+ const date = isoDate(opts.now);
297
+ const store = load({ ...opts, scope });
298
+
299
+ const stored = { ...unit, scope };
300
+ if (typeof stored.first_seen !== 'string') stored.first_seen = date;
301
+ stored.last_seen = typeof stored.last_seen === 'string' ? stored.last_seen : date;
302
+ if (typeof stored.cycles_seen !== 'number' || stored.cycles_seen < 1) stored.cycles_seen = 1;
303
+
304
+ // Track the distinct-project set used by the promotion gate.
305
+ const ids = Array.isArray(stored.project_ids) ? stored.project_ids.slice() : [];
306
+ if (typeof stored.project_id === 'string' && stored.project_id && !ids.includes(stored.project_id)) {
307
+ ids.push(stored.project_id);
308
+ }
309
+ stored.project_ids = ids;
310
+
311
+ const existing = _indexOf(store, stored.id);
312
+ if (existing >= 0) store.instincts[existing] = stored;
313
+ else store.instincts.push(stored);
314
+
315
+ save(store, { ...opts, scope });
316
+ return stored;
317
+ }
318
+
319
+ /**
320
+ * Return all units for a scope, optionally filtered by domain, sorted by
321
+ * last_seen DESC (most-recently-surfaced first).
322
+ *
323
+ * @param {{ scope?: string, domain?: string, baseDir?: string, exec?: Function }} [opts]
324
+ * @returns {object[]}
325
+ */
326
+ function list(opts = {}) {
327
+ const store = load(opts);
328
+ let units = store.instincts.slice();
329
+ if (opts.domain) units = units.filter((u) => u.domain === opts.domain);
330
+ units.sort((a, b) => String(b.last_seen || '').localeCompare(String(a.last_seen || '')));
331
+ return units;
332
+ }
333
+
334
+ /**
335
+ * Fetch one unit by id, or null.
336
+ *
337
+ * @param {string} id
338
+ * @param {{ scope?: string, baseDir?: string, exec?: Function }} [opts]
339
+ * @returns {object|null}
340
+ */
341
+ function get(id, opts = {}) {
342
+ const store = load(opts);
343
+ const i = _indexOf(store, id);
344
+ return i >= 0 ? store.instincts[i] : null;
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // query — ranked keyword match over trigger + body + domain.
349
+ // FTS5 fast path when better-sqlite3+fts5 is available, else an in-memory
350
+ // token/substring scan (the path CI exercises). Both return the SAME shape.
351
+ // ---------------------------------------------------------------------------
352
+
353
+ /** Lowercased searchable text for a unit: trigger + body + domain + id. */
354
+ function _haystack(unit) {
355
+ return [unit.trigger, unit.body, unit.domain, unit.id]
356
+ .filter((s) => typeof s === 'string')
357
+ .join('\n')
358
+ .toLowerCase();
359
+ }
360
+
361
+ /** In-memory ranking: term-frequency over tokens, with a substring fallback. */
362
+ function _scoreJs(keyword, unit) {
363
+ const hay = _haystack(unit);
364
+ const terms = String(keyword).toLowerCase().split(/\s+/).filter(Boolean);
365
+ if (!terms.length) return 0;
366
+ let score = 0;
367
+ for (const t of terms) {
368
+ // Count occurrences (term frequency). split().length-1 = occurrence count.
369
+ const occ = hay.split(t).length - 1;
370
+ if (occ > 0) score += occ;
371
+ }
372
+ // A trigger hit is worth more than a body hit — weight trigger matches.
373
+ const trig = typeof unit.trigger === 'string' ? unit.trigger.toLowerCase() : '';
374
+ for (const t of terms) if (trig.includes(t)) score += 2;
375
+ return score;
376
+ }
377
+
378
+ function _queryJs(keyword, opts) {
379
+ const limit = opts.limit ?? 3;
380
+ const store = load(opts);
381
+ const scored = store.instincts
382
+ .map((u) => ({ u, s: _scoreJs(keyword, u) }))
383
+ .filter((r) => r.s > 0);
384
+ scored.sort((a, b) => b.s - a.s || String(b.u.last_seen || '').localeCompare(String(a.u.last_seen || '')));
385
+ return scored.slice(0, limit).map((r) => r.u);
386
+ }
387
+
388
+ /** Drop a stale FTS index file (best-effort) so the next query rebuilds it. */
389
+ function _invalidateFts(opts) {
390
+ if (!_fts5Supported) return;
391
+ try {
392
+ const { ftsPath } = paths(opts);
393
+ if (fs.existsSync(ftsPath)) fs.rmSync(ftsPath, { force: true });
394
+ } catch {
395
+ /* best-effort */
396
+ }
397
+ }
398
+
399
+ function _queryFts5(keyword, opts) {
400
+ const limit = opts.limit ?? 3;
401
+ const store = load(opts);
402
+ if (!store.instincts.length) return [];
403
+ const { ftsPath, dir } = paths(opts);
404
+ fs.mkdirSync(dir, { recursive: true });
405
+
406
+ // Build a fresh in-memory-ish index keyed by array position. We rebuild each
407
+ // call (instinct stores are small) so the index can never drift from JSON.
408
+ const db = new Database(ftsPath);
409
+ try {
410
+ db.exec("DROP TABLE IF EXISTS units");
411
+ db.exec("CREATE VIRTUAL TABLE units USING fts5(idx UNINDEXED, body, tokenize='trigram')");
412
+ const insert = db.prepare('INSERT INTO units(idx, body) VALUES (?, ?)');
413
+ const txn = db.transaction((rows) => {
414
+ for (const r of rows) insert.run(r.idx, r.body);
415
+ });
416
+ txn(store.instincts.map((u, idx) => ({ idx, body: _haystack(u) })));
417
+
418
+ const terms = String(keyword).toLowerCase().split(/\s+/).filter(Boolean);
419
+ if (!terms.length) return [];
420
+ const matchExpr = terms.map((t) => `"${t.replace(/"/g, '""')}"`).join(' OR ');
421
+ const rows = db
422
+ .prepare('SELECT idx FROM units WHERE units MATCH ? ORDER BY rank LIMIT ?')
423
+ .all(matchExpr, limit);
424
+ return rows.map((r) => store.instincts[r.idx]).filter(Boolean);
425
+ } finally {
426
+ db.close();
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Rank instincts matching `keyword` over trigger + body + domain. Returns at
432
+ * most `limit` units, best match first. Uses the FTS5 backend when available,
433
+ * else the in-memory scan; both produce the same ranked shape.
434
+ *
435
+ * @param {string} keyword
436
+ * @param {{ scope?: string, baseDir?: string, limit?: number, exec?: Function }} [opts]
437
+ * @returns {object[]}
438
+ */
439
+ function query(keyword, opts = {}) {
440
+ if (typeof keyword !== 'string' || !keyword.trim()) return [];
441
+ if (_fts5Supported) {
442
+ try {
443
+ return _queryFts5(keyword, opts);
444
+ } catch {
445
+ // A corrupt/locked index must never break recall — degrade to the scan.
446
+ return _queryJs(keyword, opts);
447
+ }
448
+ }
449
+ return _queryJs(keyword, opts);
450
+ }
451
+
452
+ // ---------------------------------------------------------------------------
453
+ // touch — bump last_seen + cycles_seen, widen project_ids, reset decay.
454
+ // ---------------------------------------------------------------------------
455
+
456
+ /**
457
+ * Record that an instinct was surfaced again: bump last_seen to `now`,
458
+ * increment cycles_seen, and (project scope) add the current project_id to the
459
+ * unit's project_ids set. Resets the TTL decay window. Atomic.
460
+ *
461
+ * @param {string} id
462
+ * @param {{ scope?: string, baseDir?: string, now?: Date|string, projectId?: string, exec?: Function }} [opts]
463
+ * @returns {object|null} the touched unit, or null if not found
464
+ */
465
+ function touch(id, opts = {}) {
466
+ const scope = opts.scope || 'project';
467
+ const store = load({ ...opts, scope });
468
+ const i = _indexOf(store, id);
469
+ if (i < 0) return null;
470
+ const unit = store.instincts[i];
471
+ unit.last_seen = isoDate(opts.now);
472
+ unit.cycles_seen = (typeof unit.cycles_seen === 'number' ? unit.cycles_seen : 0) + 1;
473
+
474
+ const pid = typeof opts.projectId === 'string' && opts.projectId ? opts.projectId : unit.project_id;
475
+ if (typeof pid === 'string' && pid) {
476
+ const ids = Array.isArray(unit.project_ids) ? unit.project_ids : [];
477
+ if (!ids.includes(pid)) ids.push(pid);
478
+ unit.project_ids = ids;
479
+ }
480
+ save(store, { ...opts, scope });
481
+ return unit;
482
+ }
483
+
484
+ // ---------------------------------------------------------------------------
485
+ // promote — move a project instinct to the global store once the gate passes.
486
+ // ---------------------------------------------------------------------------
487
+
488
+ /**
489
+ * Promote a project instinct to the global store. Gate: cycles_seen >= K (2)
490
+ * AND it has been seen across >= M (2) distinct project_ids. Throws a clear
491
+ * Error if the gate is unmet. On promotion the unit is re-scoped to global,
492
+ * seeded with the Beta(2,8) prior class, and removed from the project store.
493
+ *
494
+ * @param {string} id
495
+ * @param {{ baseDir?: string, now?: Date|string, exec?: Function }} [opts]
496
+ * @returns {object} the promoted (global-scoped) unit
497
+ */
498
+ function promote(id, opts = {}) {
499
+ const projectStore = load({ ...opts, scope: 'project' });
500
+ const i = _indexOf(projectStore, id);
501
+ if (i < 0) throw new Error(`instinct-store.promote: no project instinct with id "${id}"`);
502
+ const unit = projectStore.instincts[i];
503
+
504
+ const cycles = typeof unit.cycles_seen === 'number' ? unit.cycles_seen : 0;
505
+ const distinctProjects = Array.isArray(unit.project_ids) ? new Set(unit.project_ids).size : 0;
506
+ if (cycles < PROMOTE_MIN_CYCLES || distinctProjects < PROMOTE_MIN_PROJECTS) {
507
+ throw new Error(
508
+ `instinct-store.promote: "${id}" fails the promotion gate ` +
509
+ `(cycles_seen=${cycles} needs >=${PROMOTE_MIN_CYCLES}, ` +
510
+ `distinct project_ids=${distinctProjects} needs >=${PROMOTE_MIN_PROJECTS})`,
511
+ );
512
+ }
513
+
514
+ // Build the global-scoped unit: drop the single-origin project_id, apply the
515
+ // Beta(2,8) prior class, keep the cross-project provenance set.
516
+ const promoted = {
517
+ ...unit,
518
+ scope: 'global',
519
+ alpha: INSTINCT_PRIOR.alpha,
520
+ beta: INSTINCT_PRIOR.beta,
521
+ prior_class: 'instinct',
522
+ last_seen: isoDate(opts.now),
523
+ };
524
+ delete promoted.project_id;
525
+
526
+ const globalStore = load({ ...opts, scope: 'global' });
527
+ const gi = _indexOf(globalStore, id);
528
+ if (gi >= 0) globalStore.instincts[gi] = promoted;
529
+ else globalStore.instincts.push(promoted);
530
+ save(globalStore, { ...opts, scope: 'global' });
531
+
532
+ // Remove from the project store now that it is global.
533
+ projectStore.instincts.splice(i, 1);
534
+ save(projectStore, { ...opts, scope: 'project' });
535
+
536
+ return promoted;
537
+ }
538
+
539
+ // ---------------------------------------------------------------------------
540
+ // decay — TTL: unsurfaced for >= cyclesWindow -> confidence *= 0.9; archive <0.2.
541
+ // ---------------------------------------------------------------------------
542
+
543
+ /**
544
+ * Apply TTL decay across a scope. An instinct not surfaced within the decay
545
+ * window has its confidence multiplied by 0.9. Any instinct whose confidence
546
+ * falls below 0.2 is archived (moved to <store-dir>/archive/<id>.json) and
547
+ * removed from the live store. The decay window is measured in CYCLES; one
548
+ * cycle is treated as one day for the staleness math, and tests inject `now`
549
+ * to fast-forward. Atomic.
550
+ *
551
+ * @param {{ scope?: string, baseDir?: string, now?: Date|string, cyclesWindow?: number, exec?: Function }} [opts]
552
+ * @returns {{ decayed: number, archived: number }}
553
+ */
554
+ function decay(opts = {}) {
555
+ const scope = opts.scope || 'project';
556
+ const window = typeof opts.cyclesWindow === 'number' ? opts.cyclesWindow : DECAY_CYCLES_WINDOW;
557
+ const today = isoDate(opts.now);
558
+ const store = load({ ...opts, scope });
559
+
560
+ let decayed = 0;
561
+ let archived = 0;
562
+ const survivors = [];
563
+ const toArchive = [];
564
+
565
+ for (const unit of store.instincts) {
566
+ const last = typeof unit.last_seen === 'string' ? unit.last_seen : today;
567
+ const stale = daysBetween(last, today) >= window;
568
+ if (stale && typeof unit.confidence === 'number') {
569
+ unit.confidence = Math.max(0, unit.confidence * DECAY_FACTOR);
570
+ decayed += 1;
571
+ }
572
+ if (typeof unit.confidence === 'number' && unit.confidence < ARCHIVE_THRESHOLD) {
573
+ toArchive.push(unit);
574
+ archived += 1;
575
+ } else {
576
+ survivors.push(unit);
577
+ }
578
+ }
579
+
580
+ if (archived > 0) {
581
+ const { archiveDir } = paths({ ...opts, scope });
582
+ fs.mkdirSync(archiveDir, { recursive: true });
583
+ for (const unit of toArchive) {
584
+ const dest = path.join(archiveDir, `${unit.id}.json`);
585
+ const tmp = dest + '.tmp';
586
+ fs.writeFileSync(tmp, JSON.stringify({ ...unit, archived_at: today }, null, 2) + '\n');
587
+ fs.renameSync(tmp, dest);
588
+ }
589
+ }
590
+
591
+ store.instincts = survivors;
592
+ save(store, { ...opts, scope });
593
+ return { decayed, archived };
594
+ }
595
+
596
+ // ---------------------------------------------------------------------------
597
+ // parseUnit — read a YAML instinct unit (frontmatter + body) into an object.
598
+ // Reuses splitFrontmatter from generate-skill-frontmatter.cjs. That helper
599
+ // calls process.exit on malformed input, so we guard the fences first and
600
+ // return null rather than letting the process die.
601
+ // ---------------------------------------------------------------------------
602
+
603
+ /** Minimal YAML-scalar coercion for the flat frontmatter our schema allows. */
604
+ function _coerceScalar(raw) {
605
+ const v = raw.trim();
606
+ if (v === '') return '';
607
+ if (/^-?\d+$/.test(v)) return parseInt(v, 10);
608
+ if (/^-?\d*\.\d+$/.test(v)) return parseFloat(v);
609
+ if (v === 'true') return true;
610
+ if (v === 'false') return false;
611
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
612
+ return v.slice(1, -1);
613
+ }
614
+ // inline flow array: [a, b, c]
615
+ if (v.startsWith('[') && v.endsWith(']')) {
616
+ const inner = v.slice(1, -1).trim();
617
+ if (!inner) return [];
618
+ return inner.split(',').map((s) => _coerceScalar(s));
619
+ }
620
+ return v;
621
+ }
622
+
623
+ /**
624
+ * Parse an instinct unit document (YAML frontmatter + markdown body) into a
625
+ * frontmatter object with a `body` field appended. Returns null when the text
626
+ * is not a well-formed frontmatter document (no fences) rather than throwing.
627
+ *
628
+ * @param {string} text
629
+ * @param {string} [id='instinct'] label used in error context
630
+ * @returns {object|null}
631
+ */
632
+ function parseUnit(text, id = 'instinct') {
633
+ if (typeof text !== 'string') return null;
634
+ const norm = text.replace(/\r\n/g, '\n');
635
+ // Guard the fences ourselves — splitFrontmatter exits the process on bad input.
636
+ if (!norm.startsWith('---\n') || norm.indexOf('\n---\n', 4) === -1) return null;
637
+ const { fmLines, body } = splitFrontmatter(norm, id);
638
+ const obj = {};
639
+ for (const line of fmLines) {
640
+ const m = /^([A-Za-z][\w-]*):(.*)$/.exec(line);
641
+ if (!m) continue;
642
+ obj[m[1]] = _coerceScalar(m[2]);
643
+ }
644
+ obj.body = body.trim();
645
+ return obj;
646
+ }
647
+
648
+ module.exports = {
649
+ // CRUD + recall
650
+ add,
651
+ list,
652
+ query,
653
+ get,
654
+ // lifecycle
655
+ promote,
656
+ touch,
657
+ decay,
658
+ // identity + parsing
659
+ deriveProjectId,
660
+ parseUnit,
661
+ // backend + helpers
662
+ backendName,
663
+ load,
664
+ save,
665
+ paths,
666
+ // constants
667
+ INSTINCT_PRIOR,
668
+ DOMAINS,
669
+ SCHEMA_VERSION,
670
+ CONFIDENCE_FLOOR,
671
+ CONFIDENCE_CEILING,
672
+ PROMOTE_MIN_CYCLES,
673
+ PROMOTE_MIN_PROJECTS,
674
+ DECAY_CYCLES_WINDOW,
675
+ DECAY_FACTOR,
676
+ ARCHIVE_THRESHOLD,
677
+ };