thumbgate 1.27.4 → 1.27.6

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.
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: dashboard
3
+ description: Open the local HTTP dashboard for the current project in your web browser.
4
+ ---
5
+
6
+ # Open Dashboard
7
+
8
+ Open the local HTTP dashboard for the current project in your web browser.
9
+
10
+ ## Instructions
11
+ Execute the following command in the project directory to open the browser dashboard scoped to the current repository:
12
+ ```bash
13
+ thumbgate-dashboard
14
+ ```
15
+
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: thumbgate-dashboard
3
+ description: Open the local HTTP dashboard for the current project in your web browser.
4
+ ---
5
+
6
+ # Open Scoped ThumbGate Dashboard
7
+
8
+ Open the local HTTP dashboard for the current project in your web browser.
9
+
10
+ ## Instructions
11
+ Execute the following command in the project directory to open the browser dashboard:
12
+ ```bash
13
+ thumbgate-dashboard
14
+ ```
15
+
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "thumbgate",
3
3
  "description": "One 👎 becomes a hard rule the agent cannot bypass. Captures thumbs-down feedback, distills it into PreToolUse Pre-Action Checks, enforced across every future Claude Code session.",
4
- "version": "1.27.4",
4
+ "version": "1.27.6",
5
5
  "author": {
6
6
  "name": "Igor Ganapolsky",
7
7
  "email": "ig5973700@gmail.com",
@@ -28,6 +28,7 @@
28
28
  "memory"
29
29
  ],
30
30
  "skills": "./skills/",
31
+ "commands": "./.claude/commands/",
31
32
  "mcpServers": {
32
33
  "thumbgate": {
33
34
  "command": "npx",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thumbgate",
3
- "version": "1.27.4",
3
+ "version": "1.27.6",
4
4
  "description": "ThumbGate — 👍👎 feedback that teaches your AI agent. Thumbs down a mistake, it never happens again.",
5
5
  "homepage": "https://thumbgate.ai",
6
6
  "transport": "stdio",
@@ -2,13 +2,13 @@
2
2
  "mcpServers": {
3
3
  "thumbgate": {
4
4
  "command": "npx",
5
- "args": ["--yes", "--package", "thumbgate@1.27.4", "thumbgate", "serve"]
5
+ "args": ["--yes", "--package", "thumbgate@1.27.6", "thumbgate", "serve"]
6
6
  }
7
7
  },
8
8
  "hooks": {
9
9
  "preToolUse": {
10
10
  "command": "npx",
11
- "args": ["--yes", "--package", "thumbgate@1.27.4", "thumbgate", "gate-check"]
11
+ "args": ["--yes", "--package", "thumbgate@1.27.6", "thumbgate", "gate-check"]
12
12
  }
13
13
  }
14
14
  }
@@ -231,7 +231,7 @@ const {
231
231
  finalizeSession: finalizeFeedbackSession,
232
232
  } = require('../../scripts/feedback-session');
233
233
 
234
- const SERVER_INFO = { name: 'thumbgate-mcp', version: '1.27.4' };
234
+ const SERVER_INFO = { name: 'thumbgate-mcp', version: '1.27.6' };
235
235
  const COMMERCE_CATEGORIES = [
236
236
  'product_recommendation',
237
237
  'brand_compliance',
@@ -7,7 +7,7 @@
7
7
  "npx",
8
8
  "--yes",
9
9
  "--package",
10
- "thumbgate@1.27.4",
10
+ "thumbgate@1.27.6",
11
11
  "thumbgate",
12
12
  "serve"
13
13
  ],
package/bin/cli.js CHANGED
@@ -565,6 +565,20 @@ function setupCodex() {
565
565
  }
566
566
 
567
567
  function setupGemini() {
568
+ // Try to import custom commands as a Gemini plugin if the CLI is installed
569
+ const { execSync } = require('child_process');
570
+ let pluginImported = false;
571
+ for (const binName of ['agy', 'gemini']) {
572
+ try {
573
+ execSync(`${binName} plugin import "${PKG_ROOT}" --force`, { stdio: 'ignore' });
574
+ console.log(` Gemini: imported thumbgate plugin via ${binName}`);
575
+ pluginImported = true;
576
+ break;
577
+ } catch (err) {
578
+ // ignore errors if command doesn't exist or fails
579
+ }
580
+ }
581
+
568
582
  const settingsPath = path.join(HOME, '.gemini', 'settings.json');
569
583
  if (fs.existsSync(settingsPath)) {
570
584
  const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
@@ -585,13 +599,14 @@ function setupGemini() {
585
599
  }
586
600
  }
587
601
 
588
- if (!changed) return false;
602
+ if (!changed) return pluginImported;
589
603
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
590
604
  console.log(' Gemini: updated ~/.gemini/settings.json');
591
605
  return true;
592
606
  }
593
607
  // Fallback: project-level .gemini/settings.json
594
- return mergeMcpJson(path.join(CWD, '.gemini', 'settings.json'), 'Gemini', 'project');
608
+ const mcpChanged = mergeMcpJson(path.join(CWD, '.gemini', 'settings.json'), 'Gemini', 'project');
609
+ return pluginImported || mcpChanged;
595
610
  }
596
611
 
597
612
  function setupAmp() {
@@ -910,6 +925,32 @@ function init(cliArgs = parseArgs(process.argv.slice(3))) {
910
925
  // Always create .mcp.json (project-level MCP config used by Claude, Codex, Cursor)
911
926
  mergeMcpJson(path.join(CWD, '.mcp.json'), 'MCP');
912
927
 
928
+ // Copy custom slash commands (.claude/commands/*.md) to the project's config directories
929
+ const pkgCommandsDir = path.join(PKG_ROOT, '.claude', 'commands');
930
+ if (fs.existsSync(pkgCommandsDir)) {
931
+ const targets = [
932
+ path.join(CWD, '.claude', 'commands'),
933
+ path.join(CWD, '.gemini', 'commands'),
934
+ path.join(CWD, '.antigravitycli', 'commands')
935
+ ];
936
+ for (const projectCommandsDir of targets) {
937
+ if (!fs.existsSync(projectCommandsDir)) {
938
+ fs.mkdirSync(projectCommandsDir, { recursive: true });
939
+ }
940
+ try {
941
+ const files = fs.readdirSync(pkgCommandsDir);
942
+ for (const file of files) {
943
+ if (file.endsWith('.md')) {
944
+ fs.copyFileSync(path.join(pkgCommandsDir, file), path.join(projectCommandsDir, file));
945
+ }
946
+ }
947
+ } catch (err) {
948
+ console.log(` Failed to copy custom commands to ${path.relative(CWD, projectCommandsDir)}: ${err.message}`);
949
+ }
950
+ }
951
+ console.log('Scaffolded custom slash commands directories (.claude, .gemini, .antigravitycli)');
952
+ }
953
+
913
954
  // Auto-detect and configure platform-specific locations
914
955
  console.log('');
915
956
  console.log('Detecting platforms...');
@@ -3323,8 +3364,13 @@ switch (COMMAND) {
3323
3364
  break;
3324
3365
  }
3325
3366
  case 'brain': {
3326
- const brainArgs = parseArgs(process.argv.slice(3));
3327
- process.exitCode = cmdBrain(brainArgs);
3367
+ const sub = process.argv.slice(3).find((arg) => !arg.startsWith('--'));
3368
+ if (sub && ['init', 'context', 'remember', 'check', 'cleanup', 'status'].includes(sub)) {
3369
+ brain();
3370
+ } else {
3371
+ const brainArgs = parseArgs(process.argv.slice(3));
3372
+ process.exitCode = cmdBrain(brainArgs);
3373
+ }
3328
3374
  break;
3329
3375
  }
3330
3376
  case 'billing:setup':
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: dashboard
3
+ description: Open the local HTTP dashboard for the current project in your web browser.
4
+ ---
5
+
6
+ # Open Dashboard
7
+
8
+ Open the local HTTP dashboard for the current project in your web browser.
9
+
10
+ ## Instructions
11
+ Execute the following command in the project directory to open the browser dashboard scoped to the current repository:
12
+ ```bash
13
+ thumbgate-dashboard
14
+ ```
15
+
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: thumbgate-dashboard
3
+ description: Open the local HTTP dashboard for the current project in your web browser.
4
+ ---
5
+
6
+ # Open Scoped ThumbGate Dashboard
7
+
8
+ Open the local HTTP dashboard for the current project in your web browser.
9
+
10
+ ## Instructions
11
+ Execute the following command in the project directory to open the browser dashboard:
12
+ ```bash
13
+ thumbgate-dashboard
14
+ ```
15
+
@@ -24,6 +24,12 @@
24
24
  "requiredActions": ["on_device_verified"],
25
25
  "message": "You claimed on-device verification without evidence. Capture the proof first, then call track_action('on_device_verified').",
26
26
  "createdAt": 1774972800000
27
+ },
28
+ {
29
+ "pattern": "(github|repo|repository).*(about|metadata|description|topics?).*(updated|verified|fixed|match(?:es|ed)?)|(about|metadata|description|topics?).*(updated|verified|fixed|match(?:es|ed)?).*(github|repo|repository)",
30
+ "requiredActions": ["github_metadata_verified"],
31
+ "message": "You claimed GitHub repository metadata was updated or verified without source-of-truth evidence. Read it back with gh api/gh repo view and rendered HTML, then call track_action('github_metadata_verified').",
32
+ "createdAt": 1780677400000
27
33
  }
28
34
  ]
