thumbgate 1.5.8 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,350 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * scripts/mailer/resend-mailer.js
5
+ *
6
+ * Resend-backed transactional email sender for ThumbGate.
7
+ *
8
+ * Design goals:
9
+ * - Zero-dep: uses global `fetch` (Node 18+) to hit https://api.resend.com/emails.
10
+ * - Gracefully optional: if RESEND_API_KEY is unset, sends are skipped with a
11
+ * logged warning and the caller receives `{ sent: false, reason: 'no_api_key' }`.
12
+ * This prevents the Stripe webhook from failing when email is not configured.
13
+ * - Testable: accepts an injectable `fetch` implementation via opts.fetchImpl.
14
+ * - CAN-SPAM compliant: every commercial email carries business name, physical
15
+ * address, and a functional unsubscribe method.
16
+ */
17
+
18
+ const PRODUCT_NAME = 'ThumbGate Pro';
19
+ const DASHBOARD_URL = 'https://thumbgate-production.up.railway.app/dashboard';
20
+ const SUPPORT_EMAIL = 'hello@thumbgate.app';
21
+ const DEFAULT_FROM = 'onboarding@resend.dev';
22
+ const DEFAULT_REPLY_TO = 'hello@thumbgate.app';
23
+ const DEFAULT_UNSUBSCRIBE_EMAIL = 'unsubscribe@thumbgate.app';
24
+ const DEFAULT_BUSINESS_NAME = 'Max Smith KDP LLC';
25
+ // CAN-SPAM requires a physical mailing address. Override via THUMBGATE_BUSINESS_ADDRESS.
26
+ const DEFAULT_BUSINESS_ADDRESS = '2261 Market Street #4242, San Francisco, CA 94114';
27
+ // Hosted PNG that email clients (Gmail, Outlook) will proxy. SVG is stripped from most email HTML.
28
+ const BRAND_MARK_URL = 'https://thumbgate-production.up.railway.app/thumbgate-icon.png';
29
+ const RESEND_ENDPOINT = 'https://api.resend.com/emails';
30
+ const TRIAL_LENGTH_DAYS = 7;
31
+
32
+ function getApiKey() {
33
+ return process.env.RESEND_API_KEY || '';
34
+ }
35
+
36
+ function getFromAddress() {
37
+ return process.env.THUMBGATE_TRIAL_EMAIL_FROM || process.env.RESEND_FROM_EMAIL || DEFAULT_FROM;
38
+ }
39
+
40
+ function getReplyTo() {
41
+ return process.env.THUMBGATE_TRIAL_EMAIL_REPLY_TO || DEFAULT_REPLY_TO;
42
+ }
43
+
44
+ function getUnsubscribeEmail() {
45
+ return process.env.THUMBGATE_UNSUBSCRIBE_EMAIL || DEFAULT_UNSUBSCRIBE_EMAIL;
46
+ }
47
+
48
+ function getBusinessName() {
49
+ return process.env.THUMBGATE_BUSINESS_NAME || DEFAULT_BUSINESS_NAME;
50
+ }
51
+
52
+ function getBusinessAddress() {
53
+ return process.env.THUMBGATE_BUSINESS_ADDRESS || DEFAULT_BUSINESS_ADDRESS;
54
+ }
55
+
56
+ function isNonEmptyString(value) {
57
+ return typeof value === 'string' && value.trim().length > 0;
58
+ }
59
+
60
+ /**
61
+ * Low-level send. Posts to the Resend API or no-ops when RESEND_API_KEY is
62
+ * missing. Never throws on network errors; returns a structured result instead.
63
+ */
64
+ async function sendEmail({ to, subject, html, text, from, replyTo, fetchImpl } = {}) {
65
+ if (!isNonEmptyString(to)) throw new Error('sendEmail: `to` is required');
66
+ if (!isNonEmptyString(subject)) throw new Error('sendEmail: `subject` is required');
67
+ if (!isNonEmptyString(html) && !isNonEmptyString(text)) {
68
+ throw new Error('sendEmail: at least one of `html` or `text` is required');
69
+ }
70
+
71
+ const apiKey = getApiKey();
72
+ if (!apiKey) {
73
+ // eslint-disable-next-line no-console
74
+ console.warn('[mailer] RESEND_API_KEY not set — skipping send to', to);
75
+ return { sent: false, reason: 'no_api_key' };
76
+ }
77
+
78
+ const payload = {
79
+ from: from || getFromAddress(),
80
+ to: Array.isArray(to) ? to : [to],
81
+ subject,
82
+ reply_to: replyTo || getReplyTo(),
83
+ };
84
+ if (isNonEmptyString(html)) payload.html = html;
85
+ if (isNonEmptyString(text)) payload.text = text;
86
+
87
+ const fetcher = fetchImpl || globalThis.fetch;
88
+ if (typeof fetcher !== 'function') {
89
+ // eslint-disable-next-line no-console
90
+ console.warn('[mailer] global fetch not available — skipping send');
91
+ return { sent: false, reason: 'no_fetch' };
92
+ }
93
+
94
+ try {
95
+ const res = await fetcher(RESEND_ENDPOINT, {
96
+ method: 'POST',
97
+ headers: {
98
+ 'Content-Type': 'application/json',
99
+ Authorization: `Bearer ${apiKey}`,
100
+ },
101
+ body: JSON.stringify(payload),
102
+ });
103
+
104
+ const bodyText = typeof res.text === 'function' ? await res.text() : '';
105
+ let bodyJson = null;
106
+ if (bodyText) {
107
+ try { bodyJson = JSON.parse(bodyText); } catch (_) { /* leave as text */ }
108
+ }
109
+
110
+ if (!res.ok) {
111
+ // eslint-disable-next-line no-console
112
+ console.warn(`[mailer] Resend returned ${res.status}:`, bodyText);
113
+ return { sent: false, reason: 'api_error', status: res.status, body: bodyJson || bodyText };
114
+ }
115
+
116
+ return { sent: true, id: bodyJson && bodyJson.id ? bodyJson.id : null, status: res.status };
117
+ } catch (err) {
118
+ // eslint-disable-next-line no-console
119
+ console.warn('[mailer] send failed:', err && err.message ? err.message : err);
120
+ return { sent: false, reason: 'exception', error: err && err.message ? err.message : String(err) };
121
+ }
122
+ }
123
+
124
+ function escapeHtml(value) {
125
+ return String(value)
126
+ .replace(/&/g, '&')
127
+ .replace(/</g, '&lt;')
128
+ .replace(/>/g, '&gt;')
129
+ .replace(/"/g, '&quot;')
130
+ .replace(/'/g, '&#39;');
131
+ }
132
+
133
+ function firstName(full) {
134
+ if (!isNonEmptyString(full)) return '';
135
+ const trimmed = full.trim();
136
+ const first = trimmed.split(/\s+/)[0] || '';
137
+ // Never use email-looking strings as a name.
138
+ if (/@/.test(first)) return '';
139
+ return first;
140
+ }
141
+
142
+ function formatTrialEndDate(trialEndAt) {
143
+ let d;
144
+ if (trialEndAt instanceof Date) d = trialEndAt;
145
+ else if (typeof trialEndAt === 'number') d = new Date(trialEndAt);
146
+ else if (typeof trialEndAt === 'string' && trialEndAt) d = new Date(trialEndAt);
147
+ else {
148
+ d = new Date();
149
+ d.setUTCDate(d.getUTCDate() + TRIAL_LENGTH_DAYS);
150
+ }
151
+ if (Number.isNaN(d.getTime())) {
152
+ d = new Date();
153
+ d.setUTCDate(d.getUTCDate() + TRIAL_LENGTH_DAYS);
154
+ }
155
+ // Render as "Apr 24, 2026" (UTC) — avoids locale surprises on server.
156
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
157
+ const month = months[d.getUTCMonth()];
158
+ const day = d.getUTCDate();
159
+ const year = d.getUTCFullYear();
160
+ return `${month} ${day}, ${year}`;
161
+ }
162
+
163
+ function renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialEndAt } = {}) {
164
+ const activationCommand = `npx thumbgate pro --activate --key=${licenseKey}`;
165
+ const name = firstName(customerName);
166
+ const greeting = name ? `Hi ${name},` : 'Hi there,';
167
+ const trialEndLabel = formatTrialEndDate(trialEndAt);
168
+ const headline = 'Your ThumbGate Pro trial is live.';
169
+ const subhead = `You have 7 days of Pro access. Trial ends ${trialEndLabel}.`;
170
+ const description =
171
+ 'ThumbGate turns thumbs up/down feedback into Pre-Action Gates that stop repeated AI coding mistakes ' +
172
+ 'before the next tool call. Lessons stay on your machine. Repeated failures become Reliability Gateway blocks.';
173
+ const exampleFeedback =
174
+ 'thumbs down: the answer skipped exact files and tests; next time include paths, commands, and verification evidence.';
175
+ const proofUrl = 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md';
176
+ const unsubscribeEmail = getUnsubscribeEmail();
177
+ const businessName = getBusinessName();
178
+ const businessAddress = getBusinessAddress();
179
+ const unsubscribeMailto = `mailto:${unsubscribeEmail}?subject=unsubscribe&body=Please%20remove%20me%20from%20ThumbGate%20emails.`;
180
+ const postscript =
181
+ `P.S. The first 10 minutes are the best time to catch your agent's most-repeated failure. ` +
182
+ `Open the dashboard, give one concrete thumbs down on a mistake you've seen twice, and watch ThumbGate build the gate.`;
183
+
184
+ const text = [
185
+ greeting,
186
+ '',
187
+ headline,
188
+ subhead,
189
+ '',
190
+ description,
191
+ '',
192
+ 'Your first 10 minutes',
193
+ '1. Activate Pro locally:',
194
+ ` ${activationCommand}`,
195
+ '',
196
+ `2. Open your dashboard: ${DASHBOARD_URL}`,
197
+ '',
198
+ '3. Give one concrete thumbs up or thumbs down:',
199
+ ` ${exampleFeedback}`,
200
+ '',
201
+ 'Your trial key (save this):',
202
+ ` ${licenseKey}`,
203
+ '',
204
+ `Verification evidence: ${proofUrl}`,
205
+ '',
206
+ postscript,
207
+ '',
208
+ `Questions? Just reply to this email or write ${SUPPORT_EMAIL}.`,
209
+ '',
210
+ '— Igor, founder of ThumbGate',
211
+ '',
212
+ '---',
213
+ `You're getting this because you started a ${PRODUCT_NAME} trial. Don't want these emails? Unsubscribe: ${unsubscribeEmail}`,
214
+ `${businessName} · ${businessAddress}`,
215
+ ].join('\n');
216
+
217
+ const safeKey = escapeHtml(licenseKey);
218
+ const safeCmd = escapeHtml(activationCommand);
219
+ const safeGreeting = escapeHtml(greeting);
220
+ const safeSubhead = escapeHtml(subhead);
221
+ const safeHeadline = escapeHtml(headline);
222
+ const safeDescription = escapeHtml(description);
223
+ const safeExample = escapeHtml(exampleFeedback);
224
+ const safePostscript = escapeHtml(postscript);
225
+ const safeBusinessName = escapeHtml(businessName);
226
+ const safeBusinessAddress = escapeHtml(businessAddress);
227
+ const safeUnsubscribeEmail = escapeHtml(unsubscribeEmail);
228
+ const safeUnsubscribeMailto = escapeHtml(unsubscribeMailto);
229
+ const safeCustomer = customerId ? escapeHtml(customerId) : '';
230
+
231
+ const html = `<!doctype html>
232
+ <html>
233
+ <body style="margin:0;background:#f5f7fb;padding:28px 12px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;color:#17212b;">
234
+ <div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">Activate Pro in one command, open the dashboard, and start blocking repeated AI coding mistakes.</div>
235
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;">
236
+ <tr>
237
+ <td align="center">
238
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;max-width:640px;background:#ffffff;border:1px solid #d8e2ea;border-radius:10px;overflow:hidden;">
239
+ <tr>
240
+ <td style="background:#071115;padding:24px 28px;color:#e7fbff;">
241
+ <table role="presentation" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
242
+ <tr>
243
+ <td style="vertical-align:middle;padding-right:12px;">
244
+ <img src="${BRAND_MARK_URL}" width="40" height="40" alt="ThumbGate" style="display:block;border-radius:8px;">
245
+ </td>
246
+ <td style="vertical-align:middle;">
247
+ <div style="font-size:13px;font-weight:700;letter-spacing:0.02em;text-transform:uppercase;color:#73d4e9;">${escapeHtml(PRODUCT_NAME)}</div>
248
+ </td>
249
+ </tr>
250
+ </table>
251
+ <h1 style="margin:16px 0 8px;font-size:26px;line-height:1.2;color:#ffffff;">${safeHeadline}</h1>
252
+ <p style="margin:0;font-size:14px;line-height:1.5;color:#9cbac4;">${safeSubhead}</p>
253
+ </td>
254
+ </tr>
255
+ <tr>
256
+ <td style="padding:26px 28px 6px;">
257
+ <p style="margin:0 0 12px;font-size:15px;line-height:1.6;color:#17212b;">${safeGreeting}</p>
258
+ <p style="margin:0 0 18px;font-size:15px;line-height:1.6;color:#344451;">${safeDescription}</p>
259
+ <p style="margin:0 0 24px;">
260
+ <a href="${DASHBOARD_URL}" style="display:inline-block;background:#45bfd8;color:#061015;text-decoration:none;font-weight:700;padding:12px 22px;border-radius:6px;font-size:15px;">Open your dashboard</a>
261
+ </p>
262
+ </td>
263
+ </tr>
264
+ <tr>
265
+ <td style="padding:0 28px 10px;">
266
+ <h2 style="margin:0 0 10px;font-size:17px;line-height:1.3;color:#17212b;">Your first 10 minutes</h2>
267
+ <p style="margin:0 0 8px;font-size:14px;line-height:1.55;color:#344451;"><strong>1. Activate Pro locally</strong></p>
268
+ <pre style="margin:0 0 20px;background:#081016;color:#d8f7e4;border:1px solid #23343d;border-radius:6px;padding:14px;font-size:13px;line-height:1.45;white-space:pre-wrap;word-break:break-word;"><code>${safeCmd}</code></pre>
269
+
270
+ <p style="margin:0 0 8px;font-size:14px;line-height:1.55;color:#344451;"><strong>2. Save your trial key</strong></p>
271
+ <pre style="margin:0 0 20px;background:#eef6f7;color:#0b343c;border:1px solid #c7e2e7;border-radius:6px;padding:14px;font-size:13px;line-height:1.45;white-space:pre-wrap;word-break:break-word;"><code>${safeKey}</code></pre>
272
+
273
+ <p style="margin:0 0 8px;font-size:14px;line-height:1.55;color:#344451;"><strong>3. Give one concrete thumbs up or thumbs down</strong></p>
274
+ <p style="margin:0 0 8px;font-size:13px;line-height:1.55;color:#526273;">Start with the failure you most want your agent to stop repeating.</p>
275
+ <pre style="margin:0 0 22px;background:#f1fff2;color:#22602b;border:1px solid #bae7c0;border-radius:6px;padding:14px;font-size:13px;line-height:1.45;white-space:pre-wrap;word-break:break-word;"><code>${safeExample}</code></pre>
276
+ </td>
277
+ </tr>
278
+ <tr>
279
+ <td style="padding:6px 28px 22px;">
280
+ <p style="margin:0 0 14px;font-size:14px;line-height:1.6;color:#344451;font-style:italic;">${safePostscript}</p>
281
+ <p style="margin:0 0 4px;font-size:14px;line-height:1.6;color:#17212b;">— Igor, founder of ThumbGate</p>
282
+ <p style="margin:0;font-size:13px;line-height:1.55;color:#526273;">
283
+ Questions? Just reply to this email or write
284
+ <a href="mailto:${SUPPORT_EMAIL}" style="color:#087a91;">${SUPPORT_EMAIL}</a>.
285
+ </p>
286
+ </td>
287
+ </tr>
288
+ <tr>
289
+ <td style="padding:16px 28px 22px;border-top:1px solid #e2e8ec;background:#fafbfc;">
290
+ <p style="margin:0 0 6px;font-size:12px;line-height:1.5;color:#7a8790;">
291
+ You're getting this one-time email because you started a ${escapeHtml(PRODUCT_NAME)} trial.
292
+ <a href="${safeUnsubscribeMailto}" style="color:#7a8790;text-decoration:underline;">Unsubscribe</a>
293
+ (${safeUnsubscribeEmail}).
294
+ </p>
295
+ <p style="margin:0;font-size:12px;line-height:1.5;color:#7a8790;">
296
+ ${safeBusinessName} &middot; ${safeBusinessAddress}
297
+ </p>
298
+ ${safeCustomer ? `<p style="margin:8px 0 0;font-size:11px;color:#a0abb2;">Customer ID (for support): <code>${safeCustomer}</code></p>` : ''}
299
+ </td>
300
+ </tr>
301
+ </table>
302
+ </td>
303
+ </tr>
304
+ </table>
305
+ </body>
306
+ </html>`;
307
+
308
+ return { html, text, activationCommand, trialEndLabel, greeting, unsubscribeEmail, businessName, businessAddress };
309
+ }
310
+
311
+ /**
312
+ * High-level helper: send the trial / checkout welcome email with the license key.
313
+ *
314
+ * Accepts optional `customerName` (used for greeting) and `trialEndAt`
315
+ * (Date | number | ISO string — used to display the exact expiry date).
316
+ *
317
+ * Never throws on send failures (beyond input validation); the Stripe webhook
318
+ * must keep working even if email breaks.
319
+ */
320
+ async function sendTrialWelcomeEmail({ to, licenseKey, customerId, customerName, trialEndAt, fetchImpl } = {}) {
321
+ if (!isNonEmptyString(to)) throw new Error('sendTrialWelcomeEmail: `to` is required');
322
+ if (!isNonEmptyString(licenseKey)) throw new Error('sendTrialWelcomeEmail: `licenseKey` is required');
323
+
324
+ const { html, text } = renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialEndAt });
325
+ const name = firstName(customerName);
326
+ const subject = name
327
+ ? `${name}, your ThumbGate Pro key is inside`
328
+ : 'Your ThumbGate Pro key is inside';
329
+
330
+ return sendEmail({ to, subject, html, text, replyTo: getReplyTo(), fetchImpl });
331
+ }
332
+
333
+ module.exports = {
334
+ sendEmail,
335
+ sendTrialWelcomeEmail,
336
+ renderTrialWelcomeBodies,
337
+ _constants: {
338
+ PRODUCT_NAME,
339
+ DASHBOARD_URL,
340
+ SUPPORT_EMAIL,
341
+ DEFAULT_FROM,
342
+ DEFAULT_REPLY_TO,
343
+ DEFAULT_UNSUBSCRIBE_EMAIL,
344
+ DEFAULT_BUSINESS_NAME,
345
+ DEFAULT_BUSINESS_ADDRESS,
346
+ BRAND_MARK_URL,
347
+ RESEND_ENDPOINT,
348
+ TRIAL_LENGTH_DAYS,
349
+ },
350
+ };
@@ -106,6 +106,17 @@ function portableMcpEntry(pkgVersion) {
106
106
  };
