thumbgate 1.30.0 → 1.34.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 (92) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +54 -16
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/forge/forge.yaml +3 -3
  6. package/adapters/mcp/server-stdio.js +105 -10
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +168 -31
  10. package/config/evals/generation-quality-golden.json +95 -0
  11. package/config/evals/rag-answer-quality-golden.json +91 -0
  12. package/config/evals/retrieval-hybrid-ablation.json +66 -0
  13. package/config/evals/retrieval-ranking-golden.json +522 -0
  14. package/config/gates/claim-verifiers.example.json +42 -0
  15. package/config/gates/claim-verifiers.json +25 -0
  16. package/config/gates/default.json +217 -50
  17. package/config/mcp-allowlists.json +233 -206
  18. package/config/model-tiers.json +7 -2
  19. package/glama.json +6 -0
  20. package/hooks/hooks.json +1 -1
  21. package/package.json +69 -12
  22. package/public/assets/diagrams/before-after.svg +17 -16
  23. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  24. package/public/assets/diagrams/loop.svg +19 -13
  25. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  26. package/public/compare.html +1 -0
  27. package/public/dashboard.html +126 -28
  28. package/public/evaluations.html +1 -1
  29. package/public/index.html +142 -13
  30. package/public/numbers.html +3 -2
  31. package/public/pricing.html +143 -30
  32. package/scripts/a-plus-evidence-scorecard.js +303 -0
  33. package/scripts/agent-readiness.js +110 -0
  34. package/scripts/async-eval-observability.js +36 -11
  35. package/scripts/audit-trail.js +37 -1
  36. package/scripts/auto-promote-gates.js +149 -34
  37. package/scripts/auto-wire-hooks.js +20 -8
  38. package/scripts/cli-schema.js +14 -0
  39. package/scripts/colbert-style-maxsim.js +236 -0
  40. package/scripts/cross-encoder-reranker.js +356 -126
  41. package/scripts/dashboard-chat.js +350 -17
  42. package/scripts/document-intake.js +283 -7
  43. package/scripts/eval-quality-suite.js +204 -0
  44. package/scripts/feedback-loop.js +115 -7
  45. package/scripts/feedback-paths.js +32 -13
  46. package/scripts/feedback-quality.js +53 -0
  47. package/scripts/feedback-schema.js +3 -0
  48. package/scripts/file-ledger-lock.js +130 -0
  49. package/scripts/filesystem-search.js +17 -7
  50. package/scripts/financial-control-plane.js +1514 -0
  51. package/scripts/gates-engine.js +202 -7
  52. package/scripts/gemini-embedding-policy.js +1 -0
  53. package/scripts/harness-tool-names.js +70 -0
  54. package/scripts/hook-runtime.js +15 -3
  55. package/scripts/hook-stop-anti-claim.js +63 -3
  56. package/scripts/human-escalation.js +353 -41
  57. package/scripts/lesson-db.js +16 -5
  58. package/scripts/lesson-embedding-index.js +67 -20
  59. package/scripts/lesson-embedding-maintenance.js +177 -0
  60. package/scripts/lesson-reranker.js +55 -9
  61. package/scripts/lesson-retrieval.js +305 -29
  62. package/scripts/lesson-search.js +22 -8
  63. package/scripts/llm-client.js +304 -15
  64. package/scripts/model-tier-router.js +593 -0
  65. package/scripts/pragmatic-hybrid-search.js +379 -0
  66. package/scripts/provider-action-normalizer.js +11 -4
  67. package/scripts/rag-document-pipeline.js +461 -0
  68. package/scripts/rag-structured-output.js +441 -0
  69. package/scripts/ragas-style-metrics.js +351 -0
  70. package/scripts/request-envelope.js +178 -0
  71. package/scripts/rerank-pipeline.js +370 -0
  72. package/scripts/rerank-quality-eval.js +155 -0
  73. package/scripts/retrieval-hybrid-ablation.js +120 -0
  74. package/scripts/retrieval-quality-tier.js +118 -0
  75. package/scripts/secret-scanner.js +395 -4
  76. package/scripts/self-distill-agent.js +7 -1
  77. package/scripts/self-healing-check.js +25 -0
  78. package/scripts/skill-packs.js +183 -0
  79. package/scripts/slow-loop.js +72 -0
  80. package/scripts/statusline-links.js +1 -1
  81. package/scripts/statusline.sh +8 -1
  82. package/scripts/telemetry-analytics.js +13 -1
  83. package/scripts/thumbgate-search.js +98 -6
  84. package/scripts/tier-budget-guard.js +186 -0
  85. package/scripts/tool-registry.js +141 -5
  86. package/scripts/universal-claim-evaluator.js +767 -0
  87. package/scripts/vector-store.js +154 -17
  88. package/scripts/verify-marketing-pages-deployed.js +85 -3
  89. package/scripts/workflow-sentinel.js +77 -11
  90. package/server.json +44 -0
  91. package/smithery.yaml +17 -0
  92. package/src/api/server.js +196 -13
