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.
- package/.claude-plugin/marketplace.json +6 -6
- package/.claude-plugin/plugin.json +4 -3
- package/.well-known/agentic-verify.txt +1 -0
- package/.well-known/llms.txt +33 -12
- package/.well-known/mcp/server-card.json +8 -8
- package/README.md +249 -30
- package/adapters/chatgpt/openapi.yaml +12 -0
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +2 -2
- package/adapters/gemini/function-declarations.json +1 -0
- package/adapters/mcp/server-stdio.js +263 -11
- package/adapters/opencode/opencode.json +1 -1
- package/bench/thumbgate-bench.json +2 -2
- package/bin/cli.js +1429 -121
- package/bin/postinstall.js +1 -8
- package/config/gate-classifier-routing.json +98 -0
- package/config/gate-templates.json +216 -0
- package/config/gates/claim-verification.json +12 -0
- package/config/gates/default.json +31 -2
- package/config/github-about.json +2 -2
- package/config/mcp-allowlists.json +23 -13
- package/config/merge-quality-checks.json +0 -1
- package/config/model-candidates.json +121 -6
- package/config/post-deploy-marketing-pages.json +80 -0
- package/config/tessl-tiles.json +1 -3
- package/openapi/openapi.yaml +12 -0
- package/package.json +1 -1
- package/public/blog.html +4 -4
- package/public/codex-plugin.html +72 -20
- package/public/compare.html +31 -8
- package/public/dashboard.html +930 -166
- package/public/federal.html +2 -2
- package/public/guide.html +33 -13
- package/public/index.html +469 -111
- package/public/learn.html +183 -18
- package/public/lessons.html +168 -10
- package/public/numbers.html +7 -7
- package/public/pro.html +34 -11
- package/scripts/agent-memory-lifecycle.js +211 -0
- package/scripts/agent-readiness.js +20 -3
- package/scripts/agent-reward-model.js +53 -1
- package/scripts/auto-promote-gates.js +82 -10
- package/scripts/auto-wire-hooks.js +14 -0
- package/scripts/billing.js +93 -1
- package/scripts/bot-detection.js +61 -3
- package/scripts/build-metadata.js +50 -10
- package/scripts/cli-feedback.js +4 -2
- package/scripts/cli-schema.js +97 -0
- package/scripts/cli-telemetry.js +6 -1
- package/scripts/commercial-offer.js +82 -2
- package/scripts/context-manager.js +74 -6
- package/scripts/dashboard.js +68 -2
- package/scripts/export-databricks-bundle.js +5 -1
- package/scripts/export-dpo-pairs.js +7 -2
- package/scripts/feedback-loop.js +123 -1
- package/scripts/feedback-quality.js +87 -0
- package/scripts/filesystem-search.js +35 -10
- package/scripts/gate-stats.js +89 -0
- package/scripts/gates-engine.js +1176 -85
- package/scripts/gemini-embedding-policy.js +2 -1
- package/scripts/hook-runtime.js +20 -14
- package/scripts/hook-thumbgate-cache-updater.js +18 -2
- package/scripts/hybrid-feedback-context.js +142 -7
- package/scripts/lesson-inference.js +8 -3
- package/scripts/lesson-search.js +17 -1
- package/scripts/license.js +10 -10
- package/scripts/llm-client.js +169 -4
- package/scripts/local-model-profile.js +15 -8
- package/scripts/mcp-config.js +7 -1
- package/scripts/memory-scope-readiness.js +159 -0
- package/scripts/meta-agent-loop.js +36 -0
- package/scripts/operational-integrity.js +39 -5
- package/scripts/oss-pr-opportunity-scout.js +35 -5
- package/scripts/plausible-server-events.js +9 -6
- package/scripts/pro-local-dashboard.js +4 -4
- package/scripts/proxy-pointer-rag-guardrails.js +42 -1
- package/scripts/published-cli.js +0 -8
- package/scripts/rate-limiter.js +64 -13
- package/scripts/secret-scanner.js +44 -5
- package/scripts/security-scanner.js +260 -10
- package/scripts/self-distill-agent.js +3 -1
- package/scripts/seo-gsd.js +916 -7
- package/scripts/statusline-cache-path.js +17 -2
- package/scripts/statusline-local-stats.js +9 -1
- package/scripts/statusline-meta.js +28 -2
- package/scripts/statusline.sh +20 -4
- package/scripts/telemetry-analytics.js +357 -0
- package/scripts/thompson-sampling.js +31 -10
- package/scripts/thumbgate-bench.js +16 -1
- package/scripts/thumbgate-search.js +85 -19
- package/scripts/tool-registry.js +169 -1
- package/scripts/vector-store.js +45 -0
- package/scripts/workflow-sentinel.js +286 -53
- package/scripts/workspace-evolver.js +62 -2
- package/src/api/server.js +2683 -319
- package/scripts/bot-detector.js +0 -50
package/scripts/billing.js
CHANGED
|
@@ -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
|
|
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,
|
package/scripts/bot-detection.js
CHANGED
|
@@ -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
|
-
|
|
44
|
+
/chatgpt/i,
|
|
42
45
|
/\boai-searchbot\b/i,
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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:
|
|
30
|
-
generatedAt: envGeneratedAt,
|
|
52
|
+
buildSha: fileBuildSha,
|
|
53
|
+
generatedAt: fileGeneratedAt || envGeneratedAt,
|
|
31
54
|
};
|
|
32
55
|
}
|
|
33
56
|
|
|
34
|
-
|
|
35
|
-
|
|
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:
|
|
39
|
-
generatedAt:
|
|
66
|
+
buildSha: railwayGitSha,
|
|
67
|
+
generatedAt: envGeneratedAt,
|
|
40
68
|
};
|
|
41
|
-
}
|
|
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:
|
|
45
|
-
generatedAt:
|
|
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,
|
package/scripts/cli-feedback.js
CHANGED
|
@@ -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}`);
|
package/scripts/cli-schema.js
CHANGED
|
@@ -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
|
/**
|
package/scripts/cli-telemetry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
127
|
+
ENTERPRISE_PRICE_LABEL,
|
|
51
128
|
normalizePlanId,
|
|
52
129
|
normalizeBillingCycle,
|
|
53
130
|
normalizeSeatCount,
|
|
131
|
+
buildCaptureReceipt,
|
|
132
|
+
buildStatsReceipt,
|
|
133
|
+
trackedProUrl,
|
|
54
134
|
};
|