thumbgate 1.27.4 → 1.27.7

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 (104) hide show
  1. package/.claude/commands/dashboard.md +15 -0
  2. package/.claude/commands/thumbgate-blocked.md +27 -0
  3. package/.claude/commands/thumbgate-dashboard.md +15 -0
  4. package/.claude/commands/thumbgate-doctor.md +30 -0
  5. package/.claude/commands/thumbgate-guard.md +36 -0
  6. package/.claude/commands/thumbgate-protect.md +30 -0
  7. package/.claude/commands/thumbgate-rules.md +30 -0
  8. package/.claude-plugin/plugin.json +2 -1
  9. package/.well-known/llms.txt +6 -2
  10. package/.well-known/mcp/server-card.json +1 -1
  11. package/README.md +49 -5
  12. package/adapters/claude/.mcp.json +2 -2
  13. package/adapters/letta/README.md +41 -0
  14. package/adapters/letta/thumbgate-letta-adapter.js +133 -0
  15. package/adapters/mcp/server-stdio.js +16 -1
  16. package/adapters/opencode/opencode.json +1 -1
  17. package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
  18. package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
  19. package/bench/observability-eval-suite.json +26 -0
  20. package/bin/cli.js +230 -6
  21. package/bin/postinstall.js +1 -1
  22. package/commands/dashboard.md +15 -0
  23. package/commands/thumbgate-dashboard.md +15 -0
  24. package/config/gate-templates.json +84 -0
  25. package/config/gates/claim-verification.json +12 -0
  26. package/config/gates/default.json +20 -0
  27. package/config/github-about.json +1 -1
  28. package/config/model-candidates.json +50 -0
  29. package/config/post-deploy-marketing-pages.json +5 -0
  30. package/package.json +67 -25
  31. package/public/agent-manager.html +41 -1
  32. package/public/agents-cost-savings.html +1 -1
  33. package/public/ai-malpractice-prevention.html +2 -1
  34. package/public/assets/brand/github-social-preview.png +0 -0
  35. package/public/assets/brand/thumbgate-icon-512.png +0 -0
  36. package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
  37. package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
  38. package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
  39. package/public/assets/brand/thumbgate-mark-inline.svg +15 -0
  40. package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
  41. package/public/assets/brand/thumbgate-mark-team.svg +26 -0
  42. package/public/assets/brand/thumbgate-mark.svg +15 -0
  43. package/public/assets/brand/thumbgate-wordmark.svg +20 -0
  44. package/public/assets/claude-thumbgate-statusbar.svg +8 -0
  45. package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
  46. package/public/assets/legal-intake-control-flow.svg +66 -0
  47. package/public/blog.html +1 -1
  48. package/public/brand/thumbgate-mark.svg +15 -0
  49. package/public/brand/thumbgate-og.svg +16 -0
  50. package/public/codex-enterprise.html +1 -1
  51. package/public/codex-plugin.html +1 -1
  52. package/public/compare.html +23 -3
  53. package/public/dashboard.html +316 -30
  54. package/public/federal.html +1 -1
  55. package/public/guide.html +5 -4
  56. package/public/index.html +167 -49
  57. package/public/js/buyer-intent.js +672 -0
  58. package/public/learn.html +88 -7
  59. package/public/lessons.html +2 -1
  60. package/public/numbers.html +3 -3
  61. package/public/pricing.html +63 -15
  62. package/public/pro.html +7 -7
  63. package/scripts/activation-quickstart.js +187 -0
  64. package/scripts/agent-memory-lifecycle.js +211 -0
  65. package/scripts/async-eval-observability.js +236 -0
  66. package/scripts/auto-promote-gates.js +75 -4
  67. package/scripts/billing.js +12 -1
  68. package/scripts/build-metadata.js +24 -3
  69. package/scripts/cli-schema.js +42 -10
  70. package/scripts/dashboard-chat.js +53 -7
  71. package/scripts/dashboard.js +12 -17
  72. package/scripts/export-databricks-bundle.js +5 -1
  73. package/scripts/export-dpo-pairs.js +7 -2
  74. package/scripts/feedback-aggregate.js +281 -0
  75. package/scripts/feedback-loop.js +121 -0
  76. package/scripts/filesystem-search.js +35 -10
  77. package/scripts/gates-engine.js +234 -7
  78. package/scripts/gemini-embedding-policy.js +2 -1
  79. package/scripts/hook-stop-anti-claim.js +227 -0
  80. package/scripts/hook-thumbgate-cache-updater.js +18 -2
  81. package/scripts/hybrid-feedback-context.js +1 -0
  82. package/scripts/lesson-inference.js +8 -3
  83. package/scripts/lesson-search.js +17 -1
  84. package/scripts/operational-integrity.js +39 -5
  85. package/scripts/plausible-domain-config.js +15 -2
  86. package/scripts/plausible-server-events.js +4 -4
  87. package/scripts/rate-limiter.js +12 -6
  88. package/scripts/secret-redaction.js +166 -0
  89. package/scripts/security-scanner.js +100 -0
  90. package/scripts/self-distill-agent.js +3 -1
  91. package/scripts/self-harness-optimizer.js +141 -0
  92. package/scripts/seo-gsd.js +635 -0
  93. package/scripts/statusline-cache-path.js +17 -2
  94. package/scripts/statusline-cache-read.js +57 -0
  95. package/scripts/statusline-local-stats.js +9 -1
  96. package/scripts/statusline-meta.js +5 -2
  97. package/scripts/statusline.sh +13 -1
  98. package/scripts/sync-telemetry-from-prod.js +374 -0
  99. package/scripts/telemetry-analytics.js +9 -0
  100. package/scripts/thumbgate-search.js +85 -19
  101. package/scripts/tool-contract-validator.js +76 -0
  102. package/scripts/vector-store.js +44 -0
  103. package/scripts/workspace-evolver.js +62 -2
  104. package/src/api/server.js +862 -146
