@contentful/experience-design-system-cli 2.12.0 → 2.12.1-dev-build-589b615.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 (142) hide show
  1. package/README.md +214 -357
  2. package/dist/package.json +1 -1
  3. package/dist/src/analyze/extract/astro.js +9 -0
  4. package/dist/src/analyze/extract/react.js +23 -0
  5. package/dist/src/analyze/extract/stencil.js +3 -0
  6. package/dist/src/analyze/extract/vue.js +6 -0
  7. package/dist/src/analyze/extract/web-components.js +10 -0
  8. package/dist/src/analyze/pre-classify.d.ts +1 -1
  9. package/dist/src/analyze/pre-classify.js +3 -3
  10. package/dist/src/analyze/select/persistence.d.ts +13 -0
  11. package/dist/src/analyze/select/persistence.js +38 -0
  12. package/dist/src/analyze/select/preview-annotations.d.ts +26 -0
  13. package/dist/src/analyze/select/preview-annotations.js +62 -0
  14. package/dist/src/analyze/select/tui/App.js +5 -2
  15. package/dist/src/analyze/select/tui/components/ComponentRationalePanel.d.ts +30 -0
  16. package/dist/src/analyze/select/tui/components/ComponentRationalePanel.js +137 -0
  17. package/dist/src/analyze/select/tui/components/FieldEditor.d.ts +58 -1
  18. package/dist/src/analyze/select/tui/components/FieldEditor.js +637 -161
  19. package/dist/src/analyze/select/tui/components/FinalizeDialog.js +1 -1
  20. package/dist/src/analyze/select/tui/components/JsonPanel.js +5 -1
  21. package/dist/src/analyze/select/tui/components/RationalePanel.d.ts +34 -0
  22. package/dist/src/analyze/select/tui/components/RationalePanel.js +92 -0
  23. package/dist/src/analyze/select/tui/components/Sidebar.d.ts +13 -1
  24. package/dist/src/analyze/select/tui/components/Sidebar.js +27 -3
  25. package/dist/src/analyze/select/tui/hooks/scroll-offset.d.ts +29 -0
  26. package/dist/src/analyze/select/tui/hooks/scroll-offset.js +26 -0
  27. package/dist/src/analyze/select-agent/command.d.ts +2 -0
  28. package/dist/src/analyze/select-agent/command.js +259 -45
  29. package/dist/src/analyze/select-agent/show-rationale.d.ts +35 -0
  30. package/dist/src/analyze/select-agent/show-rationale.js +92 -0
  31. package/dist/src/apply/command.js +7 -3
  32. package/dist/src/apply/tui/ServerApplyView.d.ts +3 -1
  33. package/dist/src/apply/tui/ServerApplyView.js +3 -2
  34. package/dist/src/credentials-store.d.ts +5 -0
  35. package/dist/src/credentials-store.js +7 -1
  36. package/dist/src/generate/agent-runner.d.ts +14 -0
  37. package/dist/src/generate/agent-runner.js +16 -0
  38. package/dist/src/generate/command.d.ts +6 -0
  39. package/dist/src/generate/command.js +45 -10
  40. package/dist/src/generate/progress.d.ts +9 -0
  41. package/dist/src/generate/progress.js +11 -0
  42. package/dist/src/generate/prompt-builder.d.ts +8 -0
  43. package/dist/src/generate/prompt-builder.js +22 -11
  44. package/dist/src/import/agent-model-resolve.d.ts +23 -0
  45. package/dist/src/import/agent-model-resolve.js +35 -0
  46. package/dist/src/import/auto-filter-resolve.d.ts +12 -0
  47. package/dist/src/import/auto-filter-resolve.js +16 -0
  48. package/dist/src/import/command.js +248 -8
  49. package/dist/src/import/orchestrator.d.ts +2 -0
  50. package/dist/src/import/orchestrator.js +11 -3
  51. package/dist/src/import/print-prompt.d.ts +35 -0
  52. package/dist/src/import/print-prompt.js +31 -0
  53. package/dist/src/import/tui/CustomPromptBanner.d.ts +11 -0
  54. package/dist/src/import/tui/CustomPromptBanner.js +7 -0
  55. package/dist/src/import/tui/WizardApp.d.ts +112 -8
  56. package/dist/src/import/tui/WizardApp.js +814 -333
  57. package/dist/src/import/tui/auto-filter-error.d.ts +2 -0
  58. package/dist/src/import/tui/auto-filter-error.js +29 -0
  59. package/dist/src/import/tui/final-review-host.d.ts +15 -0
  60. package/dist/src/import/tui/final-review-host.js +20 -0
  61. package/dist/src/import/tui/push-decision-gate-helpers.d.ts +8 -0
  62. package/dist/src/import/tui/push-decision-gate-helpers.js +8 -0
  63. package/dist/src/import/tui/push-progress.d.ts +23 -0
  64. package/dist/src/import/tui/push-progress.js +14 -0
  65. package/dist/src/import/tui/run-print-files-helpers.d.ts +26 -0
  66. package/dist/src/import/tui/run-print-files-helpers.js +8 -0
  67. package/dist/src/import/tui/run-teaser.d.ts +5 -0
  68. package/dist/src/import/tui/run-teaser.js +9 -0
  69. package/dist/src/import/tui/runLivePreview.d.ts +35 -0
  70. package/dist/src/import/tui/runLivePreview.js +73 -0
  71. package/dist/src/import/tui/runScopeGate.d.ts +22 -0
  72. package/dist/src/import/tui/runScopeGate.js +28 -0
  73. package/dist/src/import/tui/scope-gate-host.d.ts +21 -0
  74. package/dist/src/import/tui/scope-gate-host.js +20 -0
  75. package/dist/src/import/tui/spawn-generate.d.ts +33 -0
  76. package/dist/src/import/tui/spawn-generate.js +53 -0
  77. package/dist/src/import/tui/steps/CredentialsStep.d.ts +25 -1
  78. package/dist/src/import/tui/steps/CredentialsStep.js +31 -3
  79. package/dist/src/import/tui/steps/DoneStep.d.ts +6 -1
  80. package/dist/src/import/tui/steps/DoneStep.js +10 -4
  81. package/dist/src/import/tui/steps/GenerateReviewStep.d.ts +29 -2
  82. package/dist/src/import/tui/steps/GenerateReviewStep.js +412 -48
  83. package/dist/src/import/tui/steps/PushDecisionGateStep.d.ts +19 -0
  84. package/dist/src/import/tui/steps/PushDecisionGateStep.js +76 -0
  85. package/dist/src/import/tui/steps/PushingStep.d.ts +10 -0
  86. package/dist/src/import/tui/steps/PushingStep.js +40 -0
  87. package/dist/src/import/tui/steps/ScopeGateStep.d.ts +23 -0
  88. package/dist/src/import/tui/steps/ScopeGateStep.js +243 -0
  89. package/dist/src/import/tui/steps/TokenInputStep.js +20 -3
  90. package/dist/src/import/tui/useLivePreview.d.ts +35 -0
  91. package/dist/src/import/tui/useLivePreview.js +120 -0
  92. package/dist/src/import/tui/wizard-generate-progress.d.ts +21 -0
  93. package/dist/src/import/tui/wizard-generate-progress.js +25 -0
  94. package/dist/src/import/tui/wizard-save-flow.d.ts +37 -0
  95. package/dist/src/import/tui/wizard-save-flow.js +20 -0
  96. package/dist/src/import/tui/wizard-state-transitions.d.ts +80 -0
  97. package/dist/src/import/tui/wizard-state-transitions.js +79 -0
  98. package/dist/src/lib/contentful-urls.d.ts +34 -0
  99. package/dist/src/lib/contentful-urls.js +41 -0
  100. package/dist/src/program.js +2 -0
  101. package/dist/src/runs/export-helpers.d.ts +30 -0
  102. package/dist/src/runs/export-helpers.js +59 -0
  103. package/dist/src/runs/fingerprint.d.ts +47 -0
  104. package/dist/src/runs/fingerprint.js +75 -0
  105. package/dist/src/runs/ls-command.d.ts +12 -0
  106. package/dist/src/runs/ls-command.js +137 -0
  107. package/dist/src/runs/modify-launcher.d.ts +24 -0
  108. package/dist/src/runs/modify-launcher.js +36 -0
  109. package/dist/src/runs/path-prompt.d.ts +8 -0
  110. package/dist/src/runs/path-prompt.js +72 -0
  111. package/dist/src/runs/push-creds-prompt.d.ts +25 -0
  112. package/dist/src/runs/push-creds-prompt.js +39 -0
  113. package/dist/src/runs/push-helpers.d.ts +29 -0
  114. package/dist/src/runs/push-helpers.js +69 -0
  115. package/dist/src/runs/replay-helpers.d.ts +67 -0
  116. package/dist/src/runs/replay-helpers.js +148 -0
  117. package/dist/src/runs/resolve-run-target.d.ts +12 -0
  118. package/dist/src/runs/resolve-run-target.js +45 -0
  119. package/dist/src/runs/run-picker-mount.d.ts +46 -0
  120. package/dist/src/runs/run-picker-mount.js +72 -0
  121. package/dist/src/runs/run-picker.d.ts +17 -0
  122. package/dist/src/runs/run-picker.js +145 -0
  123. package/dist/src/runs/save-conflict.d.ts +8 -0
  124. package/dist/src/runs/save-conflict.js +56 -0
  125. package/dist/src/runs/save-path-resolver.d.ts +55 -0
  126. package/dist/src/runs/save-path-resolver.js +71 -0
  127. package/dist/src/runs/staleness.d.ts +38 -0
  128. package/dist/src/runs/staleness.js +140 -0
  129. package/dist/src/runs/store.d.ts +78 -0
  130. package/dist/src/runs/store.js +136 -0
  131. package/dist/src/session/cache-keys.d.ts +15 -0
  132. package/dist/src/session/cache-keys.js +38 -0
  133. package/dist/src/session/db.d.ts +89 -3
  134. package/dist/src/session/db.js +447 -104
  135. package/dist/src/setup/auto-filter-prompt.d.ts +13 -0
  136. package/dist/src/setup/auto-filter-prompt.js +24 -0
  137. package/dist/src/setup/command.d.ts +7 -0
  138. package/dist/src/setup/command.js +58 -2
  139. package/dist/src/types.d.ts +6 -0
  140. package/package.json +2 -2
  141. package/skills/generate-components.md +54 -6
  142. package/skills/select-components.md +13 -4