29
35
  }
@@ -51,6 +51,11 @@
51
51
  "route": "/sitemap.xml",
52
52
  "sentinel": "<urlset",
53
53
  "description": "XML sitemap for search-engine discoverability"
54
+ },
55
+ {
56
+ "route": "/about",
57
+ "sentinel": "Igor Ganapolsky",
58
+ "description": "About page for GEO discoverability"
54
59
  }
55
60
  ]
56
61
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thumbgate",
3
- "version": "1.27.4",
3
+ "version": "1.27.6",
4
4
  "description": "ThumbGate self-improving agent governance: thumbs-up/down turns every mistake into a prevention rule and blocks repeat patterns. 36 pre-action checks, budget enforcement, and self-protection for Claude Code, Cursor, Codex, Gemini CLI, and Amp.",
5
5
  "homepage": "https://thumbgate.ai",
6
6
  "repository": {
@@ -222,6 +222,8 @@
222
222
  "scripts/workspace-evolver.js",
223
223
  "scripts/xmemory-lite.js",
224
224
  ".claude-plugin/plugin.json",
225
+ ".claude/commands/",
226
+ "commands/",
225
227
  ".well-known/",
226
228
  "LICENSE",
227
229
  "README.md",
@@ -359,7 +361,7 @@
359
361
  "social:prospect:bluesky:dry": "node scripts/social-bluesky-prospecting.js --dry-run",
360
362
  "social:reply-publish:bluesky:dry": "node scripts/social-reply-monitor-bluesky.js --publish-approved --dry-run",
361
363
  "test:python": "python3 -m pytest tests/*.py",
362
- "test": "npm run test:python && npm run test:schema && npm run test:loop && npm run test:dpo && npm run test:kto && npm run test:api && npm run test:proof && npm run test:e2e && npm run test:rlaif && npm run test:attribution && npm run test:quality && npm run test:intelligence && npm run test:training-export && npm run test:deployment && npm run test:operational-integrity && npm run test:workflow && npm run test:billing && npm run test:cli && npm run test:watcher && npm run test:autoresearch && npm run test:ops && npm run test:session-analyzer && npm run test:tessl && npm run test:gates && npm run test:evoskill && npm run test:gates-hardening && npm run test:workers && npm run test:social-analytics && npm run test:memalign && npm run test:xmemory-lite && npm run test:filesystem-search && npm run test:zernio && npm run test:platform-limits && npm run test:post-video && npm run test:post-everywhere-instagram && npm run test:post-everywhere-channels && npm run test:post-everywhere-zernio-default && npm run test:zernio-canonical-pollers && npm run test:zernio-status && npm run test:obsidian-export && npm run test:lesson-db && npm run test:lesson-rotation && npm run test:memory-dedup && npm run test:feedback-quality && npm run test:sync-version && npm run test:check-congruence && npm run test:tool-registry && npm run test:repeat-metric && npm run test:noop-detect && npm run test:action-receipts && npm run test:feedback-to-rules && npm run test:memory-firewall && npm run test:memory-scope-readiness && npm run test:belief-update && npm run test:hosted-config && npm run test:operational-summary && npm run test:operational-dashboard && npm run test:operator-artifacts && npm run test:operator-key-auth && npm run test:cloudflare-sandbox && npm run test:mcp-config && npm run test:mcp-tool-annotations && npm run test:mcp-oauth && npm run test:mcp-oauth-flow && npm run test:plan-gate && npm run test:pulse && npm run test:semantic-layer && npm run test:data-pipeline && npm run test:optimize-context && npm run test:principle-extractor && npm run test:analytics-window && npm run test:funnel-analytics && npm run test:experiment-tracker && npm run test:build-metadata && npm run test:context-engine && npm run test:hf-papers && npm run test:marketing-experiment && npm run test:seo-gsd && npm run test:verify-run && npm run test:export-dpo-pairs && npm run test:export-hf-dataset && npm run test:license && npm run test:bot-detector && npm run test:audit-pr-bot-contamination && npm run test:stripe-bootstrap-saas-catalog && npm run test:postinstall && npm run test:funnel-invariants && npm run test:cli-telemetry && npm run test:pro-parity && npm run test:model-tier-router && npm run test:computer-use-firewall && npm run test:skill-exporter && npm run test:statusline && npm run test:evolution && npm run test:org-dashboard && npm run test:multi-hop-recall && npm run test:synthetic-dpo && npm run test:thumbgate-skill && npm run test:learn-hub && npm run test:feedback-fallback && npm run test:metaclaw && npm run test:server-lock && npm run test:control-tower && npm run test:pii-scanner && npm run test:data-governance && npm run test:lesson-inference && npm run test:semantic-dedup && npm run test:fs-utils && npm run test:cli-schema && npm run test:explore && npm run test:lesson-reranker && npm run test:lesson-retrieval && npm run test:lesson-semantic-retrieval && npm run test:cross-encoder && npm run test:reflector-agent && npm run test:feedback-session && npm run test:feedback-history-distiller && npm run test:hallucination-detector && npm run test:history-distiller && npm run test:predictive-insights && npm run test:predictive-credible-range && npm run test:prove-predictive-insights && npm run test:statusbar-cli && npm run test:generate-instagram-card && npm run test:instagram-thumbgate-post && npm run test:publish-instagram-thumbgate && npm run test:lesson-synthesis && npm run test:lesson-canonical && npm run test:background-governance && npm run test:memory-migration && npm run test:prompt-dlp && npm run test:ephemeral-store && npm run test:agent-security && npm run test:skill-progressive && npm run test:per-step-scoring && npm run test:weekly-auto-post && npm run test:social-post-hourly && npm run test:social-quality-gate && npm run test:a2ui-engine && npm run test:gate-satisfy && npm run test:money-watcher && npm run test:budget && npm run test:quick-start && npm run test:utm && npm run test:product-feedback && npm run test:feedback-root-consolidator && npm run test:engagement-audit && npm run test:install-growth-automation && npm run test:publish-thumbgate-launch && npm run test:community-course-platform-launch-kit && npm run test:reconcile-thumbgate-campaign && npm run test:reddit-publisher && npm run test:schedule-thumbgate-campaign && npm run test:social-reply-monitor && npm run test:social-dedupe-cleanup && npm run test:sync-launch-assets && npm run test:ai-search-visibility && npm run test:perplexity && npm run test:security-scanner && npm run test:llm-client && npm run test:managed-lesson-agent && npm run test:self-distill && npm run test:meta-agent && npm run test:harness-selector && npm run test:thumbgate-bench && npm run test:seo-guides && npm run test:enforcement-loop && npm run test:cli-agent-experience && npm run test:bot-detection && npm run test:checkout-archived-product-guard && npm run test:postgres-guard && npm run test:checkout-bot-guard && npm run test:checkout-pro-confirmation-gate && npm run test:session-health && npm run test:session-episodes && npm run test:spec-gate && npm run test:decision-trace && npm run test:dashboard-insights && npm run test:telemetry-tracked-link-slug && npm run test:prompt-eval && npm run test:demo-voiceover && npm run test:gate-coherence && npm run test:gate-eval && npm run test:ai-component-inventory && npm run test:high-roi && npm run test:public-static-assets && npm run test:token-savings && npm run test:numbers-page && npm run test:workflow-gate-checkpoint && npm run test:lesson-export-import && npm run test:landing-page-claims && npm run test:competitive-positioning-marketing && npm run test:medium-weekly && npm run test:dashboard-deeplink-e2e && npm run test:public-package-parity && npm run test:token-savings-dashboard && npm run test:cursor-wiring && npm run test:pretooluse-injection && npm run test:recent-corrective-context && npm run test:durability-step && npm run test:mailer && npm run test:brand-assets && npm run test:enforcement-teeth && npm run test:bayes-optimal-gate && npm run test:swarm-coordinator && npm run test:session-report && npm run test:agent-reasoning-traces && npm run test:judge-reward && npm run test:llm-behavior-monitor && npm run test:prompting-os && npm run test:single-use-credential-gate && npm run test:structured-prompt-driven && npm run test:require-evidence-gate && npm run test:rule-validator && npm run test:bluesky-atproto && npm run test:social-reply-monitor-bluesky && npm run test:bluesky-delete-replies && npm run test:architect-kit-memory-bridge && npm run test:sonar-review-hotspots && npm run test:actionable-remediations && npm run test:gemini-embedding-policy && npm run test:agent-design-governance && npm run test:public-core-boundary && npm run test:hook-stop-verify-deploy && npm run test:hook-stop-anti-claim && npm run test:plausible-server-events && npm run test:activation-tracker && npm run test:unified-revenue-rollup && npm run test:conversion-rate-stats && npm run test:external-customer-audit && npm run test:telemetry-export && npm run test:stripe-checkout-diagnostic && npm run test:stripe-business-identity-probe && npm run test:revenue-observability-doctor && npm run test:public-bundle-ratchet && npm run test:stripe-payment-link-update && npm run test:ci-cd-hygiene-audit && npm run test:verify-marketing-pages-deployed && npm run test:install-email-capture && npm run test:install-shim && npm run test:hook-runtime-subcommands && npm run test:implementation-notes && npm run test:daily-block-cap && npm run test:free-to-paid-conversion-units && npm run test:metrics-real-endpoint && npm run test:cli-trial-and-help && npm run test:cost-cli && npm run test:silent-failure-cluster && npm run test:proof:truth && node --test tests/adaptive-reliability.test.js && npm run test:mcp-oauth-reviewer && npm run test:dfcx-gate && npm run test:dfcx-gate-server && npm run test:vertex-scorer && npm run test:dashboard-chat && npm run test:gitar-integration",
364
+ "test": "npm run test:python && npm run test:schema && npm run test:loop && npm run test:dpo && npm run test:kto && npm run test:api && npm run test:proof && npm run test:e2e && npm run test:rlaif && npm run test:attribution && npm run test:quality && npm run test:intelligence && npm run test:training-export && npm run test:deployment && npm run test:operational-integrity && npm run test:workflow && npm run test:billing && npm run test:cli && npm run test:watcher && npm run test:autoresearch && npm run test:ops && npm run test:session-analyzer && npm run test:tessl && npm run test:gates && npm run test:evoskill && npm run test:gates-hardening && npm run test:workers && npm run test:social-analytics && npm run test:memalign && npm run test:xmemory-lite && npm run test:filesystem-search && npm run test:zernio && npm run test:platform-limits && npm run test:post-video && npm run test:post-everywhere-instagram && npm run test:post-everywhere-channels && npm run test:post-everywhere-zernio-default && npm run test:zernio-canonical-pollers && npm run test:zernio-status && npm run test:obsidian-export && npm run test:lesson-db && npm run test:lesson-rotation && npm run test:memory-dedup && npm run test:feedback-quality && npm run test:sync-version && npm run test:check-congruence && npm run test:tool-registry && npm run test:repeat-metric && npm run test:noop-detect && npm run test:action-receipts && npm run test:feedback-to-rules && npm run test:memory-firewall && npm run test:memory-scope-readiness && npm run test:belief-update && npm run test:hosted-config && npm run test:operational-summary && npm run test:operational-dashboard && npm run test:operator-artifacts && npm run test:operator-key-auth && npm run test:cloudflare-sandbox && npm run test:mcp-config && npm run test:mcp-tool-annotations && npm run test:mcp-oauth && npm run test:mcp-oauth-flow && npm run test:plan-gate && npm run test:ai-component-inventory && npm run test:pulse && npm run test:semantic-layer && npm run test:data-pipeline && npm run test:optimize-context && npm run test:principle-extractor && npm run test:analytics-window && npm run test:funnel-analytics && npm run test:experiment-tracker && npm run test:build-metadata && npm run test:context-engine && npm run test:hf-papers && npm run test:marketing-experiment && npm run test:seo-gsd && npm run test:verify-run && npm run test:export-dpo-pairs && npm run test:export-hf-dataset && npm run test:license && npm run test:bot-detector && npm run test:audit-pr-bot-contamination && npm run test:stripe-bootstrap-saas-catalog && npm run test:postinstall && npm run test:funnel-invariants && npm run test:cli-telemetry && npm run test:pro-parity && npm run test:model-tier-router && npm run test:computer-use-firewall && npm run test:skill-exporter && npm run test:statusline && npm run test:evolution && npm run test:org-dashboard && npm run test:multi-hop-recall && npm run test:synthetic-dpo && npm run test:thumbgate-skill && npm run test:learn-hub && npm run test:feedback-fallback && npm run test:metaclaw && npm run test:server-lock && npm run test:control-tower && npm run test:pii-scanner && npm run test:data-governance && npm run test:lesson-inference && npm run test:semantic-dedup && npm run test:fs-utils && npm run test:cli-schema && npm run test:explore && npm run test:lesson-reranker && npm run test:lesson-retrieval && npm run test:lesson-semantic-retrieval && npm run test:cross-encoder && npm run test:reflector-agent && npm run test:feedback-session && npm run test:feedback-history-distiller && npm run test:hallucination-detector && npm run test:history-distiller && npm run test:predictive-insights && npm run test:predictive-credible-range && npm run test:prove-predictive-insights && npm run test:statusbar-cli && npm run test:generate-instagram-card && npm run test:instagram-thumbgate-post && npm run test:publish-instagram-thumbgate && npm run test:lesson-synthesis && npm run test:lesson-canonical && npm run test:background-governance && npm run test:memory-migration && npm run test:prompt-dlp && npm run test:ephemeral-store && npm run test:agent-security && npm run test:skill-progressive && npm run test:per-step-scoring && npm run test:weekly-auto-post && npm run test:social-post-hourly && npm run test:social-quality-gate && npm run test:a2ui-engine && npm run test:gate-satisfy && npm run test:money-watcher && npm run test:budget && npm run test:quick-start && npm run test:utm && npm run test:product-feedback && npm run test:feedback-root-consolidator && npm run test:engagement-audit && npm run test:install-growth-automation && npm run test:publish-thumbgate-launch && npm run test:community-course-platform-launch-kit && npm run test:reconcile-thumbgate-campaign && npm run test:reddit-publisher && npm run test:schedule-thumbgate-campaign && npm run test:social-reply-monitor && npm run test:social-dedupe-cleanup && npm run test:sync-launch-assets && npm run test:ai-search-visibility && npm run test:perplexity && npm run test:security-scanner && npm run test:llm-client && npm run test:managed-lesson-agent && npm run test:self-distill && npm run test:meta-agent && npm run test:harness-selector && npm run test:thumbgate-bench && npm run test:seo-guides && npm run test:enforcement-loop && npm run test:cli-agent-experience && npm run test:bot-detection && npm run test:checkout-archived-product-guard && npm run test:postgres-guard && npm run test:checkout-bot-guard && npm run test:checkout-pro-confirmation-gate && npm run test:session-health && npm run test:session-episodes && npm run test:spec-gate && npm run test:decision-trace && npm run test:dashboard-insights && npm run test:telemetry-tracked-link-slug && npm run test:prompt-eval && npm run test:demo-voiceover && npm run test:gate-coherence && npm run test:gate-eval && npm run test:high-roi && npm run test:public-static-assets && npm run test:token-savings && npm run test:numbers-page && npm run test:workflow-gate-checkpoint && npm run test:lesson-export-import && npm run test:landing-page-claims && npm run test:competitive-positioning-marketing && npm run test:medium-weekly && npm run test:dashboard-deeplink-e2e && npm run test:public-package-parity && npm run test:token-savings-dashboard && npm run test:cursor-wiring && npm run test:pretooluse-injection && npm run test:recent-corrective-context && npm run test:durability-step && npm run test:mailer && npm run test:brand-assets && npm run test:enforcement-teeth && npm run test:bayes-optimal-gate && npm run test:swarm-coordinator && npm run test:session-report && npm run test:agent-reasoning-traces && npm run test:judge-reward && npm run test:llm-behavior-monitor && npm run test:prompting-os && npm run test:single-use-credential-gate && npm run test:structured-prompt-driven && npm run test:require-evidence-gate && npm run test:rule-validator && npm run test:bluesky-atproto && npm run test:social-reply-monitor-bluesky && npm run test:bluesky-delete-replies && npm run test:architect-kit-memory-bridge && npm run test:sonar-review-hotspots && npm run test:actionable-remediations && npm run test:gemini-embedding-policy && npm run test:agent-design-governance && npm run test:public-core-boundary && npm run test:hook-stop-verify-deploy && npm run test:hook-stop-anti-claim && npm run test:plausible-server-events && npm run test:activation-tracker && npm run test:unified-revenue-rollup && npm run test:conversion-rate-stats && npm run test:external-customer-audit && npm run test:telemetry-export && npm run test:stripe-checkout-diagnostic && npm run test:stripe-business-identity-probe && npm run test:revenue-observability-doctor && npm run test:public-bundle-ratchet && npm run test:stripe-payment-link-update && npm run test:ci-cd-hygiene-audit && npm run test:verify-marketing-pages-deployed && npm run test:install-email-capture && npm run test:install-shim && npm run test:hook-runtime-subcommands && npm run test:implementation-notes && npm run test:daily-block-cap && npm run test:free-to-paid-conversion-units && npm run test:metrics-real-endpoint && npm run test:cli-trial-and-help && npm run test:cost-cli && npm run test:silent-failure-cluster && npm run test:proof:truth && node --test tests/adaptive-reliability.test.js && npm run test:mcp-oauth-reviewer && npm run test:dfcx-gate && npm run test:dfcx-gate-server && npm run test:vertex-scorer && npm run test:dashboard-chat && npm run test:gitar-integration",
363
365
  "test:hook-stop-verify-deploy": "node --test tests/hook-stop-verify-deploy.test.js",
364
366
  "test:hook-stop-anti-claim": "node --test tests/hook-stop-anti-claim.test.js",
365
367
  "test:plausible-server-events": "node --test tests/plausible-server-events.test.js tests/plausible-poller.test.js tests/plausible-domain-config.test.js",
@@ -9,6 +9,7 @@
9
9
  <link rel="icon" type="image/png" href="/thumbgate-icon.png">
10
10
  <link rel="apple-touch-icon" href="/assets/brand/thumbgate-mark.svg">
11
11
  <meta name="robots" content="noindex">
12
+ <meta name="referrer" content="same-origin">
12
13
  <!-- Privacy-friendly analytics by Plausible -->
13
14
  <script defer data-domain="thumbgate-production.up.railway.app" src="https://plausible.io/js/script.js"></script>
14
15
  <script type="application/ld+json">
@@ -872,7 +873,10 @@ async function connect(options) {
872
873
  tierName = data.tier;
873
874
  }
874
875
 
875
- if (data.geminiKeyStatus === 'validated' || data.geminiValidatedAt) {
876
+ if (data.localLlmConfigured) {
877
+ var modelLabel = data.localLlmModel ? ' (' + data.localLlmModel + ')' : '';
878
+ document.getElementById('chatHint').innerHTML = '<span style="color:var(--green)">✓ Local LLM' + modelLabel + ' ready. Chat answers are generated locally — no cloud calls.</span>';
879
+ } else if (data.geminiKeyStatus === 'validated' || data.geminiValidatedAt) {
876
880
  var when = data.geminiValidatedAt ? ' (validated ' + new Date(data.geminiValidatedAt).toLocaleTimeString() + ')' : '';
877
881
  document.getElementById('chatHint').innerHTML = '<span style="color:var(--green)">✓ Gemini API key validated' + when + '. You can now chat with your data.</span>';
878
882
  } else if (data.perplexityConfigured) {
package/public/index.html CHANGED
@@ -20,7 +20,7 @@ __GOOGLE_SITE_VERIFICATION_META__
20
20
  <meta property="og:image" content="https://thumbgate.ai/og.png">
21
21
  <meta name="twitter:card" content="summary_large_image">
22
22
  <meta name="twitter:image" content="https://thumbgate.ai/og.png">
23
- <meta name="thumbgate-version" content="1.27.4">
23
+ <meta name="thumbgate-version" content="1.27.6">
24
24
  <meta name="keywords" content="ThumbGate, thumbgate, AI agent orchestration, AI experience orchestration, agentic development cycle, AC/DC framework, Guide Generate Verify Solve, agent enforcement layer, save LLM tokens, reduce Claude API cost, reduce OpenAI cost, AI agent token savings, prevent LLM retries, prevent hallucination retries, stop AI token waste, pre-action checks, agent governance, Claude Code, Cursor, Codex, Gemini, Amp, Cline, OpenCode, workflow hardening, context engineering, AI authenticity, brand authenticity AI">
25
25
  <link rel="canonical" href="__APP_ORIGIN__/">
26
26
  <link rel="alternate" type="text/markdown" title="ThumbGate LLM context" href="__APP_ORIGIN__/llm-context.md">
@@ -1676,7 +1676,7 @@ __GA_BOOTSTRAP__
1676
1676
  <a href="https://www.linkedin.com/in/igorganapolsky" target="_blank" rel="noopener">LinkedIn</a>
1677
1677
  <a href="/blog">Blog</a>
1678
1678
  </div>
1679
- <span class="footer-copy">© 2026 ThumbGate · MIT License · npm v1.27.4</span>
1679
+ <span class="footer-copy">© 2026 ThumbGate · MIT License · npm v1.27.6</span>
1680
1680
  </div>
1681
1681
  </footer>
1682
1682
 
package/public/learn.html CHANGED
@@ -112,6 +112,12 @@
112
112
  "url": "https://thumbgate.ai/learn/from-prototype-to-production",
113
113
  "name": "From git init to v1.17.0 in 70 days: an honest ThumbGate build log"
114
114
  },
115
+ {
116
+ "@type": "ListItem",
117
+ "position": 13,
118
+ "url": "https://thumbgate.ai/learn/pretix-stripe-connect-marketplaces",
119
+ "name": "Building a Pretix + Stripe Connect Plugin for Live-Music Venues"
120
+ },
115
121
  {
116
122
  "@type": "ListItem",
117
123
  "position": 7,
@@ -404,6 +410,14 @@
404
410
  <span class="article-tag">Indie SaaS</span>
405
411
  <span class="article-tag">Shipping in Public</span>
406
412
  </a>
413
+
414
+ <a href="/learn/pretix-stripe-connect-marketplaces" class="article-card">
415
+ <h3>Building a Pretix + Stripe Connect Plugin for Live-Music Venues</h3>
416
+ <p>How to design a multi-venue Stripe Connect ticketing plugin on Pretix, keeping venues as Merchant of Record without messy platform-fee tax reporting.</p>
417
+ <span class="article-tag">Stripe Connect</span>
418
+ <span class="article-tag">Pretix</span>
419
+ <span class="article-tag">Case Study</span>
420
+ </a>
407
421
  </div>
408
422
 
409
423
  <h2>Popular buyer questions</h2>
@@ -25,7 +25,7 @@
25
25
  "alternateName": "thumbgate",
26
26
  "applicationCategory": "DeveloperApplication",
27
27
  "operatingSystem": "Cross-platform, Node.js >=18.18.0",
28
- "softwareVersion": "1.27.4",
28
+ "softwareVersion": "1.27.6",
29
29
  "url": "https://thumbgate.ai/numbers",
30
30
  "dateModified": "2026-05-07",
31
31
  "creator": {
@@ -202,7 +202,7 @@
202
202
  <main class="container">
203
203
  <h1>The Numbers</h1>
204
204
  <p class="subtitle">Generated first-party operational snapshot from the ThumbGate runtime. This is not customer traction, install volume, revenue, or proof that a configured gate has fired.</p>
205
- <div class="freshness">Updated: 2026-05-07 · Version 1.27.4</div>
205
+ <div class="freshness">Updated: 2026-05-07 · Version 1.27.6</div>
206
206
  <div class="truth-note"><strong>Read this first:</strong> configured checks are inventory. Recorded blocks and warnings are usage evidence. This snapshot currently reports 0 recorded hard-block event(s) and 0 recorded warning event(s).</div>
207
207
 
208
208
  <h2>Gate enforcement</h2>
@@ -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) {
@@ -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',
@@ -617,16 +636,7 @@ const CLI_COMMANDS = [
617
636
  { name: 'info', type: 'boolean', description: 'Show Pro feature list' },
618
637
  ],
619
638
  },
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
- },
639
+
630
640
  {
631
641
  name: 'workflow',
632
642
  aliases: ['swarm'],
@@ -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
 
@@ -449,13 +449,9 @@ function computeGateAuditSeries(feedbackDir, options = {}) {
449
449
  const dayKey = toLocalDayKey(entry.timestamp);
450
450
  if (!dayKey) continue;
451
451
  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;
452
+ countsByDay.set(dayKey, { allow: 0, deny: 0, warn: 0 });
458
453
  }
454
+ countsByDay.get(dayKey)[entry.decision] += 1;
459
455
  }
460
456
 
461
457
  const days = [];
@@ -465,7 +461,7 @@ function computeGateAuditSeries(feedbackDir, options = {}) {
465
461
  const day = new Date(today);
466
462
  day.setDate(today.getDate() - offset);
467
463
  const dayKey = toLocalDayKey(day);
468
- const record = countsByDay.get(dayKey) || { allow: 0, deny: 0, warn: 0, byGate: {} };
464
+ const record = countsByDay.get(dayKey) || { allow: 0, deny: 0, warn: 0 };
469
465
  const intercepted = record.deny + record.warn;
470
466
  const total = intercepted + record.allow;
471
467
  const summary = {
@@ -475,7 +471,6 @@ function computeGateAuditSeries(feedbackDir, options = {}) {
475
471
  warn: record.warn,
476
472
  intercepted,
477
473
  total,
478
- byGate: record.byGate || {},
479
474
  };
480
475
  totals.allow += record.allow;
481
476
  totals.deny += record.deny;
@@ -530,14 +525,7 @@ function computePreventionImpact(feedbackDir, gateStats) {
530
525
  // Last auto-promotion
531
526
  const autoGates = readJsonFile(autoGatesPath);
532
527
  let lastPromotion = null;
533
- let promotionsToday = 0;
534
- let promotionIdsToday = [];
535
528
  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
529
  const sorted = autoGates.promotionLog
542
530
  .filter((p) => p.timestamp)
543
531
  .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
@@ -552,8 +540,6 @@ function computePreventionImpact(feedbackDir, gateStats) {
552
540
  estimatedHoursSaved,
553
541
  ruleCount,
554
542
  lastPromotion,
555
- promotionsToday,
556
- promotionIdsToday: promotionIdsToday.slice(0, 5),
557
543
  };
558
544
  }
559
545
 
@@ -2096,6 +2082,7 @@ module.exports = {
2096
2082
  computeObservabilityStats,
2097
2083
  readJSONL,
2098
2084
  readJsonFile,
2085
+ collectAllFeedbackEntries,
2099
2086
  };
2100
2087
 
2101
2088
  if (require.main === module) {
@@ -56,6 +56,90 @@ const {
56
56
 
57
57
  const AUDIT_TRAIL_TAG = 'audit-trail';
58
58
 
59
+ /**
60
+ * Anonymous fire-and-forget CLI feedback telemetry.
61
+ *
62
+ * Pings the hosted /v1/telemetry/ping endpoint exactly once per successful
63
+ * local feedback capture so the dashboard can measure CLI-side lesson volume.
64
+ *
65
+ * Hard contract (do NOT widen without explicit approval):
66
+ * - ONE event type: `feedback_captured`
67
+ * - Payload: { installId, signal: 'up'|'down', tier, ts } only.
68
+ * No context strings, tags, file paths, or content of any kind.
69
+ * - Opt-out: THUMBGATE_DISABLE_TELEMETRY=1 (or 'true') short-circuits
70
+ * immediately. Legacy THUMBGATE_NO_TELEMETRY=1 / DO_NOT_TRACK=1 are
71
+ * also honored for parity with cli-telemetry.js.
72
+ * - Fire-and-forget: NEVER await this call. Errors are swallowed.
73
+ * - 2-second timeout via AbortSignal.timeout.
74
+ */
75
+ function emitAnonymousFeedbackPing(signal) {
76
+ try {
77
+ const env = process.env || {};
78
+ if (
79
+ env.THUMBGATE_DISABLE_TELEMETRY === '1' ||
80
+ env.THUMBGATE_DISABLE_TELEMETRY === 'true' ||
81
+ env.THUMBGATE_NO_TELEMETRY === '1' ||
82
+ env.DO_NOT_TRACK === '1'
83
+ ) {
84
+ return;
85
+ }
86
+
87
+ const normalizedSignal = signal === 'positive' ? 'up' : signal === 'negative' ? 'down' : null;
88
+ if (!normalizedSignal) return;
89
+
90
+ // Reuse the canonical installId from cli-telemetry.js (persisted at
91
+ // ~/.thumbgate/install-id). Falls back to a fresh UUID if that module
92
+ // is unavailable — better to ship an event we can dedup on the server
93
+ // than to drop the ping entirely.
94
+ let installId = null;
95
+ try {
96
+ const { getInstallId } = require('./cli-telemetry');
97
+ installId = getInstallId();
98
+ } catch (_) { /* fall through */ }
99
+ if (!installId) {
100
+ try {
101
+ installId = require('crypto').randomUUID();
102
+ } catch (_) {
103
+ return; // no crypto, no install id → drop silently
104
+ }
105
+ }
106
+
107
+ let tier = 'free';
108
+ try {
109
+ const { getStatuslineMeta } = require('./statusline-meta');
110
+ const meta = getStatuslineMeta({ env });
111
+ const rawTier = String(meta && meta.tier ? meta.tier : 'free').toLowerCase();
112
+ if (rawTier === 'pro' || rawTier === 'enterprise' || rawTier === 'free') {
113
+ tier = rawTier;
114
+ }
115
+ } catch (_) { /* default to 'free' */ }
116
+
117
+ const base = env.THUMBGATE_PUBLIC_APP_ORIGIN
118
+ || env.THUMBGATE_API_URL
119
+ || 'https://thumbgate-production.up.railway.app';
120
+
121
+ const body = JSON.stringify({
122
+ eventType: 'feedback_captured',
123
+ clientType: 'cli',
124
+ installId,
125
+ signal: normalizedSignal,
126
+ tier,
127
+ ts: new Date().toISOString(),
128
+ });
129
+
130
+ // Fire-and-forget. No await. AbortSignal.timeout enforces the 2s cap.
131
+ if (typeof fetch !== 'function' || typeof AbortSignal === 'undefined' || typeof AbortSignal.timeout !== 'function') {
132
+ return;
133
+ }
134
+ fetch(`${base.replace(/\/+$/, '')}/v1/telemetry/ping`, {
135
+ method: 'POST',
136
+ headers: { 'Content-Type': 'application/json' },
137
+ body,
138
+ signal: AbortSignal.timeout(2000),
139
+ }).catch(() => { /* fire-and-forget */ });
140
+ } catch (_) { /* telemetry must never disrupt CLI */ }
141
+ }
142
+
59
143
  function isAuditTrailEntry(entry = {}) {
60
144
  return Array.isArray(entry.tags) && entry.tags.includes(AUDIT_TRAIL_TAG);
61
145
  }
@@ -1113,6 +1197,7 @@ function captureFeedback(params) {
1113
1197
  summary.lastUpdated = now;
1114
1198
  saveSummary(summary);
1115
1199
  appendJSONL(FEEDBACK_LOG_PATH, feedbackEvent);
1200
+ emitAnonymousFeedbackPing(signal);
1116
1201
  try { appendRejectionLedger(feedbackEvent, action.reason); } catch { /* non-critical */ }
1117
1202
  try {
1118
1203
  appendSequence(historyEntries, feedbackEvent, getFeedbackPaths(), { accepted: false });
@@ -1154,6 +1239,7 @@ function captureFeedback(params) {
1154
1239
  ...feedbackEvent,
1155
1240
  validationIssues: prepared.issues,
1156
1241
  });
1242
+ emitAnonymousFeedbackPing(signal);
1157
1243
  try { appendRejectionLedger(feedbackEvent, `Schema validation failed: ${prepared.issues.join('; ')}`); } catch { /* non-critical */ }
1158
1244
  try {
1159
1245
  appendSequence(historyEntries, feedbackEvent, getFeedbackPaths(), { accepted: false });
@@ -1228,6 +1314,7 @@ function captureFeedback(params) {
1228
1314
  }
1229
1315
 
1230
1316
  appendJSONL(FEEDBACK_LOG_PATH, feedbackEvent);
1317
+ emitAnonymousFeedbackPing(signal);
1231
1318
 
1232
1319
  // Synthesis: merge similar lessons instead of creating duplicates
1233
1320
  let synthesisResult = null;
@@ -1463,7 +1463,42 @@ function checkWhenClause(when, constraints) {
1463
1463
  }
1464
1464
 
1465
1465
  function matchGate(gate, toolName, toolInput = {}) {
1466
- const matchText = toolInput.command || toolInput.file_path || toolInput.path || '';
1466
+ let matchText = toolInput.command || toolInput.file_path || toolInput.path || '';
1467
+
1468
+ // Claw/hybrid support: enrich matchText with claw metadata (for EnterpriseClaw/OpenShell/Perplexity hybrid agents)
1469
+ const clawCtx = toolInput.clawContext || toolInput._claw || (toolInput.agentId ? {
1470
+ actionType: toolInput.actionType || 'unknown',
1471
+ agentId: toolInput.agentId || 'unknown',
1472
+ hybridRoute: toolInput.hybridRoute || 'unknown',
1473
+ screenInteraction: !!toolInput.screenInteraction,
1474
+ fileAccess: !!toolInput.fileAccess,
1475
+ } : null);
1476
+
1477
+ if (clawCtx) {
1478
+ const actionType = clawCtx.actionType || clawCtx.claw_action_type || 'unknown';
1479
+ const parts = [
1480
+ matchText,
1481
+ `claw_style: true`,
1482
+ `agent_identity: ${clawCtx.agentId || 'unknown'}`,
1483
+ `claw_action_type: ${actionType}`,
1484
+ `hybrid_route: ${clawCtx.hybridRoute || 'unknown'}`,
1485
+ ];
1486
+
1487
+ if (clawCtx.screenInteraction || actionType.includes('screen')) {
1488
+ parts.push('screen_interaction');
1489
+ parts.push('interact screen');
1490
+ }
1491
+ if (clawCtx.fileAccess || actionType.includes('file') || actionType.includes('fs')) {
1492
+ parts.push('file_system_access');
1493
+ parts.push('local device file system access');
1494
+ }
1495
+ if (actionType === 'dynamic-tool-creation' || actionType.includes('create-tool') || actionType.includes('define-tool')) {
1496
+ parts.push('create tool');
1497
+ }
1498
+
1499
+ matchText = parts.filter(Boolean).join(' | ');
1500
+ }
1501
+
1467
1502
  const affected = extractAffectedFiles(toolName, toolInput);
1468
1503
  const affectedFiles = affected.files;
1469
1504
  const repoRoot = affected.repoRoot;
@@ -730,6 +730,7 @@ function evaluatePretool(toolName, toolInput, opts) {
730
730
 
731
731
  // Slow path: build live state (also used when compiled guards are stale)
732
732
  const state = buildHybridState({
733
+ feedbackDir: o.feedbackDir,
733
734
  feedbackLogPath: o.feedbackLogPath,
734
735
  attributedFeedbackPath: o.attributedFeedbackPath,
735
736
  });
@@ -49,6 +49,17 @@ function resolvePlausibleDataDomain({ host = '', env = process.env } = {}) {
49
49
  return normalizedHost;
50
50
  }
51
51
 
52
+ // Prevent local/loopback traffic from mapping to the production Plausible site domain
53
+ const isLocal = normalizedHost === 'localhost' ||
54
+ normalizedHost === '127.0.0.1' ||
55
+ normalizedHost === '::1' ||
56
+ normalizedHost.endsWith('.local') ||
57
+ normalizedHost.startsWith('192.168.') ||
58
+ normalizedHost.startsWith('10.');
59
+ if (isLocal) {
60
+ return 'localhost';
61
+ }
62
+
52
63
  return FALLBACK_REGISTERED_PLAUSIBLE_DOMAIN;
53
64
  }
54
65
 
@@ -34,10 +34,10 @@ const REQUEST_TIMEOUT_MS = 2_000;
34
34
  function isPlausibleDisabled() {
35
35
  if (process.env.THUMBGATE_PLAUSIBLE_DISABLE === '1') return true;
36
36
  if (process.env.DO_NOT_TRACK === '1') return true;
37
- // NODE_ENV-based detection was tried and dropped: `node --test` does not
38
- // set NODE_ENV automatically, so the check produced surprising behavior
39
- // in test vs. production. Tests must opt out explicitly via the dedicated
40
- // THUMBGATE_PLAUSIBLE_DISABLE flag.
37
+ // Automatically disable Plausible events in local development unless explicitly overridden in tests
38
+ if (process.env.THUMBGATE_PLAUSIBLE_DISABLE !== '0' && process.env.NODE_ENV !== 'production') {
39
+ return true;
40
+ }
41
41
  return false;
42
42
  }
43
43
 
package/src/api/server.js CHANGED
@@ -1550,14 +1550,93 @@ function normalizeEnterpriseChatPrompt(value) {
1550
1550
 
1551
1551
  function classifyEnterpriseChatTopic(prompt) {
1552
1552
  const lower = String(prompt || '').toLowerCase();
1553
- if (/gate|block|deny|prevent|guard/.test(lower)) return 'gates';
1554
- if (/lesson|memory|feedback|thumb|mistake|negative|positive/.test(lower)) return 'feedback';
1553
+ // Feedback-specific words run FIRST: "what mistakes were blocked today" is a
1554
+ // feedback question, not a gates question — must not be hijacked by /block/.
1555
+ if (/mistake|lesson|memory|feedback|thumb|negative|positive|what (?:went )?wrong|fail|win|success|worked|good/.test(lower)) return 'feedback';
1556
+ if (/gate|block|deny|prevent|guard|enforce/.test(lower)) return 'gates';
1555
1557
  if (/team|agent|org|enterprise|rollout/.test(lower)) return 'team';
1556
1558
  if (/token|cost|saving|budget|spend/.test(lower)) return 'cost';
1557
1559
  if (/vertex|gcp|google|dialogflow|dfcx|cloud/.test(lower)) return 'cloud';
1558
1560
  return 'overview';
1559
1561
  }
1560
1562
 
1563
+ // Parse intent: LIST vs COUNT, and time window. Lets us answer "what mistakes
1564
+ // today?" with an actual filtered list instead of a canned total.
1565
+ function parseChatIntent(prompt) {
1566
+ const lower = String(prompt || '').toLowerCase();
1567
+ const wantsList = /\bwhat\b|\bwhich\b|\blist\b|\bshow me\b|\bexamples?\b|\btell me about\b/.test(lower);
1568
+ let windowMs = null;
1569
+ let windowLabel = 'across all time';
1570
+ if (/\btoday\b/.test(lower)) { windowMs = 24 * 60 * 60 * 1000; windowLabel = 'today'; }
1571
+ else if (/\byesterday\b/.test(lower)) { windowMs = 48 * 60 * 60 * 1000; windowLabel = 'yesterday'; }
1572
+ else if (/\bthis week\b|\b7 ?d(ay)?s?\b|\blast week\b/.test(lower)) { windowMs = 7 * 24 * 60 * 60 * 1000; windowLabel = 'the last 7 days'; }
1573
+ else if (/\bthis month\b|\b30 ?d(ay)?s?\b|\blast month\b/.test(lower)) { windowMs = 30 * 24 * 60 * 60 * 1000; windowLabel = 'the last 30 days'; }
1574
+ return { wantsList, windowMs, windowLabel };
1575
+ }
1576
+
1577
+ // Read recent feedback entries (signal-filtered, time-filtered) directly from
1578
+ // the feedback log. Bounded + best-effort — never throws into the chat handler.
1579
+ // Treat short/placeholder context values as "no real description" so we don't
1580
+ // surface `"thumbs down"` × 3 as a useful list. Real feedback always has a
1581
+ // concrete sentence somewhere (whatWentWrong, distillation, whatToChange) —
1582
+ // pick the longest informative one.
1583
+ // Short tokens that mean "no real description" — kept as a plain Set, not a
1584
+ // big regex, to keep complexity low and easy to extend.
1585
+ const PLACEHOLDER_TOKENS = new Set([
1586
+ 'thumbs down', 'thumbs up', 'thumb down', 'thumb up',
1587
+ 'good', 'bad', 'ok', 'nice', 'verify', 'verifies', 'verification', 'test', 'testing',
1588
+ ]);
1589
+
1590
+ function isPlaceholder(text) {
1591
+ const t = String(text || '').trim();
1592
+ if (!t || t.length < 20) return true;
1593
+ return PLACEHOLDER_TOKENS.has(t.toLowerCase().replace(/\.$/, ''));
1594
+ }
1595
+
1596
+ function bestFeedbackDescription(row) {
1597
+ const candidates = [row.whatWentWrong, row.distillation, row.context, row.whatToChange, row.whatWorked, row.reasoning]
1598
+ .map((c) => String(c || '').trim());
1599
+ // Prefer the longest non-placeholder candidate; fall back to any non-empty.
1600
+ const informative = candidates.filter((c) => c && !isPlaceholder(c));
1601
+ const fallback = candidates.find(Boolean) || '';
1602
+ const best = informative.reduce((a, b) => (b.length > a.length ? b : a), '') || fallback;
1603
+ return best.slice(0, 220);
1604
+ }
1605
+
1606
+ function readRecentFeedbackEntries(feedbackDir, signal, windowMs, limit = 5, opts = {}) {
1607
+ try {
1608
+ if (!feedbackDir) return [];
1609
+ const fsLocal = require('node:fs');
1610
+ const pathLocal = require('node:path');
1611
+ const logPath = pathLocal.join(feedbackDir, 'feedback-log.jsonl');
1612
+ if (!fsLocal.existsSync(logPath)) return [];
1613
+ const { readJsonl } = require('../../scripts/fs-utils');
1614
+ const rows = readJsonl(logPath) || [];
1615
+ const cutoff = windowMs ? Date.now() - windowMs : 0;
1616
+ const filtered = rows
1617
+ .filter((r) => !signal || r.signal === signal)
1618
+ .filter((r) => {
1619
+ if (!cutoff) return true;
1620
+ const t = r.timestamp ? Date.parse(r.timestamp) : Number.NaN;
1621
+ return Number.isFinite(t) && t >= cutoff;
1622
+ })
1623
+ .reverse()
1624
+ .map((r) => {
1625
+ const out = { timestamp: r.timestamp, context: bestFeedbackDescription(r) };
1626
+ if (opts.includeSignal) out.signal = r.signal;
1627
+ return out;
1628
+ });
1629
+ // For list display, drop entries with no real description so the list is useful,
1630
+ // not three "thumbs down" placeholders. For count-only (high limit), keep all.
1631
+ const useful = opts.skipPlaceholders === false || limit > 50
1632
+ ? filtered
1633
+ : filtered.filter((e) => e.context && !isPlaceholder(e.context));
1634
+ return useful.slice(0, limit);
1635
+ } catch {
1636
+ return [];
1637
+ }
1638
+ }
1639
+
1561
1640
  function containsUnsafeEnterpriseChatInput(prompt) {
1562
1641
  return /[;&|`$<>\\]/.test(String(prompt || ''));
1563
1642
  }
@@ -1567,101 +1646,99 @@ function compactNumber(value) {
1567
1646
  return Number.isFinite(n) ? n : 0;
1568
1647
  }
1569
1648
 
1570
- function isTodayScopedPrompt(prompt) {
1571
- return /\btoday\b|\bthis day\b|\blast 24\b|\b24 hours\b/i.test(String(prompt || ''));
1649
+ // Pick a signal preference from the prompt. Returns 'negative' | 'positive' | null.
1650
+ function detectFeedbackSignalFromPrompt(prompt) {
1651
+ const lower = String(prompt || '').toLowerCase();
1652
+ const wantsNeg = /mistake|wrong|fail|negative|thumbs? *down|block/.test(lower);
1653
+ const wantsPos = /positive|thumbs? *up|worked|success|wins?\b|good/.test(lower);
1654
+ if (wantsNeg && !wantsPos) return 'negative';
1655
+ if (wantsPos && !wantsNeg) return 'positive';
1656
+ return null;
1572
1657
  }
1573
1658
 
1574
- function getTodayGateAudit(gateAudit) {
1575
- const days = gateAudit && Array.isArray(gateAudit.days) ? gateAudit.days : [];
1576
- return days.length > 0 ? days[days.length - 1] : null;
1577
- }
1659
+ const FEEDBACK_LIST_LABELS = Object.freeze({
1660
+ negative: 'Recent mistakes',
1661
+ positive: 'Recent wins',
1662
+ });
1578
1663
 
1579
- function formatGateBuckets(byGate) {
1580
- return Object.entries(byGate || {})
1581
- .filter(([, count]) => compactNumber(count) > 0)
1582
- .sort((a, b) => compactNumber(b[1]) - compactNumber(a[1]))
1583
- .slice(0, 3)
1584
- .map(([gateId, count]) => `${gateId} (${compactNumber(count)}x)`);
1664
+ function buildFeedbackSection({ ctx, intent, feedbackDir, approval, lessonPipeline }) {
1665
+ const signal = detectFeedbackSignalFromPrompt(ctx.prompt);
1666
+
1667
+ // One read of the time-windowed log, then in-memory counts + (signal-filtered,
1668
+ // placeholder-stripped) list. Counts include ALL entries (so "Feedback today: 5"
1669
+ // matches the dashboard tile); the list drops vague entries like literal "thumbs
1670
+ // down" / "good" so it surfaces only entries with real, actionable description.
1671
+ const windowed = readRecentFeedbackEntries(feedbackDir, null, intent.windowMs, 10000, { includeSignal: true });
1672
+ const windowPos = windowed.filter((r) => r.signal === 'positive').length;
1673
+ const windowNeg = windowed.filter((r) => r.signal === 'negative').length;
1674
+ const entries = (signal ? windowed.filter((r) => r.signal === signal) : windowed)
1675
+ .filter((r) => r.context && !isPlaceholder(r.context))
1676
+ .slice(0, 5);
1677
+
1678
+ const lines = [];
1679
+ lines.push(intent.windowMs
1680
+ ? `Feedback ${intent.windowLabel}: ${windowed.length} (${windowPos} positive, ${windowNeg} negative).`
1681
+ : `Feedback total: ${compactNumber(approval.total)} (${compactNumber(approval.positive)} positive, ${compactNumber(approval.negative)} negative).`);
1682
+
1683
+ if (intent.wantsList) {
1684
+ if (entries.length) {
1685
+ lines.push(`${FEEDBACK_LIST_LABELS[signal] || 'Recent feedback'} (${intent.windowLabel}):`);
1686
+ for (const e of entries) {
1687
+ lines.push(` • ${(e.timestamp || '').slice(0, 10)} — ${e.context}`);
1688
+ }
1689
+ } else {
1690
+ lines.push(`No ${signal || 'feedback'} entries found ${intent.windowLabel}.`);
1691
+ }
1692
+ } else {
1693
+ lines.push(`Lesson pipeline: ${compactNumber(lessonPipeline.lessons || lessonPipeline.generated || 0)} lessons visible in the current dashboard snapshot.`);
1694
+ }
1695
+ return { lines, sources: ['feedback log', intent.wantsList ? 'feedback contexts' : 'lesson pipeline'] };
1585
1696
  }
1586
1697
 
1587
- function buildTodayGateAnswer(prompt, dashboardData, gateStats, gates) {
1588
- if (!isTodayScopedPrompt(prompt)) return null;
1589
- const lower = String(prompt || '').toLowerCase();
1590
- const gateAudit = dashboardData.gateAudit || {};
1591
- const prevention = dashboardData.prevention || {};
1592
- const today = getTodayGateAudit(gateAudit) || { deny: 0, warn: 0, intercepted: 0, byGate: {} };
1593
- const activeGateCount = gates.length || compactNumber(gateStats.totalGates);
1594
-
1595
- if (/\b(activated|promoted|created|enabled)\b/.test(lower)) {
1596
- const promotionsToday = compactNumber(prevention.promotionsToday);
1597
- const promotedIds = Array.isArray(prevention.promotionIdsToday) ? prevention.promotionIdsToday.filter(Boolean) : [];
1598
- return [
1599
- `Gates activated today: ${promotionsToday}.`,
1600
- `Active gates now: ${activeGateCount}.`,
1601
- promotedIds.length > 0 ? `Promoted today: ${promotedIds.slice(0, 3).join(', ')}.` : '',
1602
- ].filter(Boolean);
1603
- }
1698
+ const GATE_EVENTS_REGEX = /activat|fired|trigger|block|denied|prevent|enforce|hit/;
1604
1699
 
1605
- if (/\b(what|which)\b/.test(lower) && /\b(mistake|mistakes|block|blocked|prevent|prevented)\b/.test(lower)) {
1606
- const gateBuckets = formatGateBuckets(today.byGate);
1607
- return [
1608
- `Today: ${compactNumber(today.deny)} blocked actions and ${compactNumber(today.warn)} warning checkpoints.`,
1609
- gateBuckets.length > 0
1610
- ? `Top blocked/warned gates today: ${gateBuckets.join(', ')}.`
1611
- : 'No per-gate blocked mistake names are present in today\'s local audit snapshot.',
1612
- ];
1613
- }
1700
+ function describeGate(g) {
1701
+ return `${g.name || g.id || 'unnamed'}${g.severity ? ' [' + g.severity + ']' : ''}`;
1702
+ }
1614
1703
 
1615
- if (/\b(prevent|prevented|intercept|intercepted)\b/.test(lower)) {
1616
- return [
1617
- `Mistakes prevented today: ${compactNumber(today.intercepted)} interventions (${compactNumber(today.deny)} blocked, ${compactNumber(today.warn)} warned).`,
1618
- `All-time blocked actions recorded: ${compactNumber(gateStats.blocked || gateStats.denied || gateStats.totalBlocked)}.`,
1619
- ];
1620
- }
1704
+ function buildGatesSection({ ctx, intent, gates, gateStats }) {
1705
+ const asksEvents = GATE_EVENTS_REGEX.test(String(ctx.prompt || '').toLowerCase());
1706
+ const totalActive = gates.length || compactNumber(gateStats.totalGates);
1707
+ const totalBlocked = compactNumber(gateStats.blocked || gateStats.denied || gateStats.totalBlocked);
1621
1708
 
1622
- if (/\b(block|blocked|deny|denied)\b/.test(lower)) {
1623
- return [
1624
- `Mistakes blocked today: ${compactNumber(today.deny)} deny decisions.`,
1625
- `Warnings today: ${compactNumber(today.warn)}; all-time blocked actions recorded: ${compactNumber(gateStats.blocked || gateStats.denied || gateStats.totalBlocked)}.`,
1626
- ];
1709
+ const lines = [];
1710
+ if (asksEvents) {
1711
+ lines.push(`Blocked actions recorded (all time): ${totalBlocked}.`);
1712
+ if (intent.windowMs) {
1713
+ lines.push(`(Per-${intent.windowLabel} block-event breakdown isn't tracked in the local dashboard snapshot — only the running total. Filter the gate-events log directly for a precise window.)`);
1714
+ }
1715
+ } else {
1716
+ lines.push(`Active gates: ${totalActive}.`);
1627
1717
  }
1628
-
1629
- return null;
1718
+ if (intent.wantsList && gates.length) {
1719
+ lines.push('Active gates:');
1720
+ for (const g of gates.slice(0, 8)) lines.push(` • ${describeGate(g)}`);
1721
+ } else if (gates[0] && !intent.wantsList) {
1722
+ lines.push(`Example gate: ${describeGate(gates[0])}.`);
1723
+ }
1724
+ return { lines, sources: ['gate stats'] };
1630
1725
  }
1631
1726
 
1632
- function buildEnterpriseChatSection(topic, dashboardData, status, prompt) {
1727
+ function buildEnterpriseChatSection(topic, dashboardData, status, ctx = {}) {
1633
1728
  const approval = dashboardData.approval || {};
1634
1729
  const gates = Array.isArray(dashboardData.gates) ? dashboardData.gates : [];
1635
1730
  const gateStats = dashboardData.gateStats || {};
1636
1731
  const team = dashboardData.team || {};
1637
1732
  const tokenSavings = dashboardData.tokenSavings || {};
1638
1733
  const lessonPipeline = dashboardData.lessonPipeline || {};
1734
+ const intent = ctx.intent || { wantsList: false, windowMs: null, windowLabel: 'across all time' };
1735
+ const feedbackDir = ctx.feedbackDir || null;
1639
1736
 
1640
1737
  if (topic === 'feedback') {
1641
- return {
1642
- lines: [
1643
- `Feedback total: ${compactNumber(approval.total)} (${compactNumber(approval.positive)} positive, ${compactNumber(approval.negative)} negative).`,
1644
- `Lesson pipeline: ${compactNumber(lessonPipeline.lessons || lessonPipeline.generated || 0)} lessons visible in the current dashboard snapshot.`,
1645
- ],
1646
- sources: ['feedback log', 'lesson pipeline'],
1647
- };
1738
+ return buildFeedbackSection({ ctx, intent, feedbackDir, approval, lessonPipeline });
1648
1739
  }
1649
1740
  if (topic === 'gates') {
1650
- const todayGateAnswer = buildTodayGateAnswer(prompt, dashboardData, gateStats, gates);
1651
- if (todayGateAnswer) {
1652
- return {
1653
- lines: todayGateAnswer,
1654
- sources: ['gate audit', 'gate stats'],
1655
- };
1656
- }
1657
- return {
1658
- lines: [
1659
- `Active gates: ${gates.length || compactNumber(gateStats.totalGates)}.`,
1660
- `Blocked actions recorded: ${compactNumber(gateStats.blocked || gateStats.denied || gateStats.totalBlocked)}.`,
1661
- gates[0] ? `Example gate: ${gates[0].name || gates[0].id || 'unnamed gate'}.` : '',
1662
- ].filter(Boolean),
1663
- sources: ['gate stats'],
1664
- };
1741
+ return buildGatesSection({ ctx, intent, gates, gateStats });
1665
1742
  }
1666
1743
  if (topic === 'team') {
1667
1744
  return {
@@ -1702,13 +1779,22 @@ function buildEnterpriseChatSection(topic, dashboardData, status, prompt) {
1702
1779
  };
1703
1780
  }
1704
1781
 
1705
- function buildEnterpriseChatAnswer(prompt, dashboardData, status) {
1782
+ function buildEnterpriseChatAnswer(prompt, dashboardData, status, opts = {}) {
1706
1783
  const topic = classifyEnterpriseChatTopic(prompt);
1707
- const section = buildEnterpriseChatSection(topic, dashboardData, status, prompt);
1784
+ const intent = parseChatIntent(prompt);
1785
+ const section = buildEnterpriseChatSection(topic, dashboardData, status, {
1786
+ intent,
1787
+ feedbackDir: opts.feedbackDir,
1788
+ prompt,
1789
+ });
1790
+
1791
+ // List-style answers want newlines; single-line answers join with space.
1792
+ const hasList = section.lines.some((l) => /^\s*•/.test(l));
1793
+ const answer = hasList ? section.lines.join('\n') : section.lines.join(' ');
1708
1794
 
1709
1795
  return {
1710
1796
  topic,
1711
- answer: section.lines.join(' '),
1797
+ answer,
1712
1798
  sources: ['local dashboard data', ...section.sources],
1713
1799
  };
1714
1800
  }
@@ -1724,6 +1810,7 @@ async function trySendLocalDashboardChat(res, parsed, feedbackDir, prompt, suffi
1724
1810
  prompt,
1725
1811
  dashboardResult.data,
1726
1812
  buildEnterpriseDataChatStatus(),
1813
+ { feedbackDir },
1727
1814
  );
1728
1815
  sendJson(res, 200, {
1729
1816
  ok: true,
@@ -1735,7 +1822,7 @@ async function trySendLocalDashboardChat(res, parsed, feedbackDir, prompt, suffi
1735
1822
  grounded: true,
1736
1823
  });
1737
1824
  return true;
1738
- } catch (_) {
1825
+ } catch {
1739
1826
  return false;
1740
1827
  }
1741
1828
  }
@@ -1771,7 +1858,7 @@ async function answerEnterpriseDataChat({ prompt, feedbackDir, parsed }) {
1771
1858
  // The dashboard's real local/open-source chatbot turn goes through /v1/chat,
1772
1859
  // which uses lesson retrieval + optional LanceDB vector search + the user's
1773
1860
  // configured local or BYO model.
1774
- const chat = buildEnterpriseChatAnswer(normalizedPrompt, dashboardData, status);
1861
+ const chat = buildEnterpriseChatAnswer(normalizedPrompt, dashboardData, status, { feedbackDir });
1775
1862
  const dfcxRequest = {
1776
1863
  fulfillmentInfo: { tag: 'chat-with-data' },
1777
1864
  sessionInfo: {
@@ -2211,7 +2298,7 @@ function appendBestEffortTelemetry(feedbackDir, payload, headers, context) {
2211
2298
  evidence: [err?.message ? err.message : 'unknown_error'],
2212
2299
  },
2213
2300
  });
2214
- } catch (_) {}
2301
+ } catch {}
2215
2302
  return false;
2216
2303
  }
2217
2304
  }
@@ -3423,7 +3510,7 @@ function renderCheckoutSuccessPage(runtimeConfig) {
3423
3510
  if (window.sessionStorage) {
3424
3511
  window.sessionStorage.setItem(marker, '1');
3425
3512
  }
3426
- } catch (_) {
3513
+ } catch {
3427
3514
  sendTelemetry(eventType, extra);
3428
3515
  }
3429
3516
  }
@@ -6082,7 +6169,7 @@ ${hidden}
6082
6169
  evidence: [err?.message ? err.message : 'unknown_error'],
6083
6170
  },
6084
6171
  });
6085
- } catch (_) {
6172
+ } catch {
6086
6173
  // Telemetry is best-effort and must never fail the caller.
6087
6174
  }
6088
6175
  }
@@ -6449,7 +6536,7 @@ ${hidden}
6449
6536
  context: 'failed to persist install-email capture to ledger',
6450
6537
  metadata: { error: err?.message || 'unknown' },
6451
6538
  });
6452
- } catch (_) {}
6539
+ } catch {}
6453
6540
  }
6454
6541
 
6455
6542
  // Privacy-clean telemetry ping for funnel attribution (no email).
@@ -7926,7 +8013,7 @@ ${hidden}
7926
8013
  feedbackDir: getSafeDataDir(),
7927
8014
  limit: 10,
7928
8015
  });
7929
- } catch (_) { /* best-effort — conversation window is optional */ }
8016
+ } catch { /* best-effort — conversation window is optional */ }
7930
8017
  }
7931
8018
  const result = captureFeedback({
7932
8019
  signal: body.signal,