thumbgate 1.27.18 → 1.27.19

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 (96) hide show
  1. package/.claude-plugin/marketplace.json +6 -6
  2. package/.claude-plugin/plugin.json +4 -3
  3. package/.well-known/agentic-verify.txt +1 -0
  4. package/.well-known/llms.txt +33 -12
  5. package/.well-known/mcp/server-card.json +8 -8
  6. package/README.md +249 -30
  7. package/adapters/chatgpt/openapi.yaml +12 -0
  8. package/adapters/claude/.mcp.json +2 -2
  9. package/adapters/codex/config.toml +2 -2
  10. package/adapters/gemini/function-declarations.json +1 -0
  11. package/adapters/mcp/server-stdio.js +263 -11
  12. package/adapters/opencode/opencode.json +1 -1
  13. package/bench/thumbgate-bench.json +2 -2
  14. package/bin/cli.js +1429 -121
  15. package/bin/postinstall.js +1 -8
  16. package/config/gate-classifier-routing.json +98 -0
  17. package/config/gate-templates.json +216 -0
  18. package/config/gates/claim-verification.json +12 -0
  19. package/config/gates/default.json +31 -2
  20. package/config/github-about.json +2 -2
  21. package/config/mcp-allowlists.json +23 -13
  22. package/config/merge-quality-checks.json +0 -1
  23. package/config/model-candidates.json +121 -6
  24. package/config/post-deploy-marketing-pages.json +80 -0
  25. package/config/tessl-tiles.json +1 -3
  26. package/openapi/openapi.yaml +12 -0
  27. package/package.json +1 -1
  28. package/public/blog.html +4 -4
  29. package/public/codex-plugin.html +72 -20
  30. package/public/compare.html +31 -8
  31. package/public/dashboard.html +930 -166
  32. package/public/federal.html +2 -2
  33. package/public/guide.html +33 -13
  34. package/public/index.html +469 -111
  35. package/public/learn.html +183 -18
  36. package/public/lessons.html +168 -10
  37. package/public/numbers.html +7 -7
  38. package/public/pro.html +34 -11
  39. package/scripts/agent-memory-lifecycle.js +211 -0
  40. package/scripts/agent-readiness.js +20 -3
  41. package/scripts/agent-reward-model.js +53 -1
  42. package/scripts/auto-promote-gates.js +82 -10
  43. package/scripts/auto-wire-hooks.js +14 -0
  44. package/scripts/billing.js +93 -1
  45. package/scripts/bot-detection.js +61 -3
  46. package/scripts/build-metadata.js +50 -10
  47. package/scripts/cli-feedback.js +4 -2
  48. package/scripts/cli-schema.js +97 -0
  49. package/scripts/cli-telemetry.js +6 -1
  50. package/scripts/commercial-offer.js +82 -2
  51. package/scripts/context-manager.js +74 -6
  52. package/scripts/dashboard.js +68 -2
  53. package/scripts/export-databricks-bundle.js +5 -1
  54. package/scripts/export-dpo-pairs.js +7 -2
  55. package/scripts/feedback-loop.js +123 -1
  56. package/scripts/feedback-quality.js +87 -0
  57. package/scripts/filesystem-search.js +35 -10
  58. package/scripts/gate-stats.js +89 -0
  59. package/scripts/gates-engine.js +1176 -85
  60. package/scripts/gemini-embedding-policy.js +2 -1
  61. package/scripts/hook-runtime.js +20 -14
  62. package/scripts/hook-thumbgate-cache-updater.js +18 -2
  63. package/scripts/hybrid-feedback-context.js +142 -7
  64. package/scripts/lesson-inference.js +8 -3
  65. package/scripts/lesson-search.js +17 -1
  66. package/scripts/license.js +10 -10
  67. package/scripts/llm-client.js +169 -4
  68. package/scripts/local-model-profile.js +15 -8
  69. package/scripts/mcp-config.js +7 -1
  70. package/scripts/memory-scope-readiness.js +159 -0
  71. package/scripts/meta-agent-loop.js +36 -0
  72. package/scripts/operational-integrity.js +39 -5
  73. package/scripts/oss-pr-opportunity-scout.js +35 -5
  74. package/scripts/plausible-server-events.js +9 -6
  75. package/scripts/pro-local-dashboard.js +4 -4
  76. package/scripts/proxy-pointer-rag-guardrails.js +42 -1
  77. package/scripts/published-cli.js +0 -8
  78. package/scripts/rate-limiter.js +64 -13
  79. package/scripts/secret-scanner.js +44 -5
  80. package/scripts/security-scanner.js +260 -10
  81. package/scripts/self-distill-agent.js +3 -1
  82. package/scripts/seo-gsd.js +916 -7
  83. package/scripts/statusline-cache-path.js +17 -2
  84. package/scripts/statusline-local-stats.js +9 -1
  85. package/scripts/statusline-meta.js +28 -2
  86. package/scripts/statusline.sh +20 -4
  87. package/scripts/telemetry-analytics.js +357 -0
  88. package/scripts/thompson-sampling.js +31 -10
  89. package/scripts/thumbgate-bench.js +16 -1
  90. package/scripts/thumbgate-search.js +85 -19
  91. package/scripts/tool-registry.js +169 -1
  92. package/scripts/vector-store.js +45 -0
  93. package/scripts/workflow-sentinel.js +286 -53
  94. package/scripts/workspace-evolver.js +62 -2
  95. package/src/api/server.js +2683 -319
  96. package/scripts/bot-detector.js +0 -50