@@ -1,5 +1,5 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
- import { mkdirSync } from 'node:fs';
2
+ import { mkdirSync, readFileSync } from 'node:fs';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { homedir } from 'node:os';
5
5
  import { dirname, resolve } from 'node:path';
@@ -46,23 +46,28 @@ CREATE TABLE IF NOT EXISTS raw_components (
46
46
  extraction_confidence INTEGER,
47
47
  review_reasons TEXT NOT NULL DEFAULT '[]',
48
48
  needs_review INTEGER NOT NULL DEFAULT 0,
49
+ source_path TEXT,
50
+ reject_reason TEXT,
49
51
  PRIMARY KEY (session_id, component_id)
50
52
  );
51
53
 
52
54
  CREATE TABLE IF NOT EXISTS raw_props (
53
- session_id TEXT NOT NULL,
54
- component_id TEXT NOT NULL,
55
- name TEXT NOT NULL,
56
- type TEXT NOT NULL,
57
- required INTEGER NOT NULL CHECK (required IN (0, 1)),
58
- category TEXT CHECK (category IN ('content', 'design', 'state')),
59
- default_value TEXT,
60
- description TEXT,
61
- token_reference TEXT,
62
- position INTEGER NOT NULL,
63
- cdf_type TEXT,
64
- cdf_category TEXT CHECK (cdf_category IN ('content', 'design', 'state')),
65
- cdf_token_kind TEXT,
55
+ session_id TEXT NOT NULL,
56
+ component_id TEXT NOT NULL,
57
+ name TEXT NOT NULL,
58
+ type TEXT NOT NULL,
59
+ required INTEGER NOT NULL CHECK (required IN (0, 1)),
60
+ category TEXT CHECK (category IN ('content', 'design', 'state')),
61
+ default_value TEXT,
62
+ description TEXT,
63
+ token_reference TEXT,
64
+ position INTEGER NOT NULL,
65
+ cdf_type TEXT,
66
+ cdf_category TEXT CHECK (cdf_category IN ('content', 'design', 'state')),
67
+ cdf_token_kind TEXT,
68
+ rationale TEXT,
69
+ source_start_line INTEGER,
70
+ source_end_line INTEGER,
66
71
  PRIMARY KEY (session_id, component_id, name),
67
72
  FOREIGN KEY (session_id, component_id) REFERENCES raw_components(session_id, component_id) ON DELETE CASCADE
68
73
  );