@@ -58,6 +58,47 @@ function readJSONL(filePath) {
58
58
  }).filter(Boolean);
59
59
  }
60
60
 
61
+ // --- Self-Harness stage 3: regression-gated promotion -----------------------
62
+ // Inspired by "Self-Harness: Harnesses That Improve Themselves" (arXiv 2606.09498).
63
+ // Stages 1-2 (weakness mining -> rule extraction) already exist via lesson
64
+ // inference + this promoter. Stage 3 — accept a harness change only after
65
+ // regression-testing it does not degrade behavior — was missing: a noisy 3x
66
+ // capture could hard-block an over-broad pattern with no check that it wouldn't
67
+ // have wrongly blocked actions that were previously ALLOWED. This replays a
68
+ // candidate BLOCK rule against the audit trail's prior `allow` decisions; if it
69
+ // would have blocked safe actions, the caller quarantines it to `warn` instead.
70
+ const REGRESSION_FALSE_BLOCK_LIMIT = 0; // any prior safe action it would block => quarantine
71
+
72
+ function getAuditTrailPath() {
73
+ return path.join(path.dirname(getFeedbackLogPath()), 'audit-trail.jsonl');
74
+ }
75
+
76
+ // Returns { falseBlocks, allowSampleSize } or null when there is no history /
77
+ // matcher available — in which case the caller promotes as usual (fail-open to
78
+ // existing behavior, since regression gating is an enhancement, not a hard gate).
79
+ function regressionCheck(gate, options = {}) {
80
+ const auditPath = options.auditTrailPath || getAuditTrailPath();
81
+ const entries = readJSONL(auditPath);
82
+ if (!entries.length) return null;
83
+ // Lazy-require to avoid the gates-engine <-> auto-promote-gates require cycle.
84
+ let matchesGate;
85
+ try { ({ matchesGate } = require('./gates-engine')); } catch { return null; }
86
+ if (typeof matchesGate !== 'function') return null;
87
+ const allowed = entries.filter((e) => e && e.decision === 'allow' && e.toolName);
88
+ if (!allowed.length) return null;
89
+ let falseBlocks = 0;
90
+ for (const e of allowed) {
91
+ try {
92
+ if (matchesGate(gate, e.toolName, e.toolInput || {})) falseBlocks += 1;
93
+ } catch { /* a bad pattern/entry never counts as a false block */ }
94
+ }
95
+ return { falseBlocks, allowSampleSize: allowed.length };
96
+ }
97
+
98
+ function safeRegressionCheck(gate, options) {
99
+ try { return regressionCheck(gate, options); } catch { return null; }
100
+ }
101
+
61
102
  function loadAutoGates() {
62
103
  const autoGatesPath = getAutoGatesPath();
63
104
  if (!fs.existsSync(autoGatesPath)) {
@@ -358,9 +399,16 @@ function promote(feedbackLogPath, options) {
358
399
  const existing = data.gates[existingIdx];
359
400
  const newAction = group.count >= BLOCK_THRESHOLD ? 'block' : 'warn';
360
401
  if (existing.action !== newAction && newAction === 'block') {
361
- // Upgrade from warn to block
362
- data.gates[existingIdx] = { ...existing, action: 'block', severity: 'critical', occurrences: group.count, upgradedAt: new Date().toISOString() };
363
- promotions.push({ type: 'upgrade', gateId, from: existing.action, to: 'block', occurrences: group.count });
402
+ // Self-Harness stage 3: regression-test before upgrading warn -> block.
403
+ const regression = opts.skipRegression ? null : safeRegressionCheck(buildGateRule(group, 'block'), opts);
404
+ if (regression && regression.falseBlocks > REGRESSION_FALSE_BLOCK_LIMIT) {
405
+ // Would block prior safe actions — hold at warn instead of upgrading.
406
+ promotions.push({ type: 'upgrade-quarantined', gateId, from: existing.action, occurrences: group.count, falseBlocks: regression.falseBlocks });
407
+ } else {
408
+ // Upgrade from warn to block
409
+ data.gates[existingIdx] = { ...existing, action: 'block', severity: 'critical', occurrences: group.count, upgradedAt: new Date().toISOString() };
410
+ promotions.push({ type: 'upgrade', gateId, from: existing.action, to: 'block', occurrences: group.count });
411
+ }
364
412
  }
365
413
  // Update occurrence count even if no action change
366
414
  data.gates[existingIdx].occurrences = group.count;
@@ -370,6 +418,20 @@ function promote(feedbackLogPath, options) {
370
418
  // New gate — respect explicit gateAction override (e.g. 'approve' for human-approval rules)
371
419
  const gate = buildGateRule(group, opts.gateAction);
372
420
 
421
+ // Self-Harness stage 3: before a feedback rule goes live as a hard block,
422
+ // regression-test it against prior allowed actions. If it would have blocked
423
+ // safe actions, quarantine it to `warn` instead of `block`.
424
+ let regression = null;
425
+ if (gate.action === 'block' && !opts.gateAction && !opts.skipRegression) {
426
+ regression = safeRegressionCheck(gate, opts);
427
+ if (regression && regression.falseBlocks > REGRESSION_FALSE_BLOCK_LIMIT) {
428
+ gate.action = 'warn';
429
+ gate.severity = 'medium';
430
+ gate.quarantined = true;
431
+ gate.regression = regression;
432
+ }
433
+ }
434
+
373
435
  // Enforce max limit — rotate oldest
374
436
  if (data.gates.length >= MAX_AUTO_GATES) {
375
437
  const removed = data.gates.shift();
@@ -377,7 +439,13 @@ function promote(feedbackLogPath, options) {
377
439
  }
378
440
 
379
441
  data.gates.push(gate);
380
- promotions.push({ type: 'new', gateId: gate.id, action: gate.action, occurrences: group.count });
442
+ promotions.push({
443
+ type: gate.quarantined ? 'new-quarantined' : 'new',
444
+ gateId: gate.id,
445
+ action: gate.action,
446
+ occurrences: group.count,
447
+ ...(gate.quarantined ? { falseBlocks: regression.falseBlocks, allowSampleSize: regression.allowSampleSize } : {}),
448
+ });
381
449
  }
382
450
 
383
451
  // Log promotions
@@ -438,6 +506,9 @@ module.exports = {
438
506
  groupNegativeFeedback,
439
507
  patternToGateId,
440
508
  buildGateRule,
509
+ regressionCheck,
510
+ getAuditTrailPath,
511
+ REGRESSION_FALSE_BLOCK_LIMIT,
441
512
  extractPatternKey,
442
513
  normalizeCommandSignature,
443
514
  isNegative,
@@ -139,6 +139,10 @@ const IS_TEST = !!(
139
139
  process.env.NODE_ENV === 'test'
140
140
  );
141
141
 
142
+ function allowUnsignedStripeWebhooks() {
143
+ return IS_TEST && process.env.THUMBGATE_ALLOW_UNSIGNED_STRIPE_WEBHOOKS === '1';
144
+ }
145
+
142
146
  function shouldMergeLegacyBillingData() {
143
147
  return process.env._TEST_INCLUDE_LEGACY_BILLING_DATA === '1'
144
148
  || process.env.THUMBGATE_INCLUDE_LEGACY_BILLING_DATA === '1';
@@ -2901,7 +2905,7 @@ function disableCustomerKeys(customerId) {
2901
2905
  }
2902
2906
 
2903
2907
  function verifyWebhookSignature(rawBody, signature) {
2904
- if (!CONFIG.STRIPE_WEBHOOK_SECRET) return true;
2908
+ if (!CONFIG.STRIPE_WEBHOOK_SECRET) return allowUnsignedStripeWebhooks();
2905
2909
  if (!signature || !rawBody) return false;
2906
2910
 
2907
2911
  // Stripe signature format: t=<timestamp>,v1=<hmac>,...
@@ -2931,6 +2935,13 @@ function verifyWebhookSignature(rawBody, signature) {
2931
2935
 
2932
2936
  async function handleWebhook(rawBody, signature) {
2933
2937
  if (LOCAL_MODE()) return { handled: false, reason: 'local_mode' };
2938
+ if (!CONFIG.STRIPE_WEBHOOK_SECRET && !allowUnsignedStripeWebhooks()) {
2939
+ return {
2940
+ handled: false,
2941
+ reason: 'invalid_signature',
2942
+ error: 'STRIPE_WEBHOOK_SECRET is required before Stripe webhooks can be processed.',
2943
+ };
2944
+ }
2934
2945
  let event;
2935
2946
  try {
2936
2947
  if (CONFIG.STRIPE_WEBHOOK_SECRET) {
@@ -5,6 +5,11 @@ const PROJECT_ROOT = path.resolve(__dirname, '..');
5
5
  const DEFAULT_BUILD_METADATA_PATH = path.join(PROJECT_ROOT, 'config', 'build-metadata.json');
6
6
  const BUILD_SHA_ENV_KEY = 'THUMBGATE_BUILD_SHA';
7
7
  const BUILD_GENERATED_AT_ENV_KEY = 'THUMBGATE_BUILD_GENERATED_AT';
8
+ // Railway injects this automatically for GitHub-connected deployments: the git
9
+ // SHA of the commit that triggered the deploy. It is the ground truth for what
10
+ // code is actually live, and unlike THUMBGATE_BUILD_SHA it cannot drift (Railway
11
+ // sets it per deploy). https://docs.railway.com/reference/variables
12
+ const RAILWAY_GIT_COMMIT_SHA_ENV_KEY = 'RAILWAY_GIT_COMMIT_SHA';
8
13
 
9
14
  function normalizeNullableText(value) {
10
15
  if (typeof value !== 'string') {
@@ -28,6 +33,7 @@ function resolveBuildMetadata({ env = process.env, filePath } = {}) {
28
33
  normalizeNullableText(env.THUMBGATE_BUILD_METADATA_PATH) ||
29
34
  DEFAULT_BUILD_METADATA_PATH;
30
35
  const envBuildSha = normalizeNullableText(env[BUILD_SHA_ENV_KEY]);
36
+ const railwayGitSha = normalizeNullableText(env[RAILWAY_GIT_COMMIT_SHA_ENV_KEY]);
31
37
  const envGeneratedAt = normalizeNullableText(env[BUILD_GENERATED_AT_ENV_KEY]);
32
38
 
33
39
  let fileBuildSha = null;
@@ -48,9 +54,23 @@ function resolveBuildMetadata({ env = process.env, filePath } = {}) {
48
54
  };
49
55
  }
50
56
 
51
- // No SHA in the file fall back to env only if an explicit SHA is set.
52
- // (Previously a bare GENERATED_AT with no SHA could short-circuit and return
53
- // { buildSha: null }, losing both signals; now we require the SHA.)
57
+ // No SHA baked into the image. Prefer Railway's own per-deploy commit SHA over
58
+ // THUMBGATE_BUILD_SHA: the latter is set out-of-band by the deploy workflow and
59
+ // has drifted in prod (stuck reporting an old commit while newer code was live,
60
+ // because RAILWAY_SYNC_VARIABLES is off and `railway up` stamping is unreliable).
61
+ // RAILWAY_GIT_COMMIT_SHA is injected by Railway per deploy, so it always matches
62
+ // the code actually serving traffic on a GitHub-connected service.
63
+ if (railwayGitSha) {
64
+ return {
65
+ path: resolvedPath,
66
+ buildSha: railwayGitSha,
67
+ generatedAt: envGeneratedAt,
68
+ };
69
+ }
70
+
71
+ // Last resort: the workflow-managed env var. Only trust it when an explicit SHA
72
+ // is set. (Previously a bare GENERATED_AT with no SHA could short-circuit and
73
+ // return { buildSha: null }, losing both signals; now we require the SHA.)
54
74
  if (envBuildSha) {
55
75
  return {
56
76
  path: resolvedPath,
@@ -124,6 +144,7 @@ if (require.main === module) {
124
144
  module.exports = {
125
145
  BUILD_GENERATED_AT_ENV_KEY,
126
146
  BUILD_SHA_ENV_KEY,
147
+ RAILWAY_GIT_COMMIT_SHA_ENV_KEY,
127
148
  DEFAULT_BUILD_METADATA_PATH,
128
149
  resolveBuildMetadata,
129
150
  writeBuildMetadataFile,
@@ -94,6 +94,25 @@ const CLI_COMMANDS = [
94
94
  { name: 'remote', type: 'boolean', description: 'Fetch from hosted Railway instance' },
95
95
  ],
96
96
  },
97
+ {
98
+ name: 'brain',
99
+ aliases: ['customer-brain', 'repo-brain'],
100
+ description: 'Scaffold, query, or build the governed customer/repo context brain',
101
+ group: 'discovery',
102
+ flags: [
103
+ { name: 'json', type: 'boolean', description: 'Output as JSON' },
104
+ { name: 'task', type: 'string', description: 'Task description for routed context loading' },
105
+ { name: 'type', type: 'string', description: 'Memory type for remember: decision | pattern | feedback | log' },
106
+ { name: 'title', type: 'string', description: 'Title for a sourced memory entry' },
107
+ { name: 'content', type: 'string', description: 'Body for a sourced memory entry' },
108
+ { name: 'source', type: 'string', description: 'Required provenance for factual memory writes' },
109
+ { name: 'tags', type: 'string', description: 'Comma-separated memory tags' },
110
+ { name: 'text', type: 'string', description: 'Text/action to check against never-do rules' },
111
+ { name: 'stale-days', type: 'number', description: 'Age threshold for cleanup report (default 60)' },
112
+ { name: 'write', type: 'boolean', description: 'Save to .thumbgate/BRAIN.md (versioned, deterministic)' },
113
+ { name: 'limit', type: 'number', description: 'Max lessons to include (default 15)' },
114
+ ],
115
+ },
97
116
  {
98
117
  name: 'stats',
99
118
  description: 'Feedback analytics — approval rate, Revenue-at-Risk, recent trend',
@@ -486,6 +505,12 @@ const CLI_COMMANDS = [
486
505
  group: 'gates',
487
506
  flags: [],
488
507
  },
508
+ {
509
+ name: 'hermes-gate',
510
+ description: 'Hermes Agent pre_tool_call hook: gate runtime tool calls (incl. skill_manage) before they run',
511
+ group: 'gates',
512
+ flags: [],
513
+ },
489
514
  {
490
515
  name: 'force-gate',
491
516
  description: 'Immediately create a blocking gate from a pattern string',
@@ -617,16 +642,7 @@ const CLI_COMMANDS = [
617
642
  { name: 'info', type: 'boolean', description: 'Show Pro feature list' },
618
643
  ],
619
644
  },
620
- {
621
- name: 'brain',
622
- description: 'Build the agent-readable context brain (lessons + rules + gates + project context)',
623
- group: 'ops',
624
- flags: [
625
- { name: 'write', type: 'boolean', description: 'Save to .thumbgate/BRAIN.md (versioned, deterministic)' },
626
- { name: 'limit', type: 'number', description: 'Max lessons to include (default 15)' },
627
- { name: 'json', type: 'boolean', description: 'Output the structured model as JSON' },
628
- ],
629
- },
645
+
630
646
  {
631
647
  name: 'workflow',
632
648
  aliases: ['swarm'],
@@ -640,6 +656,22 @@ const CLI_COMMANDS = [
640
656
  { name: 'json', type: 'boolean', description: 'Output results as JSON' },
641
657
  ],
642
658
  },
659
+ {
660
+ name: 'check-update',
661
+ aliases: ['upgrade-check'],
662
+ description: 'Check for newer versions of ThumbGate from npm or GitHub',
663
+ group: 'ops',
664
+ flags: [
665
+ { name: 'json', type: 'boolean', description: 'Output results as JSON' },
666
+ ],
667
+ },
668
+ {
669
+ name: 'self-update',
670
+ aliases: ['upgrade-cli'],
671
+ description: 'Automatically install the latest version of ThumbGate globally',
672
+ group: 'ops',
673
+ flags: [],
674
+ },
643
675
  ];
644
676
 
645
677
  /**
@@ -129,6 +129,45 @@ async function retrieveVectorContext(question, opts = {}) {
129
129
  }
130
130
  }
131
131
 
132
+ // Live numeric snapshot from gate-stats + feedback analyzer. Always injected
133
+ // (~120 tokens) so the LLM can answer count/quantity questions like
134
+ // "how many were blocked today" without hallucinating.
135
+ function retrieveMetricsContext() {
136
+ const snapshot = {};
137
+ try {
138
+ const gs = require(path.join(__dirname, 'gate-stats'));
139
+ const s = gs.calculateStats();
140
+ snapshot.gates = {
141
+ total: s.totalGates,
142
+ blockRules: s.blockGates,
143
+ warnRules: s.warnGates,
144
+ totalBlockedEvents: s.totalBlocked,
145
+ totalWarnedEvents: s.totalWarned,
146
+ estimatedHoursSaved: s.estimatedHoursSaved,
147
+ topBlockedTrigger: s.topBlocked?.trigger || null,
148
+ topBlockedOccurrences: s.topBlocked?.occurrences || 0,
149
+ };
150
+ } catch (err) {
151
+ debugChatFallback('gate-stats unavailable', err);
152
+ }
153
+ try {
154
+ const fl = require(path.join(__dirname, 'feedback-loop'));
155
+ const a = fl.analyzeFeedback();
156
+ snapshot.feedback = {
157
+ total: a.total,
158
+ positive: a.totalPositive,
159
+ negative: a.totalNegative,
160
+ approvalRate: a.approvalRate,
161
+ last7d: a.windows?.['7d'],
162
+ last30d: a.windows?.['30d'],
163
+ trend: a.trend,
164
+ };
165
+ } catch (err) {
166
+ debugChatFallback('feedback-loop analyze unavailable', err);
167
+ }
168
+ return snapshot;
169
+ }
170
+
132
171
  // Retrieve relevant stored lessons and optional raw feedback vector matches.
133
172
  async function retrieveContext(question, opts = {}) {
134
173
  const lessons = retrieveLessonContext(question, opts);
@@ -137,7 +176,7 @@ async function retrieveContext(question, opts = {}) {
137
176
  }
138
177
 
139
178
  // Build a grounded RAG prompt. Pure function (testable).
140
- function buildChatPrompt(question, lessons) {
179
+ function buildChatPrompt(question, lessons, metrics) {
141
180
  const q = String(question || '').slice(0, MAX_QUESTION_CHARS).trim();
142
181
  const context = (lessons || []).map((l, i) => {
143
182
  const mark = /pos|up/i.test(l.signal) ? 'WORKED' : (/neg|down/i.test(l.signal) ? 'MISTAKE' : 'NOTE');
@@ -145,14 +184,19 @@ function buildChatPrompt(question, lessons) {
145
184
  return `(${i + 1}) [${mark}] ${l.title || ''}${tags}\n ${l.content}`;
146
185
  }).join('\n');
147
186
 
187
+ const metricsBlock = metrics && Object.keys(metrics).length
188
+ ? `\n=== Live numeric snapshot (your data, current) ===\n${JSON.stringify(metrics, null, 2)}\n`
189
+ : '';
190
+
148
191
  const system = [
149
192
  'You are ThumbGate\'s "chat with your data" assistant. Answer the user\'s question',
150
- 'using ONLY the captured lessons below (this team\'s real feedback history).',
151
- 'Be concise and specific. Cite the lesson numbers you used like [1], [3].',
152
- 'If the lessons do not contain the answer, say so plainly — do not invent facts.',
193
+ 'using ONLY the captured lessons and the live numeric snapshot below (this team\'s real data).',
194
+ 'For count/quantity questions ("how many X", "what\'s our rate"), use the numeric snapshot.',
195
+ 'For pattern/why questions, cite the lesson numbers like [1], [3].',
196
+ 'If neither source contains the answer, say so plainly — do not invent facts.',
153
197
  ].join(' ');
154
198
 
155
- return `${system}\n\n=== Captured lessons (your data) ===\n${context || '(no relevant lessons found)'}\n\n=== Question ===\n${q}`;
199
+ return `${system}\n\n=== Captured lessons (your data) ===\n${context || '(no relevant lessons found)'}\n${metricsBlock}\n=== Question ===\n${q}`;
156
200
  }
157
201
 
158
202
  // Parse the Gemini generateContent response into plain text. Pure (testable).
@@ -263,7 +307,8 @@ async function answerDataQuestion(question, opts = {}) {
263
307
  }
264
308
 
265
309
  const model = resolveModel(opts.model);
266
- const prompt = buildChatPrompt(q, lessons);
310
+ const metrics = retrieveMetricsContext();
311
+ const prompt = buildChatPrompt(q, lessons, metrics);
267
312
  const fetchImpl = opts.fetch || globalThis.fetch;
268
313
  const isPerplexity = apiKey && (apiKey.startsWith('pplx-') || apiKey.includes('perplexity'));
269
314
 
@@ -272,7 +317,8 @@ async function answerDataQuestion(question, opts = {}) {
272
317
  if (isPerplexity) return await callPerplexityEndpoint({ apiKey, prompt, fetchImpl, sources });
273
318
  return await callGeminiEndpoint({ apiKey, model, prompt, fetchImpl, sources });
274
319
  } catch (err) {
275
- return { ok: false, error: 'network', message: err?.message || String(err), sources };
320
+ const safeMessage = (err && err.message) ? String(err.message).split('\n')[0].slice(0, 100) : 'An unexpected error occurred.';
321
+ return { ok: false, error: 'network', message: safeMessage, sources };
276
322
  }
277
323
  }
278
324
 
@@ -49,6 +49,10 @@ const {
49
49
  readDecisionLog,
50
50
  } = require('./decision-journal');
51
51
  const { analyzeFeedback } = require('./feedback-loop');
52
+ const {
53
+ collectAggregateLogEntries,
54
+ shouldAggregateFeedback,
55
+ } = require('./feedback-aggregate');
52
56
 
53
57
  const PROJECT_ROOT = path.join(__dirname, '..');
54
58
  const DEFAULT_GATES_PATH = path.join(PROJECT_ROOT, 'config', 'gates', 'default.json');
@@ -449,13 +453,9 @@ function computeGateAuditSeries(feedbackDir, options = {}) {
449
453
  const dayKey = toLocalDayKey(entry.timestamp);
450
454
  if (!dayKey) continue;
451
455
  if (!countsByDay.has(dayKey)) {
452
- countsByDay.set(dayKey, { allow: 0, deny: 0, warn: 0, byGate: {} });
453
- }
454
- const bucket = countsByDay.get(dayKey);
455
- bucket[entry.decision] += 1;
456
- if ((entry.decision === 'deny' || entry.decision === 'warn') && entry.gateId) {
457
- bucket.byGate[entry.gateId] = (bucket.byGate[entry.gateId] || 0) + 1;
456
+ countsByDay.set(dayKey, { allow: 0, deny: 0, warn: 0 });
458
457
  }
458
+ countsByDay.get(dayKey)[entry.decision] += 1;
459
459
  }
460
460
 
461
461
  const days = [];
@@ -465,7 +465,7 @@ function computeGateAuditSeries(feedbackDir, options = {}) {
465
465
  const day = new Date(today);
466
466
  day.setDate(today.getDate() - offset);
467
467
  const dayKey = toLocalDayKey(day);
468
- const record = countsByDay.get(dayKey) || { allow: 0, deny: 0, warn: 0, byGate: {} };
468
+ const record = countsByDay.get(dayKey) || { allow: 0, deny: 0, warn: 0 };
469
469
  const intercepted = record.deny + record.warn;
470
470
  const total = intercepted + record.allow;
471
471
  const summary = {
@@ -475,7 +475,6 @@ function computeGateAuditSeries(feedbackDir, options = {}) {
475
475
  warn: record.warn,
476
476
  intercepted,
477
477
  total,
478
- byGate: record.byGate || {},
479
478
  };
480
479
  totals.allow += record.allow;
481
480
  totals.deny += record.deny;
@@ -530,14 +529,7 @@ function computePreventionImpact(feedbackDir, gateStats) {
530
529
  // Last auto-promotion
531
530
  const autoGates = readJsonFile(autoGatesPath);
532
531
  let lastPromotion = null;
533
- let promotionsToday = 0;
534
- let promotionIdsToday = [];
535
532
  if (autoGates && Array.isArray(autoGates.promotionLog) && autoGates.promotionLog.length > 0) {
536
- const todayKey = toLocalDayKey(new Date());
537
- promotionIdsToday = autoGates.promotionLog
538
- .filter((p) => p && p.timestamp && toLocalDayKey(p.timestamp) === todayKey)
539
- .map((p) => p.gateId || p.id || 'unknown');
540
- promotionsToday = promotionIdsToday.length;
541
533
  const sorted = autoGates.promotionLog
542
534
  .filter((p) => p.timestamp)
543
535
  .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
@@ -552,8 +544,6 @@ function computePreventionImpact(feedbackDir, gateStats) {
552
544
  estimatedHoursSaved,
553
545
  ruleCount,
554
546
  lastPromotion,
555
- promotionsToday,
556
- promotionIdsToday: promotionIdsToday.slice(0, 5),
557
547
  };
558
548
  }
559
549
 
@@ -1559,6 +1549,10 @@ function resolveTeamWindowHours(analyticsWindow) {
1559
1549
  // ---------------------------------------------------------------------------
1560
1550
 
1561
1551
  function collectAllFeedbackEntries(feedbackDir) {
1552
+ if (shouldAggregateFeedback()) {
1553
+ return collectAggregateLogEntries('feedback-log.jsonl', { feedbackDir }).entries;
1554
+ }
1555
+
1562
1556
  const entries = [];
1563
1557
  const seen = new Set();
1564
1558
 
@@ -2096,6 +2090,7 @@ module.exports = {
2096
2090
  computeObservabilityStats,
2097
2091
  readJSONL,
2098
2092
  readJsonFile,
2093
+ collectAllFeedbackEntries,
2099
2094
  };
2100
2095
 
2101
2096
  if (require.main === module) {
@@ -6,6 +6,7 @@ const path = require('path');
6
6
 
7
7
  const { getFeedbackPaths } = require('./feedback-loop');
8
8
  const { ensureDir } = require('./fs-utils');
9
+ const { redactSecretsDeep } = require('./secret-redaction');
9
10
 
10
11
  const PROJECT_ROOT = path.join(__dirname, '..');
11
12
  const DEFAULT_PROOF_DIR = process.env.THUMBGATE_PROOF_DIR
@@ -47,7 +48,10 @@ function readJSON(filePath) {
47
48
  }
48
49
 
49
50
  function writeJSONL(filePath, rows) {
50
- const content = rows.map((row) => JSON.stringify(row)).join('\n');
51
+ // Redact secrets from every bundle row this is the single choke point for all bundle tables
52
+ // (feedback_events, memory_records, sequences, attributions, proof_reports). A shared/published
53
+ // dataset must never ship a captured credential. See scripts/secret-redaction.js.
54
+ const content = rows.map((row) => JSON.stringify(redactSecretsDeep(row))).join('\n');
51
55
  fs.writeFileSync(filePath, content ? `${content}\n` : '');
52
56
  }
53
57
 
@@ -9,6 +9,7 @@ const fs = require('fs');
9
9
  const path = require('path');
10
10
  const { traceForDpoPair, aggregateTraces } = require('./code-reasoning');
11
11
  const { resolveFeedbackDir } = require('./feedback-paths');
12
+ const { redactSecretsDeep } = require('./secret-redaction');
12
13
 
13
14
  const DEFAULT_LOCAL_MEMORY_LOG = path.join(resolveFeedbackDir(), 'memory-log.jsonl');
14
15
 
@@ -201,14 +202,18 @@ function exportDpoFromMemories(memories) {
201
202
  },
202
203
  }));
203
204
 
205
+ // Redact secrets before the pairs leave this module — they are derived from memory content and
206
+ // are shipped to disk here AND consumed by export-hf-dataset.js. See scripts/secret-redaction.js.
207
+ const redactedPairs = pairsWithTraces.map((pair) => redactSecretsDeep(pair));
208
+
204
209
  return {
205
- pairs: pairsWithTraces,
210
+ pairs: redactedPairs,
206
211
  unpairedErrors: result.unpairedErrors,
207
212
  unpairedLearnings: result.unpairedLearnings,
208
213
  errors,
209
214
  learnings,
210
215
  reasoning,
211
- jsonl: toJSONL(pairsWithTraces),
216
+ jsonl: toJSONL(redactedPairs),
212
217
  };
213
218
  }
214
219