107
107
  }
108
108
 
109
+ function codexAutoUpdateCliEntry(commandArgs = []) {
110
+ return {
111
+ command: 'sh',
112
+ args: ['-lc', publishedCliShellCommand('latest', commandArgs, { preferInstalled: false })],
113
+ };
114
+ }
115
+
116
+ function codexAutoUpdateMcpEntry() {
117
+ return codexAutoUpdateCliEntry(['serve']);
118
+ }
119
+
109
120
  function localMcpEntry(pkgRoot, scope = 'project') {
110
121
  return {
111
122
  command: 'node',
@@ -201,4 +212,6 @@ module.exports = {
201
212
  resolveLocalServerPath,
202
213
  resolveMcpEntry,
203
214
  resolveStableSourceRoot,
215
+ codexAutoUpdateCliEntry,
216
+ codexAutoUpdateMcpEntry,
204
217
  };
@@ -37,6 +37,14 @@ function publishedCliShellCommand(pkgVersion, commandArgs = [], options = {}) {
37
37
  const escapedArgs = commandArgs.map(shellQuote).join(' ');
38
38
  const fastPath = `[ -x ${shellQuote(runtimeBin)} ] && exec ${shellQuote(runtimeBin)}${escapedArgs ? ` ${escapedArgs}` : ''}`;
39
39
  const installPath = `mkdir -p ${shellQuote(prefixDir)} && exec npm ${publishedCliArgs(pkgVersion, commandArgs, { prefixDir }).map(shellQuote).join(' ')}`;
40
+ if (options.preferInstalled === false) {
41
+ const packageSpec = `thumbgate@${pkgVersion}`;
42
+ return [
43
+ `mkdir -p ${shellQuote(prefixDir)}`,
44
+ `npm "install" "--prefix" ${shellQuote(prefixDir)} "--no-save" "--omit=dev" ${shellQuote(packageSpec)} >/dev/null 2>&1`,
45
+ `exec ${shellQuote(runtimeBin)}${escapedArgs ? ` ${escapedArgs}` : ''}`,
46
+ ].join(' && ');
47
+ }
40
48
  return `${fastPath} || ${installPath}`;
41
49
  }
42
50
 
@@ -61,6 +61,12 @@ const HIGH_ROI_QUERY_SEEDS = [
61
61
  source: 'seed',
62
62
  notes: 'Broader category demand that feeds comparison and guide pages.',
63
63
  },
64
+ {
65
+ query: 'autoresearch agent safety',
66
+ businessValue: 89,
67
+ source: 'seed',
68
+ notes: 'Emerging self-improving agent query where ThumbGate can own the safety and proof-control wedge.',
69
+ },
64
70
  {
65
71
  query: 'stop ai coding agents from repeating mistakes',
66
72
  businessValue: 88,
@@ -93,6 +99,45 @@ const HIGH_ROI_QUERY_SEEDS = [
93
99
  },
94
100
  ];
95
101
 
102
+ function guideBlueprint({
103
+ query,
104
+ path,
105
+ pillar,
106
+ title,
107
+ heroTitle,
108
+ heroSummary,
109
+ takeaways,
110
+ sections,
111
+ faq,
112
+ relatedPaths,
113
+ }) {
114
+ return {
115
+ query,
116
+ path,
117
+ pageType: 'guide',
118
+ pillar,
119
+ title,
120
+ heroTitle,
121
+ heroSummary,
122
+ takeaways,
123
+ sections,
124
+ faq,
125
+ relatedPaths,
126
+ };
127
+ }
128
+
129
+ function paragraphs(heading, entries) {
130
+ return { heading, paragraphs: entries };
131
+ }
132
+
133
+ function bullets(heading, entries) {
134
+ return { heading, bullets: entries };
135
+ }
136
+
137
+ function answer(question, text) {
138
+ return { question, answer: text };
139
+ }
140
+
96
141
  const PAGE_BLUEPRINTS = [
97
142
  {
98
143
  query: 'thumbgate vs speclock',
@@ -484,6 +529,54 @@ const PAGE_BLUEPRINTS = [
484
529
  ],
485
530
  relatedPaths: ['/compare/mem0', '/guides/stop-repeated-ai-agent-mistakes'],
486
531
  },
532
+ guideBlueprint({
533
+ query: 'autoresearch agent safety',
534
+ path: '/guides/autoresearch-agent-safety',
535
+ pillar: 'pre-action-gates',
536
+ title: 'Autoresearch Agent Safety | Gates for Self-Improving Coding Agents',
537
+ heroTitle: 'Autoresearch Agent Safety for Self-Improving Coding Agents',
538
+ heroSummary: 'Autoresearch-style loops can search for better code, but they need gates for holdout tests, proof trails, reward hacking, and unsafe self-improvement.',
539
+ takeaways: [
540
+ 'Self-improving coding loops need a control plane before they promote their own wins.',
541
+ 'ThumbGate turns failed experiment reviews into prevention rules and pre-action gates.',
542
+ 'The sales wedge is concrete: let the agent search, but gate the evidence before it accepts a variant.',
543
+ ],
544
+ sections: [
545
+ paragraphs(
546
+ 'Why Autoresearch creates a new buying moment',
547
+ [
548
+ 'Autoresearch-style systems run experiments, inspect results, and keep the variants that look better. That makes them powerful, but it also creates a trust gap for engineering teams.',
549
+ 'If the loop can edit the benchmark, skip a holdout, hide a failed run, or promote without proof, the buyer needs enforcement before autonomy expands.',
550
+ ],
551
+ ),
552
+ bullets(
553
+ 'Where ThumbGate fits',
554
+ [
555
+ 'Block promotion when required primary and holdout checks are missing.',
556
+ 'Require commands, changed files, logs, and verification evidence before a claimed improvement lands.',
557
+ 'Capture thumbs-down reviews when an experiment cheats the metric, then promote the pattern into a prevention rule.',
558
+ 'Use ContextFS packs and Thompson Sampling so recurring research failures get stricter over time.',
559
+ ],
560
+ ),
561
+ paragraphs(
562
+ 'Starter harnesses that make the value visible',
563
+ [
564
+ 'The first pack should wrap checks buyers already understand: npm test, lint, Playwright duration, bundle size, and CI status. Each one becomes a gate the buyer can see firing.',
565
+ ],
566
+ ),
567
+ ],
568
+ faq: [
569
+ answer(
570
+ 'Why do Autoresearch-style agents need gates?',
571
+ 'A self-improving loop can optimize the wrong signal, skip holdout tests, or promote a cherry-picked run. ThumbGate blocks known-bad promotion patterns before the agent accepts the variant.',
572
+ ),
573
+ answer(
574
+ 'What does ThumbGate add to an Autoresearch loop?',
575
+ 'ThumbGate adds structured thumbs-up/down feedback, prevention rules, Thompson Sampling, ContextFS proof packs, and pre-action gates for risky experiment and promotion steps.',
576
+ ),
577
+ ],
578
+ relatedPaths: ['/guides/pre-action-gates', '/guides/codex-cli-guardrails'],
579
+ }),
487
580
  {
488
581
  query: 'claude desktop extension plugin thumbgate',
489
582
  path: '/guides/claude-desktop',
@@ -651,6 +744,7 @@ function classifyIntent(query) {
651
744
  if (!normalized) return 'informational';
652
745
  if (/\b(vs|versus|alternative|compare|comparison|better than)\b/.test(normalized)) return 'comparison';
653
746
  if (/\b(price|pricing|buy|checkout|purchase|cost)\b/.test(normalized)) return 'transactional';
747
+ if (/\b(autoresearch|self-improving|benchmark|reward hacking|agent safety)\b/.test(normalized)) return 'commercial';
654
748
  if (/\b(claude code|cursor|codex|gemini|amp|opencode|integration|plugin|setup|install)\b/.test(normalized)) {
655
749
  return 'commercial';
656
750
  }
@@ -665,6 +759,7 @@ function inferPillar(query) {
665
759
  const normalized = normalizeText(query).toLowerCase();
666
760
  if (/\b(speclock|mem0|alternative|vs|compare|comparison)\b/.test(normalized)) return 'comparison';
667
761
  if (/\b(thumbs up|thumbs down|feedback|reinforce|mistake)\b/.test(normalized)) return 'feedback-loop';
762
+ if (/\b(autoresearch|self-improving|benchmark|reward hacking)\b/.test(normalized)) return 'pre-action-gates';
668
763
  if (/\b(pre-action gates|guardrails|block|prevent repeated mistakes|repeating mistakes)\b/.test(normalized)) return 'pre-action-gates';
669
764
  if (/\b(claude code|cursor|codex|gemini|amp|opencode|integration|plugin)\b/.test(normalized)) return 'agent-workflows';
670
765
  return 'ai-agent-reliability';
@@ -676,6 +771,7 @@ function inferPersona(query) {
676
771
  if (normalized.includes('cursor')) return 'cursor-builder';
677
772
  if (normalized.includes('codex')) return 'codex-builder';
678
773
  if (normalized.includes('gemini')) return 'gemini-builder';
774
+ if (normalized.includes('autoresearch') || normalized.includes('self-improving')) return 'ai-research-engineer';
679
775
  if (/\b(vs|alternative|compare)\b/.test(normalized)) return 'tool-evaluator';
680
776
  if (/\b(guardrails|pre-action gates)\b/.test(normalized)) return 'engineering-lead';
681
777
  return 'ai-engineer';
@@ -841,8 +937,8 @@ function createPageSpec(blueprint, row) {
841
937
  faq: blueprint.faq,
842
938
  relatedPages,
843
939
  cta: {
844
- label: 'See ThumbGate Pro',
845
- href: `/pro?utm_source=website&utm_medium=seo_page&utm_campaign=${blueprint.path.split('/').filter(Boolean).join('_')}`,
940
+ label: 'Go Pro — $19/mo',
941
+ href: `/checkout/pro?utm_source=website&utm_medium=seo_page&utm_campaign=${blueprint.path.split('/').filter(Boolean).join('_')}&cta_placement=seo_brief&plan_id=pro`,
846
942
  },
847
943
  proofLinks: [
848
944
  { label: 'Verification evidence', href: PRODUCT.verificationUrl },
@@ -1043,6 +1139,9 @@ function renderSeoPageHtml(page, runtimeConfig = {}) {
1043
1139
  <meta property="og:type" content="article" />
1044
1140
  <meta property="og:url" content="${escapeHtml(canonicalUrl)}" />
1045
1141
  <link rel="canonical" href="${escapeHtml(canonicalUrl)}" />
1142
+ <link rel="icon" type="image/svg+xml" href="/thumbgate-icon.png" />
1143
+ <link rel="apple-touch-icon" href="/assets/brand/thumbgate-mark.svg" />
1144
+ <meta property="og:image" content="/og.png" />
1046
1145
  <style>
1047
1146
  :root {
1048
1147
  --bg: #0a0a0b;
@@ -1084,7 +1183,12 @@ function renderSeoPageHtml(page, runtimeConfig = {}) {
1084
1183
  .brand {
1085
1184
  font-weight: 700;
1086
1185
  color: var(--text);
1186
+ display: inline-flex;
1187
+ align-items: center;
1188
+ gap: 8px;
1189
+ text-decoration: none;
1087
1190
  }
1191
+ .brand .logo-mark { width: 28px; height: 28px; display: block; }
1088
1192
  .hero { padding: 72px 0 32px; }
1089
1193
  .eyebrow {
1090
1194
  display: inline-flex;
@@ -1163,8 +1267,16 @@ function renderSeoPageHtml(page, runtimeConfig = {}) {
1163
1267
  }
1164
1268
  .sidebar-card {
1165
1269
  padding: 20px;
1270
+ }
1271
+ /* Only the first sidebar card sticks. Stacking multiple stickies at the
1272
+ same top offset makes them overlap each other on scroll. The related-
1273
+ pages card flows normally below. */
1274
+ .sidebar-card:first-child {
1166
1275
  position: sticky;
1167
1276
  top: 84px;
1277
+ max-height: calc(100vh - 104px);
1278
+ overflow-y: auto;
1279
+ -webkit-overflow-scrolling: touch;
1168
1280
  }
1169
1281
  .proof-links {
1170
1282
  display: flex;
@@ -1216,8 +1328,10 @@ function renderSeoPageHtml(page, runtimeConfig = {}) {
1216
1328
  .grid {
1217
1329
  grid-template-columns: 1fr;
1218
1330
  }
1219
- .sidebar-card {
1331
+ .sidebar-card:first-child {
1220
1332
  position: static;
1333
+ max-height: none;
1334
+ overflow: visible;
1221
1335
  }
1222
1336
  }
1223
1337
  </style>
@@ -1229,7 +1343,7 @@ ${renderWebPageJsonLd(page, { appOrigin })}
1229
1343
  <body>
1230
1344
  <div class="topbar">
1231
1345
  <div class="container">
1232
- <a class="brand" href="/">👍👎 ThumbGate</a>
1346
+ <a class="brand" href="/"><img src="/assets/brand/thumbgate-mark-inline.svg" alt="ThumbGate" class="logo-mark" width="28" height="28"><span class="logo-text">ThumbGate</span></a>
1233
1347
  <a href="${escapeHtml(PRODUCT.verificationUrl)}" target="_blank" rel="noopener">Verification evidence</a>
1234
1348
  </div>
1235
1349
  </div>
@@ -151,6 +151,14 @@ esac
151
151
  osc_link() {
152
152
  local url="$1"
153
153
  local label="$2"
154
+ # THUMBGATE_STATUSLINE_PLAIN=1 suppresses OSC 8 hyperlinks. Consumers that embed
155
+ # ThumbGate as a non-last row in a multi-line statusline should set this, because
156
+ # some agents (Claude Code) silently drop downstream rows when a preceding row
157
+ # contains OSC 8 sequences.
158
+ if [ "${THUMBGATE_STATUSLINE_PLAIN:-0}" = "1" ]; then
159
+ printf '%s' "$label"
160
+ return 0
161
+ fi
154
162
  case "$url" in
155
163
  "") printf '%s' "$label" ;;
156
164
  *) printf '\033]8;;%s\007%s\033]8;;\007' "$url" "$label" ;;
@@ -8,6 +8,7 @@ const {
8
8
  writeModelFitReport,
9
9
  resolveFeedbackDir,
10
10
  } = require('./local-model-profile');
11
+ const { runStep } = require('./durability/step');
11
12
 
12
13
  const DEFAULT_FEEDBACK_DIR = resolveFeedbackDir();
13
14
  const DEFAULT_LANCE_DIR = path.join(DEFAULT_FEEDBACK_DIR, 'lancedb');
@@ -125,6 +126,9 @@ async function upsertFeedback(feedbackEvent) {
125
126
  feedbackEvent.whatWorked || '',
126
127
  ].filter(Boolean).join('. ');
127
128
 
129
+ // Embed is pure CPU/model work (transformers.js or stub) — deterministic
130
+ // for a given input, so no retry is needed here. Retry wraps the table
131
+ // write below, which is the actual I/O failure surface.
128
132
  const vector = await embed(textForEmbedding);
129
133
 
130
134
  const record = {
@@ -137,13 +141,23 @@ async function upsertFeedback(feedbackEvent) {
137
141
  context: feedbackEvent.context || '',
138
142
  };
139
143
 
140
- const tableNames = await db.tableNames();
141
- if (tableNames.includes(TABLE_NAME)) {
142
- const table = await db.openTable(TABLE_NAME);
143
- await table.add([record]);
144
- } else {
145
- await db.createTable(TABLE_NAME, [record]);
146
- }
144
+ // Wrap the actual LanceDB write with retry. LanceDB is local-disk in our
145
+ // deployment but can fail on transient fs contention (EBUSY on Windows,
146
+ // lock timeouts on WSL, disk-full edge cases). `feedbackEvent.id` already
147
+ // acts as a stable row identity — re-running this step with the same
148
+ // event produces the same row, so retries are safe.
149
+ await runStep('vector-store.upsertFeedback', {
150
+ retries: 2,
151
+ logger: (msg) => console.warn(msg),
152
+ }, async () => {
153
+ const tableNames = await db.tableNames();
154
+ if (tableNames.includes(TABLE_NAME)) {
155
+ const table = await db.openTable(TABLE_NAME);
156
+ await table.add([record]);
157
+ } else {
158
+ await db.createTable(TABLE_NAME, [record]);
159
+ }
160
+ });
147
161
  }
148
162
 
149
163
  async function searchSimilar(queryText, limit = 5) {