@@ -155,6 +160,21 @@ export function openPipelineDb(dbPath) {
155
160
  mkdirSync(dirname(path), { recursive: true });
156
161
  try {
157
162
  const db = new DatabaseSync(path);
163
+ // Configure connection-level pragmas BEFORE running schema/migrations.
164
+ // - journal_mode=WAL: persistent on the DB file; readers don't block writers
165
+ // and vice versa. (Also set in SCHEMA, but harmless to assert per-open.)
166
+ // - busy_timeout: per-connection; SQLite waits for a lock instead of
167
+ // immediately throwing 'database is locked'. Critical when the wizard
168
+ // main process and select-agent subprocesses contend on writes.
169
+ // - synchronous=NORMAL: safe under WAL, faster than FULL.
170
+ // busy_timeout MUST come first. PRAGMA journal_mode = WAL needs a
171
+ // RESERVED lock briefly even when the DB is already WAL; if the
172
+ // select-agent child is mid-write at that moment, the wizard's open
173
+ // fails instantly without retry. Setting busy_timeout first makes
174
+ // SQLite wait up to 5s for the lock.
175
+ db.exec('PRAGMA busy_timeout = 5000');
176
+ db.exec('PRAGMA journal_mode = WAL');
177
+ db.exec('PRAGMA synchronous = NORMAL');
158
178
  db.exec(SCHEMA);
159
179
  applyDbMigrations(db);
160
180
  return db;
@@ -162,8 +182,12 @@ export function openPipelineDb(dbPath) {
162
182
  catch (e) {
163
183
  const msg = e instanceof Error ? e.message : String(e);
164
184
  if (msg.includes('database is locked')) {
165
- process.stderr.write('Error: another CLI process is already running. Wait for it to finish and retry.\n');
166
- process.exit(1);
185
+ // Throw a typed-ish error rather than process.exit(1). Calling exit
186
+ // from inside Ink's render reconciler crashes the TUI mid-frame and
187
+ // emits a noisy 'setState during render' warning. The CLI entry
188
+ // point (src/index.ts) catches and exits cleanly for non-TUI
189
+ // contexts; the wizard catches and transitions to its error step.
190
+ throw new Error('database is locked: another CLI process may be running. Retry once it exits.');
167
191
  }
168
192
  throw e;
169
193
  }
@@ -186,6 +210,84 @@ function applyDbMigrations(db) {
186
210
  if (!rawCompColNames.has('needs_review')) {
187
211
  db.exec('ALTER TABLE raw_components ADD COLUMN needs_review INTEGER NOT NULL DEFAULT 0');
188
212
  }
213
+ // Feature 1: per-component source path on raw_components, rationale + per-prop
214
+ // source location on raw_props. All nullable, no DEFAULT — existing rows survive.
215
+ if (!rawCompColNames.has('source_path')) {
216
+ db.exec('ALTER TABLE raw_components ADD COLUMN source_path TEXT');
217
+ }
218
+ const rawPropCols = db.prepare('PRAGMA table_info(raw_props)').all();
219
+ const rawPropColNames = new Set(rawPropCols.map((c) => c.name));
220
+ if (!rawPropColNames.has('rationale')) {
221
+ db.exec('ALTER TABLE raw_props ADD COLUMN rationale TEXT');
222
+ }
223
+ if (!rawPropColNames.has('source_start_line')) {
224
+ db.exec('ALTER TABLE raw_props ADD COLUMN source_start_line INTEGER');
225
+ }
226
+ if (!rawPropColNames.has('source_end_line')) {
227
+ db.exec('ALTER TABLE raw_props ADD COLUMN source_end_line INTEGER');
228
+ }
229
+ // Feature 3: persist select-agent's reject reason on raw_components. Nullable,
230
+ // no DEFAULT — accepted components keep NULL, rejected ones store the LLM's
231
+ // reason (or `validation error: <codes>` for validation auto-rejections).
232
+ if (!rawCompColNames.has('reject_reason')) {
233
+ db.exec('ALTER TABLE raw_components ADD COLUMN reject_reason TEXT');
234
+ }
235
+ // Component rationale: WHY a component was classified, why these props,
236
+ // why these slots — surfaced via the `I` panel. All nullable, no DEFAULT
237
+ // so existing rows survive. Plus per-slot rationale on raw_slots.
238
+ if (!rawCompColNames.has('component_description_rationale')) {
239
+ db.exec('ALTER TABLE raw_components ADD COLUMN component_description_rationale TEXT');
240
+ }
241
+ if (!rawCompColNames.has('props_rationale')) {
242
+ db.exec('ALTER TABLE raw_components ADD COLUMN props_rationale TEXT');
243
+ }
244
+ if (!rawCompColNames.has('slots_rationale')) {
245
+ db.exec('ALTER TABLE raw_components ADD COLUMN slots_rationale TEXT');
246
+ }
247
+ const rawSlotColsForRationale = db.prepare('PRAGMA table_info(raw_slots)').all();
248
+ if (!rawSlotColsForRationale.some((c) => c.name === 'rationale')) {
249
+ db.exec('ALTER TABLE raw_slots ADD COLUMN rationale TEXT');
250
+ }
251
+ // Fine-grained cache: extract_cache holds per-file extracted components keyed
252
+ // on (file_hash, cli_version). cli_version is a content hash of bundled skill
253
+ // files so cache busts on a prompt change (not on every npm patch bump).
254
+ const hasExtractCache = db
255
+ .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='extract_cache'`)
256
+ .all();
257
+ if (hasExtractCache.length === 0) {
258
+ db.exec(`
259
+ CREATE TABLE extract_cache (
260
+ file_path TEXT NOT NULL,
261
+ file_hash TEXT NOT NULL,
262
+ cli_version TEXT NOT NULL,
263
+ created_at TEXT NOT NULL,
264
+ updated_at TEXT NOT NULL,
265
+ components_json TEXT NOT NULL,
266
+ PRIMARY KEY (file_hash, cli_version)
267
+ );
268
+ CREATE INDEX idx_extract_cache_file ON extract_cache(file_path);
269
+ `);
270
+ }
271
+ // Fine-grained cache: select_cache stores accept/reject decisions per
272
+ // (component_hash, prompt_hash, cli_version). On hit, the wizard replays the
273
+ // decision without an LLM call.
274
+ const hasSelectCache = db
275
+ .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='select_cache'`)
276
+ .all();
277
+ if (hasSelectCache.length === 0) {
278
+ db.exec(`
279
+ CREATE TABLE select_cache (
280
+ component_hash TEXT NOT NULL,
281
+ prompt_hash TEXT NOT NULL,
282
+ cli_version TEXT NOT NULL,
283
+ decision TEXT NOT NULL CHECK (decision IN ('accepted','rejected')),
284
+ reason TEXT,
285
+ created_at TEXT NOT NULL,
286
+ updated_at TEXT NOT NULL,
287
+ PRIMARY KEY (component_hash, prompt_hash, cli_version)
288
+ );
289
+ `);
290
+ }
189
291
  // Add generation_cache table if it doesn't exist yet.
190
292
  const tables = db
191
293
  .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='generation_cache'`)
@@ -206,6 +308,131 @@ function applyDbMigrations(db) {
206
308
  CREATE INDEX idx_generation_cache_session ON generation_cache(source_session_id);
207
309
  `);
208
310
  }
311
+ // Fine-grained cache: add prompt_hash to generation_cache so cache busts when
312
+ // the operator switches prompt files. Existing rows get '' (matches the
313
+ // historical "default bundled prompt" — note that lookups using a non-empty
314
+ // hash for the bundled prompt won't match, so first run after upgrade busts
315
+ // the cache. Documented as acceptable.
316
+ //
317
+ // The primary key also needs to expand to include prompt_hash so multiple
318
+ // prompt variants can coexist per (input_hash, entity_type, entity_id).
319
+ // SQLite doesn't allow ALTERing a PK, so when promoting the schema we rebuild
320
+ // the table in-place.
321
+ const genCacheCols = db.prepare(`PRAGMA table_info(generation_cache)`).all();
322
+ const hasPromptHash = genCacheCols.some((c) => c.name === 'prompt_hash');
323
+ const oldPkCols = genCacheCols.filter((c) => c.pk > 0).map((c) => c.name);
324
+ const promptHashInPk = oldPkCols.includes('prompt_hash');
325
+ if (!hasPromptHash) {
326
+ db.exec(`ALTER TABLE generation_cache ADD COLUMN prompt_hash TEXT NOT NULL DEFAULT ''`);
327
+ }
328
+ if (!promptHashInPk) {
329
+ db.exec('BEGIN');
330
+ try {
331
+ db.exec(`
332
+ CREATE TABLE generation_cache__new (
333
+ input_hash TEXT NOT NULL,
334
+ entity_type TEXT NOT NULL CHECK (entity_type IN ('component', 'token_set')),
335
+ entity_id TEXT NOT NULL,
336
+ source_session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
337
+ human_edited INTEGER NOT NULL DEFAULT 0 CHECK (human_edited IN (0, 1)),
338
+ created_at TEXT NOT NULL,
339
+ updated_at TEXT NOT NULL,
340
+ prompt_hash TEXT NOT NULL DEFAULT '',
341
+ PRIMARY KEY (input_hash, prompt_hash, entity_type, entity_id)
342
+ );
343
+ INSERT INTO generation_cache__new
344
+ (input_hash, entity_type, entity_id, source_session_id, human_edited, created_at, updated_at, prompt_hash)
345
+ SELECT input_hash, entity_type, entity_id, source_session_id, human_edited, created_at, updated_at, prompt_hash
346
+ FROM generation_cache;
347
+ DROP TABLE generation_cache;
348
+ ALTER TABLE generation_cache__new RENAME TO generation_cache;
349
+ CREATE INDEX IF NOT EXISTS idx_generation_cache_entity ON generation_cache(entity_type, entity_id);
350
+ CREATE INDEX IF NOT EXISTS idx_generation_cache_session ON generation_cache(source_session_id);
351
+ `);
352
+ db.exec('COMMIT');
353
+ }
354
+ catch (e) {
355
+ db.exec('ROLLBACK');
356
+ throw e;
357
+ }
358
+ }
359
+ }
360
+ export function loadComponentReviewMetadata(db, sessionId, componentName) {
361
+ const compRow = db
362
+ .prepare(`SELECT component_id, source, source_path FROM raw_components WHERE session_id = ? AND name = ?`)
363
+ .get(sessionId, componentName);
364
+ if (!compRow)
365
+ return null;
366
+ const propRows = db
367
+ .prepare(`SELECT name, rationale, source_start_line, source_end_line FROM raw_props WHERE session_id = ? AND component_id = ?`)
368
+ .all(sessionId, compRow.component_id);
369
+ const props = {};
370
+ for (const r of propRows) {
371
+ props[r.name] = {
372
+ rationale: r.rationale,
373
+ sourceStartLine: r.source_start_line,
374
+ sourceEndLine: r.source_end_line,
375
+ };
376
+ }
377
+ // raw_components.source historically stores the file path, not the file
378
+ // text. For the source-view panel to render real lines, prefer reading
379
+ // the file from disk via source_path. Fall back to whatever's in `source`
380
+ // (handles in-memory fixtures and tests).
381
+ let componentSource = compRow.source ?? null;
382
+ if (compRow.source_path) {
383
+ try {
384
+ componentSource = readFileSync(compRow.source_path, 'utf8');
385
+ }
386
+ catch {
387
+ // File no longer exists or unreadable — leave fallback.
388
+ }
389
+ }
390
+ return {
391
+ sourcePath: compRow.source_path,
392
+ componentSource,
393
+ props,
394
+ };
395
+ }
396
+ /**
397
+ * Component-rationale loader: data contract for the `I` ComponentRationalePanel.
398
+ * Returns the component's description + the three component-level rationale
399
+ * strings (why-description / why-props / why-slots), plus per-prop and per-slot
400
+ * rationale rows for the operator-facing panel. Returns null when no component
401
+ * matches.
402
+ */
403
+ export function loadComponentRationale(db, sessionId, componentName) {
404
+ const compRow = db
405
+ .prepare(`SELECT component_id, name, description, component_description_rationale, props_rationale, slots_rationale
406
+ FROM raw_components WHERE session_id = ? AND name = ?`)
407
+ .get(sessionId, componentName);
408
+ if (!compRow)
409
+ return null;
410
+ const propRows = db
411
+ .prepare(`SELECT name, cdf_category, category, description, rationale FROM raw_props
412
+ WHERE session_id = ? AND component_id = ? ORDER BY position`)
413
+ .all(sessionId, compRow.component_id);
414
+ const slotRows = db
415
+ .prepare(`SELECT name, description, rationale FROM raw_slots
416
+ WHERE session_id = ? AND component_id = ? ORDER BY position`)
417
+ .all(sessionId, compRow.component_id);
418
+ return {
419
+ name: compRow.name,
420
+ description: compRow.description,
421
+ descriptionRationale: compRow.component_description_rationale,
422
+ propsRationale: compRow.props_rationale,
423
+ slotsRationale: compRow.slots_rationale,
424
+ props: propRows.map((p) => ({
425
+ name: p.name,
426
+ category: p.cdf_category ?? p.category ?? null,
427
+ description: p.description,
428
+ rationale: p.rationale,
429
+ })),
430
+ slots: slotRows.map((s) => ({
431
+ name: s.name,
432
+ description: s.description,
433
+ rationale: s.rationale,
434
+ })),
435
+ };
209
436
  }