@@ -51,6 +51,7 @@ const {
51
51
  } = require('./analytics-window');
52
52
  const { ensureParentDir } = require('./fs-utils');
53
53
  const mailer = require('./mailer');
54
+ const { recordCheckoutFunnelEvent } = require('./plausible-server-events');
54
55
 
55
56
  function loadWorkflowSprintIntakeModule() {
56
57
  const modulePath = path.resolve(__dirname, 'workflow-sprint-intake.js');
@@ -138,6 +139,10 @@ const IS_TEST = !!(
138
139
  process.env.NODE_ENV === 'test'
139
140
  );
140
141
 
142
+ function allowUnsignedStripeWebhooks() {
143
+ return IS_TEST && process.env.THUMBGATE_ALLOW_UNSIGNED_STRIPE_WEBHOOKS === '1';
144
+ }
145
+
141
146
  function shouldMergeLegacyBillingData() {
142
147
  return process.env._TEST_INCLUDE_LEGACY_BILLING_DATA === '1'
143
148
  || process.env.THUMBGATE_INCLUDE_LEGACY_BILLING_DATA === '1';
@@ -444,6 +449,57 @@ function buildCheckoutProductData({ name, description, appOrigin, planId }) {
444
449
  };
445
450
  }
446
451
 
452
+ /**
453
+ * Verify an ACTIVE Stripe product exists for the given plan name before
454
+ * we let buildSubscriptionPriceData create inline price_data under it.
455
+ *
456
+ * Stripe matches product_data by `name`. If only an archived product
457
+ * matches, new prices created via that path inherit active=false and the
458
+ * generated checkout URL renders "Something went wrong / The page you
459
+ * were looking for could not be found." for the buyer. Stripe Dashboard
460
+ * shows the session as `open` with no email captured — looks like the
461
+ * buyer abandoned, but they were never given a working page.
462
+ *
463
+ * Failing here surfaces the misconfiguration at the first checkout
464
+ * attempt instead of silently breaking every buyer for days.
465
+ *
466
+ * Verified incident: ThumbGate#2188 (May 2026) — 20 sessions abandoned in
467
+ * 7 days, all because the only product named "ThumbGate Pro" matching the
468
+ * inline product_data was archived (prod_UXxOHAfbDsPyRb), while an active
469
+ * product with the same name existed (prod_UW82THPxfNvwKT) that should
470
+ * have been used instead.
471
+ */
472
+ async function verifyActiveProductForPlan(stripe, planId) {
473
+ const expectedName = planId === 'team' ? 'ThumbGate Team' : 'ThumbGate Pro';
474
+ let products;
475
+ try {
476
+ products = await stripe.products.list({ limit: 100 });
477
+ } catch (err) {
478
+ // Network/transient failures shouldn't block checkout creation.
479
+ // The original session.create call will surface real Stripe errors.
480
+ return;
481
+ }
482
+ const matching = (products && products.data ? products.data : [])
483
+ .filter((p) => p && p.name === expectedName);
484
+ if (matching.length === 0) {
485
+ // No product with this name exists; Stripe will create a new one when
486
+ // session.create fires with inline product_data. Safe path.
487
+ return;
488
+ }
489
+ const active = matching.find((p) => p.active === true);
490
+ if (!active) {
491
+ const archived = matching[0];
492
+ throw new Error(
493
+ `Refusing to create checkout session: Stripe product named "${expectedName}" ` +
494
+ `exists only in archived state (id=${archived.id}, active=false). New prices ` +
495
+ `created via inline product_data would inherit active=false, rendering ` +
496
+ `"page not found" on Stripe checkout for every buyer. Fix: reactivate the ` +
497
+ `archived product in Stripe Dashboard, or rename the active product to ` +
498
+ `match "${expectedName}". See ThumbGate#2188 for the May 2026 incident.`
499
+ );
500
+ }
501
+ }
502
+
447
503
  function buildSubscriptionPriceData(checkoutSelection, appOrigin) {
448
504
  const isTeam = checkoutSelection.planId === 'team';
449
505
  const annual = checkoutSelection.billingCycle === 'annual';
@@ -2525,6 +2581,18 @@ async function createCheckoutSession({ successUrl, cancelUrl, customerEmail, ins
2525
2581
  }
2526
2582
 
2527
2583
  const stripe = getStripeClient();
2584
+
2585
+ // Defensive guard against ThumbGate#2188:
2586
+ // When buildSubscriptionPriceData passes inline `product_data` to Stripe,
2587
+ // Stripe name-matches existing products. If the only existing product with
2588
+ // that name is ARCHIVED (active=false), the new price inherits active=false
2589
+ // and every Stripe checkout page renders "page not found" for the buyer.
2590
+ // That bug burnt 20+ silent abandoned sessions in May 2026. Fail fast
2591
+ // instead of letting the broken page ship.
2592
+ if (!packId) {
2593
+ await verifyActiveProductForPlan(stripe, checkoutSelection.planId);
2594
+ }
2595
+
2528
2596
  const sessionPayload = buildCheckoutSessionPayload({
2529
2597
  successUrl,
2530
2598
  cancelUrl,
@@ -2837,7 +2905,7 @@ function disableCustomerKeys(customerId) {
2837
2905
  }
2838
2906
 
2839
2907
  function verifyWebhookSignature(rawBody, signature) {
2840
- if (!CONFIG.STRIPE_WEBHOOK_SECRET) return true;
2908
+ if (!CONFIG.STRIPE_WEBHOOK_SECRET) return allowUnsignedStripeWebhooks();
2841
2909
  if (!signature || !rawBody) return false;
2842
2910
 
2843
2911
  // Stripe signature format: t=<timestamp>,v1=<hmac>,...
@@ -2867,6 +2935,13 @@ function verifyWebhookSignature(rawBody, signature) {
2867
2935
 
2868
2936
  async function handleWebhook(rawBody, signature) {
2869
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
+ }
2870
2945
  let event;
2871
2946
  try {
2872
2947
  if (CONFIG.STRIPE_WEBHOOK_SECRET) {
@@ -2975,6 +3050,22 @@ async function handleWebhook(rawBody, signature) {
2975
3050
  attribution,
2976
3051
  });
2977
3052
  }
3053
+ // Fire Plausible purchase event so the funnel poller can measure
3054
+ // end-to-end conversion: visitor → CTA → checkout → email → Stripe → purchase.
3055
+ // Fire-and-forget (never blocks the webhook response).
3056
+ void recordCheckoutFunnelEvent('purchase', {
3057
+ page: '/success',
3058
+ props: {
3059
+ sessionId: session.id,
3060
+ customerId,
3061
+ traceId: traceId || '',
3062
+ packId: packId || '',
3063
+ amount: session.amount_total != null ? String(session.amount_total) : '',
3064
+ currency: session.currency || '',
3065
+ ...attribution,
3066
+ },
3067
+ });
3068
+
2978
3069
  return {
2979
3070
  handled: true,
2980
3071
  action: 'provisioned_api_key',
@@ -3127,6 +3218,7 @@ module.exports = {
3127
3218
  _buildTrialActivationEmail: buildTrialActivationEmail,
3128
3219
  _sendTrialActivationEmail: sendTrialActivationEmail,
3129
3220
  _resolveSubscriptionCheckoutSelection: resolveSubscriptionCheckoutSelection,
3221
+ _verifyActiveProductForPlan: verifyActiveProductForPlan,
3130
3222
  _API_KEYS_PATH: () => CONFIG.API_KEYS_PATH,
3131
3223
  _FUNNEL_LEDGER_PATH: () => CONFIG.FUNNEL_LEDGER_PATH,
3132
3224
  _REVENUE_LEDGER_PATH: () => CONFIG.REVENUE_LEDGER_PATH,
@@ -36,13 +36,18 @@ const BOT_PATTERNS = [
36
36
  /\bbaiduspider\b/i,
37
37
  /\bduckduckbot\b/i,
38
38
  /\bapplebot\b/i,
39
+ /slurp/i,
40
+ /petalbot/i,
41
+ /mediapartners/i,
39
42
  // LLM / AI crawlers — these started exploding in 2024+
40
43
  /\bgptbot\b/i,
41
- /\bchatgpt-user\b/i,
44
+ /chatgpt/i,
42
45
  /\boai-searchbot\b/i,
43
- /\bperplexitybot\b/i,
44
- /\banthropic-ai\b/i,
46
+ /perplexity/i,
47
+ /anthropic/i,
45
48
  /\bclaude(?:bot|-web)\b/i,
49
+ /claude-searchbot/i,
50
+ /google-extended/i,
46
51
  /\bccbot\b/i,
47
52
  /\bcohere-ai\b/i,
48
53
  /\bbytespider\b/i,
@@ -86,6 +91,8 @@ const BOT_PATTERNS = [
86
91
  /\blibwww-perl\b/i,
87
92
  /\bjava\//i,
88
93
  /\bgo-http-client\b/i,
94
+ /scrapy/i,
95
+ /httpclient/i,
89
96
  /\bruby\b/i,
90
97
  // API/test tools
91
98
  /\bpostman(?:runtime)?\b/i,
@@ -158,8 +165,59 @@ function isProbablyBot(headers) {
158
165
  return classifyRequester(headers).isBot;
159
166
  }
160
167
 
168
+ // --- Visitor classification (analytics filtering) ---------------------------
169
+ // Classifies telemetry/analytics traffic as bot / owner / real_user.
170
+ // Owner traffic is identified via the THUMBGATE_OWNER_EMAILS env var
171
+ // (comma-separated, case-insensitive). There is no built-in default:
172
+ // operators opt in via config, so the shipped artifact carries no
173
+ // personal data.
174
+
175
+ function getOwnerEmails(env = process.env) {
176
+ const raw = env.THUMBGATE_OWNER_EMAILS;
177
+ if (!raw) return [];
178
+ return raw
179
+ .split(',')
180
+ .map((entry) => entry.trim().toLowerCase())
181
+ .filter(Boolean);
182
+ }
183
+
184
+ function classifyVisitor(req) {
185
+ const ua = (req.headers && req.headers['user-agent']) || '';
186
+ const email = req.email || (req.query && req.query.email) || '';
187
+
188
+ for (const pattern of BOT_PATTERNS) {
189
+ if (pattern.test(ua)) {
190
+ return { type: 'bot', reason: `UA matches: ${pattern}`, userAgent: ua };
191
+ }
192
+ }
193
+ if (!ua || ua.length < 10) {
194
+ return { type: 'bot', reason: 'Empty or short user-agent', userAgent: ua };
195
+ }
196
+ if (email) {
197
+ const normalized = email.toLowerCase();
198
+ if (getOwnerEmails().some((ownerEmail) => normalized.includes(ownerEmail))) {
199
+ return { type: 'owner', reason: 'Email matches configured owner list', userAgent: ua };
200
+ }
201
+ }
202
+ return { type: 'real_user', reason: 'No bot pattern matched', userAgent: ua };
203
+ }
204
+
205
+ function shouldExcludeFromAnalytics(req) {
206
+ const classification = req.visitorClass || classifyVisitor(req);
207
+ return classification.type === 'bot';
208
+ }
209
+
210
+ function botFilterMiddleware(req, res, next) {
211
+ req.visitorClass = classifyVisitor(req);
212
+ next();
213
+ }
214
+
161
215
  module.exports = {
162
216
  classifyRequester,
163
217
  isProbablyBot,
218
+ classifyVisitor,
219
+ shouldExcludeFromAnalytics,
220
+ botFilterMiddleware,
221
+ getOwnerEmails,
164
222
  BOT_PATTERNS,
165
223
  };
@@ -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') {
@@ -16,35 +21,69 @@ function normalizeNullableText(value) {
16
21
  }
17
22
 
18
23
  function resolveBuildMetadata({ env = process.env, filePath } = {}) {
24
+ // Precedence: immutable JSON file (baked into Docker image at build time, so it
25
+ // ALWAYS matches the deployed code) wins over runtime env vars. Env vars are
26
+ // mutable Railway/host config that can drift — they shadowed the freshly-stamped
27
+ // SHA in prod on 2026-05-20 and made /health lie about the deployed commit.
28
+ // Fall back to env vars only when the file is missing or its values are null,
29
+ // and require an explicit SHA env var (not just a stray GENERATED_AT) before
30
+ // trusting the env branch.
19
31
  const resolvedPath =
20
32
  normalizeNullableText(filePath) ||
21
33
  normalizeNullableText(env.THUMBGATE_BUILD_METADATA_PATH) ||
22
34
  DEFAULT_BUILD_METADATA_PATH;
23
35
  const envBuildSha = normalizeNullableText(env[BUILD_SHA_ENV_KEY]);
36
+ const railwayGitSha = normalizeNullableText(env[RAILWAY_GIT_COMMIT_SHA_ENV_KEY]);
24
37
  const envGeneratedAt = normalizeNullableText(env[BUILD_GENERATED_AT_ENV_KEY]);
25
38
 
26
- if (envBuildSha || envGeneratedAt) {
39
+ let fileBuildSha = null;
40
+ let fileGeneratedAt = null;
41
+ try {
42
+ const parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
43
+ fileBuildSha = normalizeNullableText(parsed.buildSha);
44
+ fileGeneratedAt = normalizeNullableText(parsed.generatedAt);
45
+ } catch {
46
+ // file missing or unreadable — fall through to env branch
47
+ }
48
+
49
+ if (fileBuildSha) {
27
50
  return {
28
51
  path: resolvedPath,
29
- buildSha: envBuildSha,
30
- generatedAt: envGeneratedAt,
52
+ buildSha: fileBuildSha,
53
+ generatedAt: fileGeneratedAt || envGeneratedAt,
31
54
  };
32
55
  }
33
56
 
34
- try {
35
- const parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
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) {
36
64
  return {
37
65
  path: resolvedPath,
38
- buildSha: normalizeNullableText(parsed.buildSha),
39
- generatedAt: normalizeNullableText(parsed.generatedAt),
66
+ buildSha: railwayGitSha,
67
+ generatedAt: envGeneratedAt,
40
68
  };
41
- } catch {
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.)
74
+ if (envBuildSha) {
42
75
  return {
43
76
  path: resolvedPath,
44
- buildSha: null,
45
- generatedAt: null,
77
+ buildSha: envBuildSha,
78
+ generatedAt: envGeneratedAt,
46
79
  };
47
80
  }
81
+
82
+ return {
83
+ path: resolvedPath,
84
+ buildSha: null,
85
+ generatedAt: fileGeneratedAt || envGeneratedAt,
86
+ };
48
87
  }
49
88
 
50
89
  function writeBuildMetadataFile({ sha, outputPath, generatedAt = new Date().toISOString() }) {
@@ -105,6 +144,7 @@ if (require.main === module) {
105
144
  module.exports = {
106
145
  BUILD_GENERATED_AT_ENV_KEY,
107
146
  BUILD_SHA_ENV_KEY,
147
+ RAILWAY_GIT_COMMIT_SHA_ENV_KEY,
108
148
  DEFAULT_BUILD_METADATA_PATH,
109
149
  resolveBuildMetadata,
110
150
  writeBuildMetadataFile,
@@ -88,11 +88,13 @@ function formatCliOutput(result) {
88
88
  if (result.feedbackResult && result.feedbackResult.accepted !== false) {
89
89
  lines.push(`${isDown ? R : G}${BD}${isDown ? '👎 Thumbs down recorded' : '👍 Thumbs up recorded'}${RST}`);
90
90
  const feedbackId = (result.feedbackResult.feedbackEvent && result.feedbackResult.feedbackEvent.id) || result.feedbackResult.id;
91
+ const memoryId = (result.feedbackResult.memoryRecord && result.feedbackResult.memoryRecord.id) || result.feedbackResult.memoryId;
91
92
  if (feedbackId) {
92
- lines.push(`${D} ID: ${feedbackId}${RST}`);
93
+ lines.push(`${D} Feedback ID: ${feedbackId}${RST}`);
94
+ if (memoryId) lines.push(`${D} Memory ID : ${memoryId}${RST}`);
93
95
  // Echo feedback ID to stderr so it's visible directly in the terminal,
94
96
  // not hidden behind Claude Code's "ctrl+o to expand" MCP call collapse.
95
- process.stderr.write(`✅ Feedback captured (${feedbackId})\n`);
97
+ process.stderr.write(`✅ Feedback captured (${feedbackId}${memoryId ? `, ${memoryId}` : ''})\n`);
96
98
  }
97
99
  } else {
98
100
  lines.push(`${R}Feedback not accepted: ${(result.feedbackResult && result.feedbackResult.reason) || 'unknown'}${RST}`);
@@ -51,6 +51,20 @@ const CLI_COMMANDS = [
51
51
  { name: 'json', type: 'boolean', description: 'Output as JSON' },
52
52
  ],
53
53
  },
54
+ {
55
+ name: 'feedback-self-test',
56
+ aliases: ['dogfood'],
57
+ description: 'Prove thumbs feedback capture works in the current runtime',
58
+ group: 'capture',
59
+ mcpTool: 'capture_feedback',
60
+ flags: [
61
+ { name: 'feedback', type: 'string', description: 'Signal to test: up or down (default down)' },
62
+ { name: 'context', type: 'string', description: 'Context to store in the test capture' },
63
+ { name: 'persist', type: 'boolean', description: 'Use the active ThumbGate store instead of an isolated test store' },
64
+ { name: 'feedback-dir', type: 'string', description: 'Explicit feedback directory for the self-test' },
65
+ { name: 'json', type: 'boolean', description: 'Output as JSON' },
66
+ ],
67
+ },
54
68
 
55
69
  // -------------------------------------------------------------------------
56
70
  // Discovery
@@ -80,6 +94,25 @@ const CLI_COMMANDS = [
80
94
  { name: 'remote', type: 'boolean', description: 'Fetch from hosted Railway instance' },
81
95
  ],
82
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
+ },
83
116
  {
84
117
  name: 'stats',
85
118
  description: 'Feedback analytics — approval rate, Revenue-at-Risk, recent trend',
@@ -337,6 +370,19 @@ const CLI_COMMANDS = [
337
370
  { name: 'high-risk-workflows', type: 'string', description: 'Comma-separated workflows touching money, prod, secrets, data, or publishing' },
338
371
  ],
339
372
  }),
373
+ discoveryCommand({
374
+ name: 'ai-inventory',
375
+ aliases: ['ai-component-inventory', 'ml-bom', 'mlbom'],
376
+ description: 'Scan AI/ML components and export enterprise ML-BOM evidence',
377
+ mcpTool: 'ai_component_inventory',
378
+ flags: [
379
+ jsonFlag(),
380
+ { name: 'root', type: 'string', description: 'Project root to scan' },
381
+ { name: 'format', type: 'string', description: 'summary, json, or cyclonedx' },
382
+ { name: 'output', type: 'string', description: 'Write evidence to this path' },
383
+ { name: 'max-files', type: 'number', description: 'Maximum files to scan' },
384
+ ],
385
+ }),
340
386
  discoveryCommand({
341
387
  name: 'long-running-agent-context-guardrails',
342
388
  aliases: ['agent-context-guardrails', 'slack-context-guardrails'],
@@ -459,6 +505,12 @@ const CLI_COMMANDS = [
459
505
  group: 'gates',
460
506
  flags: [],
461
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
+ },
462
514
  {
463
515
  name: 'force-gate',
464
516
  description: 'Immediately create a blocking gate from a pattern string',
@@ -525,6 +577,14 @@ const CLI_COMMANDS = [
525
577
  { name: 'json', type: 'boolean', description: 'Output as JSON' },
526
578
  ],
527
579
  },
580
+ {
581
+ name: 'audit',
582
+ description: 'Audit an agent transcript for repeat-mistake patterns and estimated token waste',
583
+ group: 'ops',
584
+ flags: [
585
+ { name: 'file', type: 'string', description: 'Path to the agent transcript to audit' },
586
+ ],
587
+ },
528
588
  {
529
589
  name: 'init',
530
590
  description: 'Scaffold .thumbgate/ config and wire agent hooks',
@@ -582,6 +642,43 @@ const CLI_COMMANDS = [
582
642
  { name: 'info', type: 'boolean', description: 'Show Pro feature list' },
583
643
  ],
584
644
  },
645
+ {
646
+ name: 'diagnostic',
647
+ aliases: ['workflow-diagnostic', 'sprint-diagnostic'],
648
+ description: '$499 Workflow Hardening Diagnostic for one repeated AI-agent workflow failure',
649
+ group: 'ops',
650
+ flags: [],
651
+ },
652
+
653
+ {
654
+ name: 'workflow',
655
+ aliases: ['swarm'],
656
+ description: 'Execute a dynamic parallel workflow for security audit, benchmarking, or exploration',
657
+ group: 'ops',
658
+ mcpTool: 'parallel_workflow',
659
+ flags: [
660
+ { name: 'objective', type: 'string', required: true, description: 'The objective to plan and execute (e.g. security audit, performance benchmark)' },
661
+ { name: 'concurrency', type: 'number', description: 'Maximum parallel subtasks (default 3)' },
662
+ { name: 'timeoutMs', type: 'number', description: 'Timeout in milliseconds (default 60000)' },
663
+ { name: 'json', type: 'boolean', description: 'Output results as JSON' },
664
+ ],
665
+ },
666
+ {
667
+ name: 'check-update',
668
+ aliases: ['upgrade-check'],
669
+ description: 'Check for newer versions of ThumbGate from npm or GitHub',
670
+ group: 'ops',
671
+ flags: [
672
+ { name: 'json', type: 'boolean', description: 'Output results as JSON' },
673
+ ],
674
+ },
675
+ {
676
+ name: 'self-update',
677
+ aliases: ['upgrade-cli'],
678
+ description: 'Automatically install the latest version of ThumbGate globally',
679
+ group: 'ops',
680
+ flags: [],
681
+ },
585
682
  ];
586
683
 
587
684
  /**
@@ -10,6 +10,9 @@ const _DEFAULT_TELEMETRY_HOST = 'https://thumbgate-production.up.railway.app';
10
10
  const TELEMETRY_ENDPOINT = `${process.env.THUMBGATE_API_URL || _DEFAULT_TELEMETRY_HOST}/v1/telemetry/ping`;
11
11
  const INSTALL_ID_PATH = path.join(process.env.HOME || process.env.USERPROFILE || '.', '.thumbgate', 'install-id');
12
12
 
13
+ // Session ID: random per process invocation. Groups all events from one CLI run.
14
+ const SESSION_ID = crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString('hex');
15
+
13
16
  /**
14
17
  * Get or create a stable anonymous install ID.
15
18
  * This is NOT tied to any personal info — it's a random UUID stored locally.
@@ -61,7 +64,9 @@ function trackEvent(eventType, metadata = {}) {
61
64
  const payload = JSON.stringify({
62
65
  eventType,
63
66
  installId: getInstallId(),
67
+ sessionId: SESSION_ID,
64
68
  visitorType: classifyInstall(),
69
+ clientType: 'cli',
65
70
  platform: os.platform(),
66
71
  arch: os.arch(),
67
72
  nodeVersion: process.version,
@@ -87,4 +92,4 @@ function trackEvent(eventType, metadata = {}) {
87
92
  } catch (_) {} // never crash the CLI
88
93
  }
89
94
 
90
- module.exports = { trackEvent, getInstallId, classifyInstall, INSTALL_ID_PATH };
95
+ module.exports = { trackEvent, getInstallId, classifyInstall, INSTALL_ID_PATH, SESSION_ID };
@@ -14,7 +14,12 @@ const TEAM_ANNUAL_PRICE_DOLLARS = 588;
14
14
  const TEAM_MIN_SEATS = 3;
15
15
 
16
16
  const PRO_PRICE_LABEL = '$19/mo or $149/yr (individual)';
17
- const TEAM_PRICE_LABEL = '$49/seat/mo Agent governance for engineering teams';
17
+ // Enterprise is the contact-sales tier (absorbs the former Team workflow + the
18
+ // regulated-industry lane). Pricing is scoped after intake — no self-serve seat
19
+ // price is surfaced. The dormant TEAM_* Stripe constants below remain only as
20
+ // inert billing plumbing pending a dedicated cleanup; they are no longer a
21
+ // customer-facing tier.
22
+ const ENTERPRISE_PRICE_LABEL = 'Custom pricing, scoped after intake — Enterprise agent governance';
18
23
 
19
24
  function normalizePlanId(value) {
20
25
  const text = String(value || '').trim().toLowerCase();
@@ -35,6 +40,78 @@ function normalizeSeatCount(value, fallback = TEAM_MIN_SEATS) {
35
40
  return Math.max(TEAM_MIN_SEATS, Math.round(parsed));
36
41
  }
37
42
 
43
+ function trackedProUrl(source = 'cli_receipt', content = 'value_receipt') {
44
+ try {
45
+ const url = new URL(PRO_MONTHLY_PAYMENT_LINK);
46
+ url.searchParams.set('utm_source', source);
47
+ url.searchParams.set('utm_medium', 'cli');
48
+ url.searchParams.set('utm_campaign', 'pro_conversion');
49
+ url.searchParams.set('utm_content', content);
50
+ return url.toString();
51
+ } catch (_) {
52
+ return PRO_MONTHLY_PAYMENT_LINK;
53
+ }
54
+ }
55
+
56
+ function pluralize(count, singular, plural = `${singular}s`) {
57
+ return Number(count) === 1 ? singular : plural;
58
+ }
59
+
60
+ function buildCaptureReceipt({ signal, feedbackId, memoryId, actionType } = {}) {
61
+ const normalizedSignal = String(signal || '').toUpperCase() || 'UNKNOWN';
62
+ const lines = [
63
+ '',
64
+ 'Value receipt',
65
+ '─'.repeat(50),
66
+ ` Stored proof : ${normalizedSignal} feedback${feedbackId ? ` (${feedbackId})` : ''}`,
67
+ memoryId ? ` Local memory : ${memoryId}` : ' Local memory : saved locally',
68
+ actionType ? ` Rule pressure : ${actionType}` : ' Rule pressure : available for promotion',
69
+ ' Free today : this proof protects this local machine',
70
+ ' Pro sync : keep this lesson, rule, and dashboard synced across machines and agent runtimes',
71
+ ' Next proof : npx thumbgate stats',
72
+ ' Cost proof : npx thumbgate cost',
73
+ '',
74
+ ` Solo Pro : ${PRO_PRICE_LABEL} for hosted sync, search, dashboard, and exports`,
75
+ ` Upgrade : ${trackedProUrl('cli_capture_receipt', actionType || normalizedSignal.toLowerCase())}`,
76
+ ` Enterprise : ${ENTERPRISE_PRICE_LABEL}; start with one repeated workflow failure`,
77
+ ' https://thumbgate.ai/#workflow-sprint-intake',
78
+ '',
79
+ ];
80
+ return lines.join('\n');
81
+ }
82
+
83
+ function buildStatsReceipt(stats = {}) {
84
+ const negatives = Number(stats.negatives || stats.totalNegative || 0);
85
+ const blocked = Number(stats.gatesBlocked || stats.blocked || 0);
86
+ const warned = Number(stats.gatesWarned || stats.warned || 0);
87
+ const gates = Number(stats.totalGates || 0);
88
+ const autoPromoted = Number(stats.autoPromotedGates || 0);
89
+ const hasFriction = negatives > 0 || blocked > 0 || warned > 0 || gates > 0;
90
+ if (!hasFriction) return '';
91
+
92
+ const interventions = blocked + warned;
93
+ const lines = [
94
+ '',
95
+ 'Paid-intent next step',
96
+ '─'.repeat(50),
97
+ ];
98
+ if (interventions > 0) {
99
+ lines.push(` Proof already seen : ${interventions} gate ${pluralize(interventions, 'intervention')}`);
100
+ }
101
+ if (gates > 0) {
102
+ lines.push(` Active prevention : ${gates} ${pluralize(gates, 'gate')} (${autoPromoted} auto-promoted)`);
103
+ }
104
+ if (negatives > 0) {
105
+ lines.push(` Failure pressure : ${negatives} negative ${pluralize(negatives, 'signal')}`);
106
+ }
107
+ lines.push(' Show the buyer : npx thumbgate cost');
108
+ lines.push(' Pro sync value : keep these lessons/rules visible across laptops, CI, containers, and agent runtimes');
109
+ lines.push(` Solo Pro : ${trackedProUrl('cli_stats_receipt', 'proof_seen')}`);
110
+ lines.push(' Enterprise : https://thumbgate.ai/#workflow-sprint-intake');
111
+ lines.push('');
112
+ return lines.join('\n');
113
+ }
114
+
38
115
  module.exports = {
39
116
  PRO_MONTHLY_PAYMENT_LINK,
40
117
  PRO_ANNUAL_PAYMENT_LINK,
@@ -47,8 +124,11 @@ module.exports = {
47
124
  TEAM_ANNUAL_PRICE_DOLLARS,
48
125
  TEAM_MIN_SEATS,
49
126
  PRO_PRICE_LABEL,
50
- TEAM_PRICE_LABEL,
127
+ ENTERPRISE_PRICE_LABEL,
51
128
  normalizePlanId,
52
129
  normalizeBillingCycle,
53
130
  normalizeSeatCount,
131
+ buildCaptureReceipt,
132
+ buildStatsReceipt,
133
+ trackedProUrl,
54
134
  };