@@ -13,8 +13,11 @@ const crypto = require('node:crypto');
13
13
  const fs = require('node:fs');
14
14
  const path = require('node:path');
15
15
  const { getFeedbackPaths } = require('./feedback-paths');
16
+ const { withFileLedgerLock } = require('./file-ledger-lock');
16
17
 
17
18
  const ESCALATIONS_FILE = 'human-escalations.jsonl';
19
+ const ESCALATIONS_HEAD_FILE = 'human-escalations.head.json';
20
+ const ESCALATIONS_JOURNAL_FILE = 'human-escalations.journal.json';
18
21
  const MAX_TTL_MS = 7 * 24 * 60 * 60 * 1000;
19
22
  const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
20
23
  const SEVERITIES = new Set(['low', 'medium', 'high', 'critical']);
@@ -24,6 +27,16 @@ function getEscalationsPath(options = {}) {
24
27
  return path.join(getFeedbackPaths(options).FEEDBACK_DIR, ESCALATIONS_FILE);
25
28
  }
26
29
 
30
+ function getEscalationsHeadPath(options = {}) {
31
+ if (options.inputPath) return `${path.resolve(options.inputPath)}.head.json`;
32
+ return path.join(getFeedbackPaths(options).FEEDBACK_DIR, ESCALATIONS_HEAD_FILE);
33
+ }
34
+
35
+ function getEscalationsJournalPath(options = {}) {
36
+ if (options.inputPath) return `${path.resolve(options.inputPath)}.journal.json`;
37
+ return path.join(getFeedbackPaths(options).FEEDBACK_DIR, ESCALATIONS_JOURNAL_FILE);
38
+ }
39
+
27
40
  function requestEscalation(input = {}, options = {}) {
28
41
  const now = options.now || new Date();
29
42
  const taskId = requiredString(input.taskId, 'taskId');
@@ -35,8 +48,7 @@ function requestEscalation(input = {}, options = {}) {
35
48
  if (!SEVERITIES.has(severity)) throw escalationError(`severity must be one of ${Array.from(SEVERITIES).join(', ')}`);
36
49
  const ttlMs = Math.min(MAX_TTL_MS, Math.max(1, finiteNumber(input.ttlMs, DEFAULT_TTL_MS)));
37
50
  const idempotencyKey = requiredString(input.idempotencyKey || taskId, 'idempotencyKey');
38
- const existing = listEscalations(options).find((entry) => entry.idempotencyKey === idempotencyKey);
39
-
51
+ const approvalContextDigest = optionalDigest(input.approvalContextDigest, 'approvalContextDigest');
40
52
  const request = {
41
53
  escalationId: input.escalationId || `esc_${crypto.randomUUID()}`,
42
54
  idempotencyKey,
@@ -50,19 +62,31 @@ function requestEscalation(input = {}, options = {}) {
50
62
  status: 'pending',
51
63
  eventType: 'requested',
52
64
  };
53
- request.eventHash = eventHash(request);
54
-
55
- if (existing) {
56
- if (eventComparableHash(existing) !== eventComparableHash(request)) {
57
- const error = escalationError(`conflicting request for idempotency key '${idempotencyKey}'`);
58
- error.code = 'THUMBGATE_IDEMPOTENCY_CONFLICT';
59
- throw error;
65
+ if (approvalContextDigest) request.approvalContextDigest = approvalContextDigest;
66
+ return withEscalationLock(options, () => {
67
+ const ledger = readLedger(options);
68
+ assertLedgerHealthy(ledger);
69
+ // Compare retries with the immutable request event. A later decision event
70
+ // deliberately carries the reviewer's reason and status, so comparing the
71
+ // projected row would turn an already-approved request into a false
72
+ // idempotency conflict during cross-ledger recovery.
73
+ const existingRequest = ledger.events.find((event) => (
74
+ event.idempotencyKey === idempotencyKey
75
+ && (!event.eventType || event.eventType === 'requested')
76
+ ));
77
+ if (existingRequest) {
78
+ if (eventComparableHash(existingRequest) !== eventComparableHash(request)) {
79
+ const error = escalationError(`conflicting request for idempotency key '${idempotencyKey}'`);
80
+ error.code = 'THUMBGATE_IDEMPOTENCY_CONFLICT';
81
+ throw error;
82
+ }
83
+ const current = projectEscalations(ledger.events, options)
84
+ .find((entry) => entry.escalationId === existingRequest.escalationId);
85
+ return { recorded: false, duplicate: true, escalation: current || existingRequest };
60
86
  }
61
- return { recorded: false, duplicate: true, escalation: existing };
62
- }
63
-
64
- appendEvent(request, options);
65
- return { recorded: true, duplicate: false, escalation: request };
87
+ const recorded = appendEventUnlocked(request, options, ledger.events);
88
+ return { recorded: true, duplicate: false, escalation: recorded };
89
+ });
66
90
  }
67
91
 
68
92
  function decideEscalation(input = {}, options = {}) {
@@ -75,29 +99,74 @@ function decideEscalation(input = {}, options = {}) {
75
99
  const actor = requiredIdentity(options.authenticatedActor, 'authenticatedActor');
76
100
  if (actor.kind !== 'human') throw escalationError('authenticatedActor.kind must be human');
77
101
  const reason = requiredString(input.reason, 'reason');
78
- const current = getEscalation(escalationId, options);
79
- if (!current) throw escalationError(`unknown escalation '${escalationId}'`);
80
- if (current.status !== 'pending') throw escalationError(`escalation '${escalationId}' is already ${current.status}`);
81
- if (sameIdentity(current.requester, actor)) throw escalationError('requester cannot decide their own escalation');
102
+ return withEscalationLock(options, () => {
103
+ const ledger = readLedger(options);
104
+ assertLedgerHealthy(ledger);
105
+ const current = projectEscalations(ledger.events, options)
106
+ .find((entry) => entry.escalationId === escalationId);
107
+ if (!current) throw escalationError(`unknown escalation '${escalationId}'`);
108
+ if (current.status !== 'pending') throw escalationError(`escalation '${escalationId}' is already ${current.status}`);
109
+ if (sameIdentity(current.requester, actor)) throw escalationError('requester cannot decide their own escalation');
82
110
 
83
- const now = options.now || new Date();
84
- const event = {
85
- escalationId,
86
- taskId: current.taskId,
87
- status: decision,
88
- eventType: 'decided',
89
- decision,
90
- actor,
91
- reason,
92
- decidedAt: now.toISOString(),
93
- };
94
- event.eventHash = eventHash(event);
95
- appendEvent(event, options);
96
- return { recorded: true, escalation: { ...current, ...event } };
111
+ const now = options.now || new Date();
112
+ const event = {
113
+ escalationId,
114
+ taskId: current.taskId,
115
+ status: decision,
116
+ eventType: 'decided',
117
+ decision,
118
+ actor,
119
+ reason,
120
+ decidedAt: now.toISOString(),
121
+ };
122
+ if (current.approvalContextDigest) {
123
+ event.approvalContextDigest = current.approvalContextDigest;
124
+ }
125
+ const signingKey = optionalString(options.approvalSigningKey);
126
+ if (signingKey) event.approvalReceipt = signApprovalReceipt(event, signingKey);
127
+ const recorded = appendEventUnlocked(event, options, ledger.events);
128
+ return { recorded: true, escalation: { ...current, ...recorded } };
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Return an approval only when both the append-only history and the reviewer
134
+ * receipt authenticate. Merely appending an `approved` JSON row is not proof
135
+ * that the independently authenticated reviewer API produced it.
136
+ */
137
+ function getVerifiedApproval(escalationId, options = {}) {
138
+ return withEscalationLock(options, () => getVerifiedApprovalUnlocked(escalationId, options));
139
+ }
140
+
141
+ function getVerifiedApprovalUnlocked(escalationId, options = {}) {
142
+ const ledger = readLedger(options);
143
+ const integrity = validateEscalationLedger(ledger.events, ledger.malformedRows, ledger.head);
144
+ if (!integrity.ok) throw escalationError('escalation ledger integrity verification failed');
145
+ const events = ledger.events.filter((event) => event.escalationId === escalationId);
146
+ const requested = events.find((event) => event.eventType === 'requested');
147
+ const decided = events.findLast((event) => event.eventType === 'decided');
148
+ if (!requested || !decided || decided.status !== 'approved') return null;
149
+ if (decided.taskId !== requested.taskId) throw escalationError('approval task does not match its request');
150
+ if ((requested.approvalContextDigest || null) !== (decided.approvalContextDigest || null)) {
151
+ throw escalationError('approval context does not match its request');
152
+ }
153
+ if (decided.actor?.kind !== 'human' || sameIdentity(requested.requester, decided.actor)) {
154
+ throw escalationError('approval is not from an independent human actor');
155
+ }
156
+ const verificationKey = optionalString(
157
+ options.approvalVerificationKey || process.env.THUMBGATE_HUMAN_REVIEWER_KEY
158
+ );
159
+ if (!verificationKey || !verifyApprovalReceipt(decided, verificationKey)) {
160
+ throw escalationError('approval receipt is missing or unauthenticated');
161
+ }
162
+ return { ...requested, ...decided };
97
163
  }
98
164
 
99
165
  function listEscalations(options = {}) {
100
- const events = readEvents(options);
166
+ return projectEscalations(readEvents(options), options);
167
+ }
168
+
169
+ function projectEscalations(events, options = {}) {
101
170
  const byId = new Map();
102
171
  for (const event of events) {
103
172
  const current = byId.get(event.escalationId) || {};
@@ -143,26 +212,254 @@ function calculateEscalationMetrics(escalations = [], now = new Date()) {
143
212
  }
144
213
 
145
214
  function readEvents(options = {}) {
215
+ return readLedger(options).events;
216
+ }
217
+
218
+ function readLedger(options = {}) {
146
219
  const inputPath = options.inputPath ? path.resolve(options.inputPath) : getEscalationsPath(options);
147
220
  let raw = '';
148
221
  try {
149
222
  raw = fs.readFileSync(inputPath, 'utf8');
150
- } catch {
151
- return [];
223
+ } catch (error) {
224
+ if (error.code !== 'ENOENT') throw error;
225
+ return { events: [], malformedRows: [], head: readLedgerHead(options) };
152
226
  }
153
- return raw.split('\n').map((line) => line.trim()).filter(Boolean).flatMap((line) => {
227
+ const events = [];
228
+ const malformedRows = [];
229
+ raw.split('\n').forEach((line, index) => {
230
+ if (!line.trim()) return;
154
231
  try {
155
- return [JSON.parse(line)];
232
+ events.push(JSON.parse(line));
156
233
  } catch {
157
- return [];
234
+ malformedRows.push(index + 1);
158
235
  }
159
236
  });
237
+ return { events, malformedRows, head: readLedgerHead(options) };
160
238
  }
161
239
 
162
- function appendEvent(event, options) {
240
+ function appendEventUnlocked(event, options, existingEvents) {
163
241
  const outputPath = getEscalationsPath(options);
242
+ const previous = existingEvents.at(-1) || null;
243
+ const chained = {
244
+ ...event,
245
+ schemaVersion: 'human-escalation-v2',
246
+ sequence: existingEvents.length + 1,
247
+ previousEventHash: previous?.eventHash || null,
248
+ };
249
+ chained.eventHash = eventHash(chained);
164
250
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
165
- fs.appendFileSync(outputPath, `${JSON.stringify(event)}\n`, 'utf8');
251
+ const journalPath = getEscalationsJournalPath(options);
252
+ writeAtomicJson(journalPath, {
253
+ schemaVersion: 'human-escalation-journal-v1',
254
+ previousHead: readLedgerHead(options),
255
+ event: chained,
256
+ });
257
+ const ledgerFd = fs.openSync(outputPath, 'a', 0o600);
258
+ try {
259
+ fs.writeSync(ledgerFd, `${JSON.stringify(chained)}\n`, null, 'utf8');
260
+ fs.fsyncSync(ledgerFd);
261
+ } finally {
262
+ fs.closeSync(ledgerFd);
263
+ }
264
+ fsyncDirectoryFor(outputPath);
265
+ writeLedgerHead(chained, options);
266
+ removeDurableFile(journalPath);
267
+ return chained;
268
+ }
269
+
270
+ function assertLedgerHealthy(ledger) {
271
+ if (!validateEscalationLedger(ledger.events, ledger.malformedRows, ledger.head).ok) {
272
+ throw escalationError('refusing to append to a damaged escalation ledger');
273
+ }
274
+ }
275
+
276
+ function withEscalationLock(options, callback) {
277
+ return withFileLedgerLock(`${getEscalationsPath(options)}.lock`, callback, {
278
+ now: options.now,
279
+ lockStaleMs: options.lockStaleMs,
280
+ errorFactory: (message) => escalationError(message),
281
+ beforeCallback: () => recoverEscalationTransaction(options),
282
+ });
283
+ }
284
+
285
+ function readLedgerHead(options = {}) {
286
+ try {
287
+ return JSON.parse(fs.readFileSync(getEscalationsHeadPath(options), 'utf8'));
288
+ } catch (error) {
289
+ if (error.code === 'ENOENT') return null;
290
+ return { malformed: true };
291
+ }
292
+ }
293
+
294
+ function writeLedgerHead(event, options = {}) {
295
+ const headPath = getEscalationsHeadPath(options);
296
+ const head = {
297
+ schemaVersion: 'human-escalation-head-v1',
298
+ sequence: event.sequence,
299
+ eventHash: event.eventHash,
300
+ };
301
+ writeAtomicJson(headPath, head);
302
+ }
303
+
304
+ function recoverEscalationTransaction(options = {}) {
305
+ const journalPath = getEscalationsJournalPath(options);
306
+ let journal;
307
+ try {
308
+ journal = JSON.parse(fs.readFileSync(journalPath, 'utf8'));
309
+ } catch (error) {
310
+ if (error.code === 'ENOENT') return;
311
+ throw escalationError(`cannot recover escalation journal: ${error.message}`);
312
+ }
313
+ if (journal?.schemaVersion !== 'human-escalation-journal-v1'
314
+ || !journal.event
315
+ || journal.event.eventHash !== eventHash(journal.event)) {
316
+ throw escalationError('escalation journal integrity verification failed');
317
+ }
318
+
319
+ const ledger = readLedger(options);
320
+ const event = journal.event;
321
+ const currentLast = ledger.events.at(-1) || null;
322
+ const eventHead = { sequence: event.sequence, eventHash: event.eventHash };
323
+ const eventAlreadyAppended = sameHead(currentLast, eventHead);
324
+ const headAtPrevious = sameHead(ledger.head, journal.previousHead);
325
+ const headAtEvent = sameHead(ledger.head, eventHead);
326
+
327
+ if (eventAlreadyAppended) {
328
+ const preceding = ledger.events.at(-2) || null;
329
+ const precedingMatches = event.sequence === ledger.events.length
330
+ && event.previousEventHash === (preceding?.eventHash || null);
331
+ const syntheticHead = {
332
+ schemaVersion: 'human-escalation-head-v1',
333
+ ...eventHead,
334
+ };
335
+ const integrity = validateEscalationLedger(ledger.events, ledger.malformedRows, syntheticHead);
336
+ if (!precedingMatches || !integrity.ok || (!headAtPrevious && !headAtEvent)) {
337
+ throw escalationError('escalation journal does not match the recoverable append');
338
+ }
339
+ if (!headAtEvent) writeLedgerHead(event, options);
340
+ removeDurableFile(journalPath);
341
+ return;
342
+ }
343
+
344
+ const currentIntegrity = validateEscalationLedger(ledger.events, ledger.malformedRows, ledger.head);
345
+ if (currentIntegrity.ok && headAtPrevious && event.sequence === ledger.events.length + 1) {
346
+ // No append became durable, so the caller never received success. Discard
347
+ // the prepared transaction and let the original operation be retried.
348
+ removeDurableFile(journalPath);
349
+ return;
350
+ }
351
+ throw escalationError('escalation journal cannot be reconciled safely');
352
+ }
353
+
354
+ function sameHead(left, right) {
355
+ if (!left && !right) return true;
356
+ return left?.sequence === right?.sequence && left?.eventHash === right?.eventHash;
357
+ }
358
+
359
+ function writeAtomicJson(targetPath, value) {
360
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
361
+ const temporaryPath = `${targetPath}.tmp-${process.pid}-${crypto.randomUUID()}`;
362
+ const fd = fs.openSync(temporaryPath, 'w', 0o600);
363
+ try {
364
+ fs.writeSync(fd, `${JSON.stringify(value)}\n`, null, 'utf8');
365
+ fs.fsyncSync(fd);
366
+ } finally {
367
+ fs.closeSync(fd);
368
+ }
369
+ fs.renameSync(temporaryPath, targetPath);
370
+ fsyncDirectoryFor(targetPath);
371
+ }
372
+
373
+ function removeDurableFile(targetPath) {
374
+ fs.unlinkSync(targetPath);
375
+ fsyncDirectoryFor(targetPath);
376
+ }
377
+
378
+ function fsyncDirectoryFor(targetPath) {
379
+ const directoryFd = fs.openSync(path.dirname(targetPath), 'r');
380
+ try {
381
+ fs.fsyncSync(directoryFd);
382
+ } finally {
383
+ fs.closeSync(directoryFd);
384
+ }
385
+ }
386
+
387
+ function validateEscalationLedger(events, malformedRows = [], head = null) {
388
+ const invalidEventHashes = [];
389
+ const invalidChainLinks = [];
390
+ let previousHash = null;
391
+ let chainedEvents = 0;
392
+ events.forEach((event, index) => {
393
+ const sequence = index + 1;
394
+ if (event.eventHash !== eventHash(event)) invalidEventHashes.push(sequence);
395
+ const isLegacy = !event.schemaVersion
396
+ && event.sequence === undefined
397
+ && event.previousEventHash === undefined;
398
+ if (!isLegacy) chainedEvents += 1;
399
+ // Legacy events predate the global chain. They may remain only as a
400
+ // contiguous prefix; the first new event seals their terminal hash into
401
+ // the v2 chain and creates the external head checkpoint.
402
+ if ((!isLegacy && (event.sequence !== sequence || event.previousEventHash !== previousHash))
403
+ || (isLegacy && chainedEvents > 0)) {
404
+ invalidChainLinks.push(sequence);
405
+ }
406
+ previousHash = event.eventHash || null;
407
+ });
408
+ const expected = events.at(-1) || null;
409
+ const headValid = expected === null
410
+ ? head === null
411
+ : chainedEvents === 0
412
+ ? head === null
413
+ : head?.schemaVersion === 'human-escalation-head-v1'
414
+ && head.sequence === expected.sequence
415
+ && head.eventHash === expected.eventHash;
416
+ return {
417
+ ok: malformedRows.length === 0
418
+ && invalidEventHashes.length === 0
419
+ && invalidChainLinks.length === 0
420
+ && headValid,
421
+ malformedRows,
422
+ invalidEventHashes,
423
+ invalidChainLinks,
424
+ headValid,
425
+ };
426
+ }
427
+
428
+ function signApprovalReceipt(event, signingKey) {
429
+ return {
430
+ algorithm: 'hmac-sha256',
431
+ keyId: crypto.createHash('sha256').update(signingKey).digest('hex').slice(0, 16),
432
+ signature: crypto.createHmac('sha256', signingKey).update(approvalPayload(event)).digest('hex'),
433
+ };
434
+ }
435
+
436
+ function verifyApprovalReceipt(event, verificationKey) {
437
+ const receipt = event.approvalReceipt;
438
+ if (!receipt || receipt.algorithm !== 'hmac-sha256') return false;
439
+ const expectedKeyId = crypto.createHash('sha256').update(verificationKey).digest('hex').slice(0, 16);
440
+ if (receipt.keyId !== expectedKeyId) return false;
441
+ const expected = crypto.createHmac('sha256', verificationKey).update(approvalPayload(event)).digest();
442
+ let actual;
443
+ try {
444
+ actual = Buffer.from(String(receipt.signature || ''), 'hex');
445
+ } catch {
446
+ return false;
447
+ }
448
+ return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
449
+ }
450
+
451
+ function approvalPayload(event) {
452
+ const payload = {
453
+ escalationId: event.escalationId,
454
+ taskId: event.taskId,
455
+ status: event.status,
456
+ decision: event.decision,
457
+ actor: event.actor,
458
+ reason: event.reason,
459
+ decidedAt: event.decidedAt,
460
+ };
461
+ if (event.approvalContextDigest) payload.approvalContextDigest = event.approvalContextDigest;
462
+ return stableStringify(payload);
166
463
  }
167
464
 
168
465
  function requiredIdentity(value, field) {
@@ -187,6 +484,14 @@ function optionalString(value) {
187
484
  return clean || undefined;
188
485
  }
189
486
 
487
+ function optionalDigest(value, field) {
488
+ const digest = optionalString(value);
489
+ if (digest && !/^[a-f0-9]{64}$/i.test(digest)) {
490
+ throw escalationError(`${field} must be a SHA-256 hex digest`);
491
+ }
492
+ return digest?.toLowerCase();
493
+ }
494
+
190
495
  function stringArray(value) {
191
496
  return Array.isArray(value) ? value.map((entry) => String(entry).trim()).filter(Boolean) : [];
192
497
  }
@@ -201,7 +506,9 @@ function sameIdentity(a, b) {
201
506
  }
202
507
 
203
508
  function eventHash(event) {
204
- return crypto.createHash('sha256').update(stableStringify(event)).digest('hex');
509
+ const copy = { ...event };
510
+ delete copy.eventHash;
511
+ return crypto.createHash('sha256').update(stableStringify(copy)).digest('hex');
205
512
  }
206
513
 
207
514
  function eventComparableHash(event) {
@@ -213,6 +520,7 @@ function eventComparableHash(event) {
213
520
  requester: event.requester,
214
521
  evidence: event.evidence,
215
522
  };
523
+ if (event.approvalContextDigest) comparable.approvalContextDigest = event.approvalContextDigest;
216
524
  return crypto.createHash('sha256').update(stableStringify(comparable)).digest('hex');
217
525
  }
218
526
 
@@ -259,7 +567,11 @@ module.exports = {
259
567
  calculateEscalationMetrics,
260
568
  decideEscalation,
261
569
  getEscalation,
570
+ getEscalationsHeadPath,
571
+ getEscalationsJournalPath,
262
572
  getEscalationsPath,
573
+ getVerifiedApproval,
263
574
  listEscalations,
264
575
  requestEscalation,
576
+ validateEscalationLedger,
265
577
  };
@@ -15,14 +15,16 @@
15
15
  const path = require('node:path');
16
16
  const fs = require('node:fs');
17
17
  const { readJsonl } = require('./fs-utils');
18
+ const { resolveFeedbackDir } = require('./feedback-paths');
18
19
 
19
- const PROJECT_ROOT = path.join(__dirname, '..');
20
- const DEFAULT_DB_PATH = path.join(PROJECT_ROOT, '.claude', 'memory', 'lessons.sqlite');
20
+ function resolveDefaultDbPath(options = {}) {
21
+ return path.join(resolveFeedbackDir(options), 'lessons.sqlite');
22
+ }
21
23
 
22
24
  /** @returns {import('better-sqlite3').Database} */
23
25
  function initDB(dbPath) {
24
26
  const Database = require('better-sqlite3');
25
- const resolvedPath = dbPath || process.env.LESSON_DB_PATH || DEFAULT_DB_PATH;
27
+ const resolvedPath = dbPath || process.env.LESSON_DB_PATH || resolveDefaultDbPath();
26
28
 
27
29
  // Ensure parent directory exists
28
30
  const dir = path.dirname(resolvedPath);
@@ -643,7 +645,7 @@ function safeParseTags(tagsStr) {
643
645
  }
644
646
  }
645
647
 
646
- module.exports = {
648
+ const lessonDbApi = {
647
649
  initDB,
648
650
  upsertLesson,
649
651
  upsertSession,
@@ -655,5 +657,14 @@ module.exports = {
655
657
  getStats,
656
658
  getStatsFromDB,
657
659
  backfillFromJsonl,
658
- DEFAULT_DB_PATH,
660
+ resolveDefaultDbPath,
659
661
  };
662
+
663
+ // Preserve the public property while resolving it at access time so callers
664
+ // cannot cache a package-checkout path before project selection is known.
665
+ Object.defineProperty(lessonDbApi, 'DEFAULT_DB_PATH', {
666
+ enumerable: true,
667
+ get: resolveDefaultDbPath,
668
+ });
669
+
670
+ module.exports = lessonDbApi;
@@ -12,7 +12,8 @@
12
12
  *
13
13
  * Design constraints:
14
14
  * - Embedding the whole corpus on every tool call is too expensive. We cache
15
- * document vectors keyed by `id + sha1(text)` in <feedbackDir>/lesson-embeddings.json.
15
+ * document vectors keyed by `id + sha256(text) + provider + dimension` in
16
+ * <feedbackDir>/lesson-embeddings.json.
16
17
  * Only the query is embedded per call; only new/changed lessons re-embed.
17
18
  * - The embedder is reused from vector-store.embed (Gemini -> local transformers
18
19
  * -> stub). No new embedding dependency.
@@ -92,15 +93,12 @@ function cosineSimilarity(a, b) {
92
93
  * WITHOUT loading a model. Returns true for the deterministic stub (test/CI safe).
93
94
  */
94
95
  function isEmbedderAvailable() {
95
- if (process.env.THUMBGATE_VECTOR_STUB_EMBED === 'true') return true;
96
- // Managed Gemini path
97
96
  try {
98
- const { resolveGeminiEmbeddingConfig } = require('./gemini-embedding-policy');
99
- const cfg = resolveGeminiEmbeddingConfig();
100
- if (cfg && cfg.enabled && cfg.apiKey) return true;
101
- } catch { /* policy module unavailable */ }
102
- // The zero-dependency feature-hash provider is always available locally.
103
- return true;
97
+ const { hasSemanticEmbeddingProvider } = require('./vector-store');
98
+ return hasSemanticEmbeddingProvider();
99
+ } catch {
100
+ return false;
101
+ }
104
102
  }
105
103
 
106
104
  function defaultEmbedder() {
@@ -122,12 +120,34 @@ function readCache(cachePath) {
122
120
  function writeCache(cachePath, cache) {
123
121
  try {
124
122
  fs.mkdirSync(path.dirname(cachePath), { recursive: true });
125
- fs.writeFileSync(cachePath, JSON.stringify(cache));
123
+ const tmpPath = `${cachePath}.${process.pid}.tmp`;
124
+ fs.writeFileSync(tmpPath, JSON.stringify(cache), { mode: 0o600 });
125
+ fs.renameSync(tmpPath, cachePath);
126
+ fs.chmodSync(cachePath, 0o600);
126
127
  } catch {
127
128
  /* cache is best-effort; never throw into the hot path */
128
129
  }
129
130
  }
130
131
 
132
+ function resolveProviderFingerprint(options, vector) {
133
+ if (options.embedder) {
134
+ return String(options.embedderId || options.embedder.providerId || 'injected-test');
135
+ }
136
+ try {
137
+ const { getLastEmbeddingProfile } = require('./vector-store');
138
+ const profile = getLastEmbeddingProfile();
139
+ const active = profile && profile.activeProfile;
140
+ return [
141
+ profile && profile.source,
142
+ active && active.id,
143
+ active && active.model,
144
+ Array.isArray(vector) ? vector.length : 0,
145
+ ].filter(Boolean).join(':') || 'unknown';
146
+ } catch {
147
+ return 'unknown';
148
+ }
149
+ }
150
+
131
151
  /**
132
152
  * Rank lessons by dense (embedding) similarity to the query.
133
153
  * Returns [{ id, score }] sorted descending. Embeds the query once; reuses cached
@@ -136,10 +156,17 @@ function writeCache(cachePath, cache) {
136
156
  * @returns {Promise<Array<{id:string, score:number}>>}
137
157
  */
138
158
  async function semanticRank(queryText, lessons = [], options = {}) {
139
- const { feedbackDir, embedder = defaultEmbedder(), persist = true, truncateDimension = null } = options;
159
+ const {
160
+ feedbackDir,
161
+ embedder = defaultEmbedder(),
162
+ persist = true,
163
+ pruneCache = true,
164
+ truncateDimension = null,
165
+ cacheFile = CACHE_FILE,
166
+ } = options;
140
167
  if (!queryText || !Array.isArray(lessons) || lessons.length === 0) return [];
141
168
 
142
- const cachePath = getCachePath(feedbackDir);
169
+ const cachePath = path.join(resolveFeedbackDir(feedbackDir), path.basename(cacheFile));
143
170
  const cache = readCache(cachePath);
144
171
  let cacheDirty = false;
145
172
 
@@ -149,6 +176,11 @@ async function semanticRank(queryText, lessons = [], options = {}) {
149
176
  if (truncateDimension) {
150
177
  queryVector = truncateVector(queryVector, truncateDimension);
151
178
  }
179
+ const provider = resolveProviderFingerprint(options, queryVector);
180
+ const dimension = queryVector.length;
181
+ if (!options.embedder && /(?:built-in|feature-hash)/i.test(provider)) {
182
+ throw new Error('Semantic embedding provider degraded to feature hashing');
183
+ }
152
184
 
153
185
  const scored = [];
154
186
  for (const lesson of lessons) {
@@ -158,14 +190,22 @@ async function semanticRank(queryText, lessons = [], options = {}) {
158
190
  const hash = hashText(text);
159
191
 
160
192
  let entry = cache[lesson.id];
161
- if (!entry || entry.hash !== hash || !Array.isArray(entry.vector)) {
193
+ if (
194
+ !entry
195
+ || entry.hash !== hash
196
+ || entry.provider !== provider
197
+ || entry.dimension !== dimension
198
+ || !Array.isArray(entry.vector)
199
+ || entry.vector.length !== dimension
200
+ ) {
162
201
  const vector = await embedder(text, {
163
202
  kind: 'document',
164
203
  task: 'code retrieval',
165
204
  title: lesson.title || undefined,
166
205
  });
167
206
  if (!Array.isArray(vector) || vector.length === 0) continue;
168
- entry = { hash, vector };
207
+ if (vector.length !== dimension) continue;
208
+ entry = { hash, provider, dimension, vector };
169
209
  cache[lesson.id] = entry;
170
210
  cacheDirty = true;
171
211
  }
@@ -174,12 +214,16 @@ async function semanticRank(queryText, lessons = [], options = {}) {
174
214
  scored.push({ id: lesson.id, score: cosineSimilarity(queryVector, docVector) });
175
215
  }
176
216
 
177
- // Prune cache entries for lessons no longer present (bounded growth).
178
- const liveIds = new Set(lessons.map((l) => l && l.id).filter(Boolean));
179
- for (const id of Object.keys(cache)) {
180
- if (!liveIds.has(id)) {
181
- delete cache[id];
182
- cacheDirty = true;
217
+ // Prune only when the caller supplied the complete corpus. Metadata-filtered
218
+ // searches pass pruneCache=false so alternating filters cannot evict and
219
+ // regenerate one another's vectors.
220
+ if (pruneCache) {
221
+ const liveIds = new Set(lessons.map((l) => l && l.id).filter(Boolean));
222
+ for (const id of Object.keys(cache)) {
223
+ if (!liveIds.has(id)) {
224
+ delete cache[id];
225
+ cacheDirty = true;
226
+ }
183
227
  }
184
228
  }
185
229
 
@@ -196,4 +240,7 @@ module.exports = {
196
240
  lessonText,
197
241
  hashText,
198
242
  getCachePath,
243
+ readCache,
244
+ writeCache,
245
+ resolveProviderFingerprint,
199
246
  };