210
437
  export function applyToolCalls(db, sessionId, componentId, componentName, calls, incomingWarnings) {
211
438
  const now = new Date().toISOString();
@@ -213,9 +440,9 @@ export function applyToolCalls(db, sessionId, componentId, componentName, calls,
213
440
  let classified = 0;
214
441
  let excluded = 0;
215
442
  let slots = 0;
216
- const updateProp = db.prepare(`UPDATE raw_props SET cdf_type = ?, cdf_category = ?, cdf_token_kind = ?, required = ?, description = ?
443
+ const updateProp = db.prepare(`UPDATE raw_props SET cdf_type = ?, cdf_category = ?, cdf_token_kind = ?, required = ?, description = ?, rationale = ?
217
444
  WHERE session_id = ? AND component_id = ? AND name = ?`);
218
- const clearProp = db.prepare(`UPDATE raw_props SET cdf_type = 'excluded', cdf_category = NULL, cdf_token_kind = NULL
445
+ const clearProp = db.prepare(`UPDATE raw_props SET cdf_type = 'excluded', cdf_category = NULL, cdf_token_kind = NULL, rationale = ?
219
446
  WHERE session_id = ? AND component_id = ? AND name = ?`);
220
447
  const deleteAllowedValues = db.prepare(`DELETE FROM raw_prop_allowed_values WHERE session_id = ? AND component_id = ? AND prop_name = ?`);
221
448
  const insertAllowedValue = db.prepare(`INSERT OR IGNORE INTO raw_prop_allowed_values (session_id, component_id, prop_name, value, position)
@@ -231,9 +458,22 @@ export function applyToolCalls(db, sessionId, componentId, componentName, calls,
231
458
  if (call.description !== undefined) {
232
459
  db.prepare('UPDATE raw_components SET description = ? WHERE session_id = ? AND component_id = ?').run(call.description, sessionId, componentId);
233
460
  }
461
+ // Component-level rationale: sparse-update each field only when present
462
+ // so missing keys don't overwrite previously-stored values.
463
+ if (call.rationale) {
464
+ if (call.rationale.description !== undefined) {
465
+ db.prepare('UPDATE raw_components SET component_description_rationale = ? WHERE session_id = ? AND component_id = ?').run(call.rationale.description, sessionId, componentId);
466
+ }
467
+ if (call.rationale.props !== undefined) {
468
+ db.prepare('UPDATE raw_components SET props_rationale = ? WHERE session_id = ? AND component_id = ?').run(call.rationale.props, sessionId, componentId);
469
+ }
470
+ if (call.rationale.slots !== undefined) {
471
+ db.prepare('UPDATE raw_components SET slots_rationale = ? WHERE session_id = ? AND component_id = ?').run(call.rationale.slots, sessionId, componentId);
472
+ }
473
+ }
234
474
  }
235
475
  else if (call.tool === 'classify_prop') {
236
- const changes = updateProp.run(call.cdf_type, call.cdf_category, call.token_kind ?? null, call.required !== undefined ? (call.required ? 1 : 0) : 0, call.description ?? null, sessionId, componentId, call.prop);
476
+ const changes = updateProp.run(call.cdf_type, call.cdf_category, call.token_kind ?? null, call.required !== undefined ? (call.required ? 1 : 0) : 0, call.description ?? null, call.reason ?? null, sessionId, componentId, call.prop);
237
477
  if (changes.changes === 0) {
238
478
  warnings.push(`${componentName}: classify_prop '${call.prop}' — prop not found, skipped`);
239
479
  continue;
@@ -249,7 +489,7 @@ export function applyToolCalls(db, sessionId, componentId, componentName, calls,
249
489
  classified++;
250
490
  }
251
491
  else if (call.tool === 'exclude_prop') {
252
- clearProp.run(sessionId, componentId, call.prop);
492
+ clearProp.run(call.reason || null, sessionId, componentId, call.prop);
253
493
  excluded++;
254
494
  }
255
495
  else if (call.tool === 'classify_slot') {
@@ -259,6 +499,11 @@ export function applyToolCalls(db, sessionId, componentId, componentName, calls,
259
499
  warnings.push(`${componentName}: classify_slot '${call.slot}' — slot not found, skipped`);
260
500
  continue;
261
501
  }
502
+ // Per-slot rationale: sparse-update only when present so omitting it
503
+ // doesn't blank existing values.
504
+ if (call.rationale !== undefined) {
505
+ db.prepare('UPDATE raw_slots SET rationale = ? WHERE session_id = ? AND component_id = ? AND name = ?').run(call.rationale, sessionId, componentId, call.slot);
506
+ }
262
507
  if (call.allowed_components !== undefined) {
263
508
  deleteAllowedComponents.run(sessionId, componentId, call.slot);
264
509
  call.allowed_components.forEach((ac, i) => insertAllowedComponent.run(sessionId, componentId, call.slot, ac, i));
@@ -276,15 +521,13 @@ export function applyToolCalls(db, sessionId, componentId, componentName, calls,
276
521
  }
277
522
  return { classified, excluded, slots, warnings };
278
523
  }
279
- export function getOrCreateSession(db, sessionFlag, sessionName, hints) {
524
+ export function getOrCreateSession(db, sessionFlag, sessionName,
525
+ // `_hints` is retained for call-site compatibility but no longer drives an
526
+ // implicit auto-match by inputPath/outDir — operators who want to resume must
527
+ // pass an explicit --session <id>.
528
+ _hints) {
280
529
  const now = new Date().toISOString();
281
530
  if (sessionFlag === 'new' || sessionFlag === undefined) {
282
- if (sessionFlag !== 'new') {
283
- const match = findMatchingSession(db, hints);
284
- if (match) {
285
- return { sessionId: match, isNew: false, isResumed: true };
286
- }
287
- }
288
531
  const id = generateSessionId();
289
532
  db.prepare('INSERT INTO sessions (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)').run(id, sessionName ?? null, now, now);
290
533
  return { sessionId: id, isNew: true, isResumed: false };
@@ -295,48 +538,6 @@ export function getOrCreateSession(db, sessionFlag, sessionName, hints) {
295
538
  }
296
539
  return { sessionId: sessionFlag, isNew: false, isResumed: false };
297
540
  }
298
- function findMatchingSession(db, hints) {
299
- if (!hints.inputPath && !hints.outDir)
300
- return null;
301
- const rows = db
302
- .prepare(`SELECT s.id, st.inputs, st.outputs, st.status
303
- FROM sessions s
304
- JOIN steps st ON st.session_id = s.id
305
- WHERE st.command = ?
306
- AND st.status IN ('pending', 'interrupted')
307
- ORDER BY st.started_at DESC
308
- LIMIT 20`)
309
- .all(hints.command);
310
- for (const row of rows) {
311
- let inputs = {};
312
- try {
313
- inputs = JSON.parse(row.inputs);
314
- }
315
- catch {
316
- continue;
317
- }
318
- if (hints.inputPath) {
319
- const inputValues = Object.values(inputs);
320
- if (inputValues.some((v) => v === hints.inputPath)) {
321
- return row.id;
322
- }
323
- }
324
- if (hints.outDir) {
325
- let outputs = {};
326
- try {
327
- outputs = JSON.parse(row.outputs);
328
- }
329
- catch {
330
- // ignore
331
- }
332
- const allValues = [...Object.values(inputs), ...Object.values(outputs)];
333
- if (allValues.some((v) => v === hints.outDir)) {
334
- return row.id;
335
- }
336
- }
337
- }
338
- return null;
339
- }
340
541
  export function createStep(db, sessionId, command, inputs) {
341
542
  const now = new Date().toISOString();
342
543
  db.exec('BEGIN');
@@ -374,11 +575,11 @@ export function updateStep(db, stepId, status, outputs, error) {
374
575
  }
375
576
  export function storeRawComponents(db, sessionId, components, options) {
376
577
  const now = new Date().toISOString();
377
- const insertComp = db.prepare(`INSERT INTO raw_components (session_id, component_id, name, source, framework, extracted_at, extraction_confidence, review_reasons, needs_review)
378
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
379
- const insertProp = db.prepare(`INSERT INTO raw_props
380
- (session_id, component_id, name, type, required, category, default_value, description, token_reference, position)
578
+ const insertComp = db.prepare(`INSERT INTO raw_components (session_id, component_id, name, source, framework, extracted_at, extraction_confidence, review_reasons, needs_review, source_path)
381
579
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
580
+ const insertProp = db.prepare(`INSERT INTO raw_props
581
+ (session_id, component_id, name, type, required, category, default_value, description, token_reference, position, source_start_line, source_end_line)
582
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
382
583
  const insertAllowedValue = db.prepare(`INSERT INTO raw_prop_allowed_values (session_id, component_id, prop_name, position, value)
383
584
  VALUES (?, ?, ?, ?, ?)`);
384
585
  const insertSlot = db.prepare(`INSERT INTO raw_slots (session_id, component_id, name, is_default, description, position)
@@ -409,10 +610,10 @@ export function storeRawComponents(db, sessionId, components, options) {
409
610
  db.prepare('DELETE FROM raw_components WHERE session_id = ?').run(sessionId);
410
611
  for (const comp of components) {
411
612
  const componentId = deriveComponentId(comp.name, comp.source);
412
- insertComp.run(sessionId, componentId, comp.name, comp.source, comp.framework, now, comp.extractionConfidence ?? null, JSON.stringify(comp.reviewReasons ?? []), comp.needsReview ? 1 : 0);
613
+ insertComp.run(sessionId, componentId, comp.name, comp.source, comp.framework, now, comp.extractionConfidence ?? null, JSON.stringify(comp.reviewReasons ?? []), comp.needsReview ? 1 : 0, comp.sourcePath ?? null);
413
614
  for (let i = 0; i < comp.props.length; i++) {
414
615
  const prop = comp.props[i];
415
- insertProp.run(sessionId, componentId, prop.name, prop.type, prop.required ? 1 : 0, prop.category ?? null, prop.defaultValue ?? null, prop.description ?? null, prop.tokenReference ?? null, i);
616
+ insertProp.run(sessionId, componentId, prop.name, prop.type, prop.required ? 1 : 0, prop.category ?? null, prop.defaultValue ?? null, prop.description ?? null, prop.tokenReference ?? null, i, prop.sourceStartLine ?? null, prop.sourceEndLine ?? null);
416
617
  if (prop.allowedValues) {
417
618
  prop.allowedValues.forEach((v, j) => insertAllowedValue.run(sessionId, componentId, prop.name, j, v));
418
619
  }
@@ -489,13 +690,14 @@ export function storeRawComponents(db, sessionId, components, options) {
489
690
  }
490
691
  export function loadRawComponents(db, sessionId, allowedNames) {
491
692
  const all = db
492
- .prepare('SELECT component_id, name, source, framework, extraction_confidence, review_reasons, needs_review FROM raw_components WHERE session_id = ? ORDER BY rowid')
693
+ .prepare('SELECT component_id, name, source, framework, extraction_confidence, review_reasons, needs_review, source_path FROM raw_components WHERE session_id = ? ORDER BY rowid')
493
694
  .all(sessionId);
494
695
  const components = allowedNames ? all.filter((c) => allowedNames.has(c.name)) : all;
495
696
  if (components.length === 0)
496
697
  return [];
497
698
  const props = db
498
- .prepare(`SELECT component_id, name, type, required, category, default_value, description, token_reference, position
699
+ .prepare(`SELECT component_id, name, type, required, category, default_value, description, token_reference, position,
700
+ rationale, source_start_line, source_end_line
499
701
  FROM raw_props WHERE session_id = ? ORDER BY component_id, position`)
500
702
  .all(sessionId);
501
703
  const allowedValues = db
@@ -530,6 +732,7 @@ export function loadRawComponents(db, sessionId, allowedNames) {
530
732
  }
531
733
  })(),
532
734
  needsReview: Boolean(c.needs_review),
735
+ sourcePath: c.source_path ?? undefined,
533
736
  props: (propsByComponent.get(c.component_id) ?? []).map((p) => {
534
737
  const av = allowedValuesByProp.get(`${c.component_id}::${p.name}`);
535
738
  const prop = {
@@ -547,6 +750,10 @@ export function loadRawComponents(db, sessionId, allowedNames) {
547
750
  prop.tokenReference = p.token_reference;
548
751
  if (av && av.length > 0)
549
752
  prop.allowedValues = av.map((v) => v.value);
753
+ if (p.source_start_line !== null)
754
+ prop.sourceStartLine = p.source_start_line;
755
+ if (p.source_end_line !== null)
756
+ prop.sourceEndLine = p.source_end_line;
550
757
  return prop;
551
758
  }),
552
759
  slots: (slotsByComponent.get(c.component_id) ?? []).map((s) => {
@@ -706,13 +913,16 @@ export function loadCDFComponents(db, sessionId) {
706
913
  const allowedValuesByProp = groupBy(allowedValues, (av) => `${av.component_id}::${av.prop_name}`);
707
914
  const slotsByComponent = groupBy(slots, (s) => s.component_id);
708
915
  const allowedComponentsBySlot = groupBy(allowedComponents, (ac) => `${ac.component_id}::${ac.slot_name}`);
709
- return components
710
- .map(({ component_id, name, description }) => {
916
+ return components.map(({ component_id, name, description }) => {
711
917
  const compProps = propsByComponent.get(component_id) ?? [];
712
- // Skip components with no CDF-classified props sending them with empty
713
- // $properties causes the server to flag all stored props as "removed" (breaking).
714
- if (compProps.length === 0)
715
- return null;
918
+ // NOTE: Components with zero CDF-classified props are intentionally
919
+ // returned with an empty `$properties` object. Filtering them out here
920
+ // silently dropped them from the wizard's final-review (INTEG-4257),
921
+ // so the user couldn't see that the LLM had failed to classify anything
922
+ // and couldn't reject the empty component. Surface them instead — the
923
+ // wizard tags them with a ⚠ badge and an in-panel banner so the user
924
+ // can manually add props in FieldEditor or reject the component.
925
+ // Downstream consumers (buildManifest, push) tolerate empty $properties.
716
926
  const $properties = {};
717
927
  for (const p of compProps) {
718
928
  // Hallucination insurance: drop any prop whose name didn't survive trim.
@@ -768,8 +978,42 @@ export function loadCDFComponents(db, sessionId) {
768
978
  if (Object.keys($slots).length > 0)
769
979
  entry.$slots = $slots;
770
980
  return { key: name, entry };
771
- })
772
- .filter((c) => c !== null);
981
+ });
982
+ }
983
+ export function loadScopeComponents(db, sessionId) {
984
+ // Loads extract-stage components: those still pending operator review (`extracted`)
985
+ // plus those the Feature 3 auto-filter has already classified (`accepted` /
986
+ // `rejected`). Excludes `generated` (already past scope-gate) so re-runs of the
987
+ // wizard don't show already-confirmed components.
988
+ const rows = db
989
+ .prepare(`SELECT name, component_id, status, reject_reason FROM raw_components
990
+ WHERE session_id = ? AND status IN ('extracted', 'accepted', 'rejected')
991
+ ORDER BY name`)
992
+ .all(sessionId);
993
+ return rows.map((r) => ({
994
+ name: r.name,
995
+ componentId: r.component_id,
996
+ aiDecision: r.status === 'accepted' ? 'accepted' : r.status === 'rejected' ? 'rejected' : null,
997
+ aiReason: r.reject_reason,
998
+ }));
999
+ }
1000
+ export function applyScopeDecisions(db, sessionId, decisions) {
1001
+ const now = new Date().toISOString();
1002
+ const acceptedSet = new Set(decisions.accepted);
1003
+ const accepted = [...acceptedSet];
1004
+ // Conflict precedence: accepted wins over rejected.
1005
+ const rejected = [...new Set(decisions.rejected)].filter((n) => !acceptedSet.has(n));
1006
+ // Rejected first so accepted writes can flip back to 'generated' afterwards
1007
+ // if a name appears in both lists.
1008
+ if (rejected.length > 0) {
1009
+ const placeholders = rejected.map(() => '?').join(',');
1010
+ db.prepare(`UPDATE raw_components SET status = 'rejected' WHERE session_id = ? AND name IN (${placeholders})`).run(sessionId, ...rejected);
1011
+ }
1012
+ if (accepted.length > 0) {
1013
+ const placeholders = accepted.map(() => '?').join(',');
1014
+ db.prepare(`UPDATE raw_components SET status = 'generated' WHERE session_id = ? AND name IN (${placeholders})`).run(sessionId, ...accepted);
1015
+ }
1016
+ db.prepare('UPDATE sessions SET updated_at = ? WHERE id = ?').run(now, sessionId);
773
1017
  }
774
1018
  export function storeDTCGTokens(db, sessionId, groups, tokens) {
775
1019
  const now = new Date().toISOString();
@@ -1053,37 +1297,36 @@ function mapServerTypeToCDFType(serverType) {
1053
1297
  }
1054
1298
  }
1055
1299
  export function computeComponentInputHash(component) {
1300
+ // Narrow the hashed payload to extractor-only fields. Any field that
1301
+ // `applyToolCalls` may overwrite (prop description/required/default/allowed
1302
+ // values, slot description/allowed components) is excluded so that the cache
1303
+ // key does not depend on its own output — otherwise re-reading raw props
1304
+ // after a generation pass would drift the hash even when the underlying
1305
+ // source has not changed.
1056
1306
  const payload = {
1057
1307
  framework: component.framework,
1058
1308
  name: component.name,
1309
+ source: component.source,
1059
1310
  props: component.props.map((p) => ({
1060
- allowedValues: p.allowedValues ?? [],
1061
- defaultValue: p.defaultValue ?? null,
1062
- description: p.description ?? null,
1063
1311
  name: p.name,
1064
- required: p.required,
1065
- tokenReference: p.tokenReference ?? null,
1066
1312
  type: p.type,
1067
1313
  })),
1068
1314
  slots: component.slots.map((s) => ({
1069
- allowedComponents: s.allowedComponents ?? [],
1070
- description: s.description ?? null,
1071
- isDefault: s.isDefault,
1072
1315
  name: s.name,
1316
+ isDefault: s.isDefault,
1073
1317
  })),
1074
- source: component.source,
1075
1318
  };
1076
1319
  return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
1077
1320
  }
1078
1321
  export function computeTokenInputHash(rawTokenContent) {
1079
1322
  return createHash('sha256').update(rawTokenContent.trim()).digest('hex');
1080
1323
  }
1081
- export function lookupCache(db, inputHash, entityType, entityId) {
1324
+ export function lookupCache(db, inputHash, entityType, entityId, promptHash = '') {
1082
1325
  const row = db
1083
- .prepare(`SELECT input_hash, entity_type, entity_id, source_session_id, human_edited, created_at, updated_at
1326
+ .prepare(`SELECT input_hash, entity_type, entity_id, source_session_id, human_edited, created_at, updated_at, prompt_hash
1084
1327
  FROM generation_cache
1085
- WHERE input_hash = ? AND entity_type = ? AND entity_id = ?`)
1086
- .get(inputHash, entityType, entityId);
1328
+ WHERE input_hash = ? AND entity_type = ? AND entity_id = ? AND prompt_hash = ?`)
1329
+ .get(inputHash, entityType, entityId, promptHash);
1087
1330
  if (!row)
1088
1331
  return null;
1089
1332
  return {
@@ -1092,13 +1335,14 @@ export function lookupCache(db, inputHash, entityType, entityId) {
1092
1335
  entityId: row.entity_id,
1093
1336
  sourceSessionId: row.source_session_id,
1094
1337
  humanEdited: row.human_edited === 1,
1338
+ promptHash: row.prompt_hash,
1095
1339
  createdAt: row.created_at,
1096
1340
  updatedAt: row.updated_at,
1097
1341
  };
1098
1342
  }
1099
1343
  export function lookupCacheByEntity(db, entityType, entityId) {
1100
1344
  const row = db
1101
- .prepare(`SELECT input_hash, entity_type, entity_id, source_session_id, human_edited, created_at, updated_at
1345
+ .prepare(`SELECT input_hash, entity_type, entity_id, source_session_id, human_edited, created_at, updated_at, prompt_hash
1102
1346
  FROM generation_cache
1103
1347
  WHERE entity_type = ? AND entity_id = ?
1104
1348
  ORDER BY updated_at DESC LIMIT 1`)
@@ -1111,18 +1355,19 @@ export function lookupCacheByEntity(db, entityType, entityId) {
1111
1355
  entityId: row.entity_id,
1112
1356
  sourceSessionId: row.source_session_id,
1113
1357
  humanEdited: row.human_edited === 1,
1358
+ promptHash: row.prompt_hash,
1114
1359
  createdAt: row.created_at,
1115
1360
  updatedAt: row.updated_at,
1116
1361
  };
1117
1362
  }
1118
- export function storeCache(db, inputHash, entityType, entityId, sourceSessionId, humanEdited) {
1363
+ export function storeCache(db, inputHash, entityType, entityId, sourceSessionId, humanEdited, promptHash = '') {
1119
1364
  const now = new Date().toISOString();
1120
- db.prepare(`INSERT INTO generation_cache (input_hash, entity_type, entity_id, source_session_id, human_edited, created_at, updated_at)
1121
- VALUES (?, ?, ?, ?, ?, ?, ?)
1122
- ON CONFLICT(input_hash, entity_type, entity_id) DO UPDATE SET
1365
+ db.prepare(`INSERT INTO generation_cache (input_hash, entity_type, entity_id, source_session_id, human_edited, created_at, updated_at, prompt_hash)
1366
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1367
+ ON CONFLICT(input_hash, prompt_hash, entity_type, entity_id) DO UPDATE SET
1123
1368
  source_session_id = excluded.source_session_id,
1124
1369
  human_edited = CASE WHEN generation_cache.human_edited = 1 THEN 1 ELSE excluded.human_edited END,
1125
- updated_at = excluded.updated_at`).run(inputHash, entityType, entityId, sourceSessionId, humanEdited ? 1 : 0, now, now);
1370
+ updated_at = excluded.updated_at`).run(inputHash, entityType, entityId, sourceSessionId, humanEdited ? 1 : 0, now, now, promptHash);
1126
1371
  }
1127
1372
  export function storeScannedFiles(db, sessionId, filePaths) {
1128
1373
  db.exec('BEGIN');
@@ -1213,3 +1458,101 @@ export function copyTokensFromCache(db, sourceSessionId, targetSessionId) {
1213
1458
  throw e;
1214
1459
  }
1215
1460
  }
1461
+ // ---------------------------------------------------------------------------
1462
+ // Fine-grained cache: extract_cache helpers
1463
+ // ---------------------------------------------------------------------------
1464
+ let _cliCacheVersionCache = null;
1465
+ /**
1466
+ * Returns the CLI cache version used to namespace cache rows. To avoid busting
1467
+ * the cache on every npm patch bump, this hashes the content of the bundled
1468
+ * skill prompts. When the prompts change, cache invalidates; when only library
1469
+ * code or test fixtures change, the cache stays warm.
1470
+ *
1471
+ * Memoized per-process. Falls back to package version on any I/O error.
1472
+ */
1473
+ export async function getCliCacheVersion() {
1474
+ if (_cliCacheVersionCache)
1475
+ return _cliCacheVersionCache;
1476
+ try {
1477
+ const { hashContent } = await import('./cache-keys.js');
1478
+ const { resolveSkillPath } = await import('../generate/prompt-builder.js');
1479
+ const skills = ['components', 'tokens', 'select'];
1480
+ const parts = [];
1481
+ for (const s of skills) {
1482
+ try {
1483
+ const p = resolveSkillPath(s);
1484
+ parts.push(readFileSync(p, 'utf8'));
1485
+ }
1486
+ catch {
1487
+ parts.push(`<missing:${s}>`);
1488
+ }
1489
+ }
1490
+ _cliCacheVersionCache = hashContent(parts.join('\n---\n'));
1491
+ return _cliCacheVersionCache;
1492
+ }
1493
+ catch {
1494
+ _cliCacheVersionCache = 'fallback';
1495
+ return _cliCacheVersionCache;
1496
+ }
1497
+ }
1498
+ export function storeExtractCache(db, filePath, fileHash, cliVersion, components) {
1499
+ const now = new Date().toISOString();
1500
+ const componentsJson = JSON.stringify(components);
1501
+ db.prepare(`INSERT INTO extract_cache (file_path, file_hash, cli_version, created_at, updated_at, components_json)
1502
+ VALUES (?, ?, ?, ?, ?, ?)
1503
+ ON CONFLICT(file_hash, cli_version) DO UPDATE SET
1504
+ file_path = excluded.file_path,
1505
+ updated_at = excluded.updated_at,
1506
+ components_json = excluded.components_json`).run(filePath, fileHash, cliVersion, now, now, componentsJson);
1507
+ }
1508
+ export function lookupExtractCache(db, fileHash, cliVersion) {
1509
+ const row = db
1510
+ .prepare(`SELECT file_path, file_hash, cli_version, created_at, updated_at, components_json
1511
+ FROM extract_cache
1512
+ WHERE file_hash = ? AND cli_version = ?`)
1513
+ .get(fileHash, cliVersion);
1514
+ if (!row)
1515
+ return null;
1516
+ let components = [];
1517
+ try {
1518
+ components = JSON.parse(row.components_json);
1519
+ }
1520
+ catch {
1521
+ return null;
1522
+ }
1523
+ return {
1524
+ filePath: row.file_path,
1525
+ fileHash: row.file_hash,
1526
+ cliVersion: row.cli_version,
1527
+ createdAt: row.created_at,
1528
+ updatedAt: row.updated_at,
1529
+ components,
1530
+ };
1531
+ }
1532
+ export function storeSelectCache(db, componentHash, promptHash, cliVersion, decision, reason) {
1533
+ const now = new Date().toISOString();
1534
+ db.prepare(`INSERT INTO select_cache (component_hash, prompt_hash, cli_version, decision, reason, created_at, updated_at)
1535
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1536
+ ON CONFLICT(component_hash, prompt_hash, cli_version) DO UPDATE SET
1537
+ decision = excluded.decision,
1538
+ reason = excluded.reason,
1539
+ updated_at = excluded.updated_at`).run(componentHash, promptHash, cliVersion, decision, reason, now, now);
1540
+ }
1541
+ export function lookupSelectCache(db, componentHash, promptHash, cliVersion) {
1542
+ const row = db
1543
+ .prepare(`SELECT component_hash, prompt_hash, cli_version, decision, reason, created_at, updated_at
1544
+ FROM select_cache
1545
+ WHERE component_hash = ? AND prompt_hash = ? AND cli_version = ?`)
1546
+ .get(componentHash, promptHash, cliVersion);
1547
+ if (!row)
1548
+ return null;
1549
+ return {
1550
+ componentHash: row.component_hash,
1551
+ promptHash: row.prompt_hash,
1552
+ cliVersion: row.cli_version,
1553
+ decision: row.decision,
1554
+ reason: row.reason,
1555
+ createdAt: row.created_at,
1556
+ updatedAt: row.updated_at,
1557
+ };
1558
+ }