thumbgate 1.16.21 → 1.17.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.
@@ -6,9 +6,9 @@
6
6
  <meta name="generator" content="ThumbGate">
7
7
  <meta name="author" content="Igor Ganapolsky">
8
8
  <title>ThumbGate — The Numbers | First-Party Data Snapshot</title>
9
- <meta name="description" content="ThumbGate's generated first-party operational snapshot: active pre-action checks, AI agent actions blocked, estimated LLM tokens and dollars saved, and the Bayes error rate of the intervention scorer.">
9
+ <meta name="description" content="ThumbGate's generated first-party operational snapshot: configured pre-action checks, recorded block/warn events, estimated LLM token savings when events exist, and scorer calibration when the sample supports it.">
10
10
  <meta property="og:title" content="ThumbGate — The Numbers">
11
- <meta property="og:description" content="Generated first-party operational metrics: gates, blocks, token savings, and scorer calibration.">
11
+ <meta property="og:description" content="Generated first-party operational snapshot: configured gates, recorded interventions, and explicit zero-evidence caveats.">
12
12
  <meta property="og:type" content="website">
13
13
  <meta property="og:url" content="https://thumbgate-production.up.railway.app/numbers">
14
14
  <meta name="twitter:card" content="summary_large_image">
@@ -25,9 +25,9 @@
25
25
  "alternateName": "thumbgate",
26
26
  "applicationCategory": "DeveloperApplication",
27
27
  "operatingSystem": "Cross-platform, Node.js >=18.18.0",
28
- "softwareVersion": "1.16.21",
28
+ "softwareVersion": "1.17.0",
29
29
  "url": "https://thumbgate-production.up.railway.app/numbers",
30
- "dateModified": "2026-05-06",
30
+ "dateModified": "2026-05-07",
31
31
  "creator": {
32
32
  "@type": "Person",
33
33
  "name": "Igor Ganapolsky",
@@ -44,8 +44,8 @@
44
44
  {
45
45
  "@context": "https://schema.org",
46
46
  "@type": "Dataset",
47
- "name": "ThumbGate Live Operational Metrics",
48
- "description": "First-party operational metrics from the ThumbGate pre-action check runtime: active checks, blocked AI agent actions, estimated token savings, and Bayes error rate of the intervention scorer.",
47
+ "name": "ThumbGate Operational Snapshot",
48
+ "description": "First-party operational snapshot from the ThumbGate pre-action check runtime: configured checks, recorded block/warn events, estimated token savings from recorded blocks, and Bayes error rate when the sample supports it.",
49
49
  "url": "https://thumbgate-production.up.railway.app/numbers",
50
50
  "license": "https://opensource.org/licenses/MIT",
51
51
  "creator": {
@@ -57,8 +57,8 @@
57
57
  "https://www.linkedin.com/in/igorganapolsky"
58
58
  ]
59
59
  },
60
- "dateModified": "2026-05-06",
61
- "datePublished": "2026-05-06",
60
+ "dateModified": "2026-05-07",
61
+ "datePublished": "2026-05-07",
62
62
  "keywords": [
63
63
  "AI agent gates",
64
64
  "LLM token savings",
@@ -69,7 +69,7 @@
69
69
  "variableMeasured": [
70
70
  {
71
71
  "@type": "PropertyValue",
72
- "name": "active_gates",
72
+ "name": "configured_gates",
73
73
  "value": 36
74
74
  },
75
75
  {
@@ -119,6 +119,8 @@
119
119
  --cyan: #22d3ee;
120
120
  --green: #34d399;
121
121
  --amber: #fbbf24;
122
+ --amber-soft: rgba(251, 191, 36, 0.1);
123
+ --amber-line: rgba(251, 191, 36, 0.34);
122
124
  }
123
125
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
124
126
  nav { padding: 1rem 2rem; border-bottom: 1px solid var(--border); display: flex; gap: 1.5rem; align-items: center; }
@@ -140,6 +142,16 @@
140
142
  font-weight: 600;
141
143
  margin-bottom: 2.5rem;
142
144
  }
145
+ .truth-note {
146
+ background: var(--amber-soft);
147
+ border: 1px solid var(--amber-line);
148
+ border-radius: 12px;
149
+ color: #fde68a;
150
+ padding: 16px 18px;
151
+ margin: 0 0 2.5rem;
152
+ font-size: 0.95rem;
153
+ }
154
+ .truth-note strong { color: #fff3bd; }
143
155
  .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 18px; margin: 1.5rem 0; }
144
156
  .stat-card {
145
157
  background: var(--bg-card);
@@ -189,33 +201,34 @@
189
201
 
190
202
  <main class="container">
191
203
  <h1>The Numbers</h1>
192
- <p class="subtitle">Generated first-party operational data from the ThumbGate runtime. No surveys or projections — this page is a release-time snapshot produced by the same local scripts that power the CLI and dashboard.</p>
193
- <div class="freshness">Updated: 2026-05-06 · Version 1.16.21</div>
204
+ <p class="subtitle">Generated first-party operational snapshot from the ThumbGate runtime. This is not customer traction, install volume, revenue, or proof that a configured gate has fired.</p>
205
+ <div class="freshness">Updated: 2026-05-07 · Version 1.17.0</div>
206
+ <div class="truth-note"><strong>Read this first:</strong> configured checks are inventory. Recorded blocks and warnings are usage evidence. This snapshot currently reports 0 recorded hard-block event(s) and 0 recorded warning event(s).</div>
194
207
 
195
208
  <h2>Gate enforcement</h2>
196
209
  <div class="stats-grid">
197
210
  <div class="stat-card">
198
- <div class="stat-label">Active gates</div>
211
+ <div class="stat-label">Configured checks</div>
199
212
  <div class="stat-value">36</div>
200
- <div class="stat-sub">36 manual · 0 auto-promoted</div>
213
+ <div class="stat-sub">36 shipped defaults · 0 auto-promoted; inventory, not usage</div>
201
214
  <a class="stat-source" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/scripts/gate-stats.js">source: gate-stats.js</a>
202
215
  </div>
203
216
  <div class="stat-card">
204
217
  <div class="stat-label">Actions blocked</div>
205
218
  <div class="stat-value">0</div>
206
- <div class="stat-sub">repeat AI mistakes prevented at the gate</div>
219
+ <div class="stat-sub">no recorded hard-block events in this snapshot</div>
207
220
  <a class="stat-source" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/scripts/gate-stats.js">source: gate-stats.js</a>
208
221
  </div>
209
222
  <div class="stat-card">
210
223
  <div class="stat-label">Actions warned</div>
211
224
  <div class="stat-value">0</div>
212
- <div class="stat-sub">soft interventions; not blocks</div>
225
+ <div class="stat-sub">no recorded soft-warning events in this snapshot</div>
213
226
  <a class="stat-source" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/scripts/gate-stats.js">source: gate-stats.js</a>
214
227
  </div>
215
228
  <div class="stat-card">
216
- <div class="stat-label">Top blocked gate</div>
217
- <div class="stat-value" style="font-size:1.1rem;">local-only-git-writes (0 blocks)</div>
218
- <div class="stat-sub">highest-occurrence prevention rule</div>
229
+ <div class="stat-label">Top recorded blocker</div>
230
+ <div class="stat-value" style="font-size:1.1rem;">No recorded blocker yet</div>
231
+ <div class="stat-sub">top blocker appears only after at least one recorded block</div>
219
232
  <a class="stat-source" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/scripts/gate-stats.js">source: gate-stats.js</a>
220
233
  </div>
221
234
  </div>
@@ -225,25 +238,25 @@
225
238
  <div class="stat-card">
226
239
  <div class="stat-label">Estimated hours saved</div>
227
240
  <div class="stat-value">0.0</div>
228
- <div class="stat-sub">~15 min per blocked mistake × blocks+warns</div>
241
+ <div class="stat-sub">0 because no block/warn events are recorded</div>
229
242
  <a class="stat-source" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/scripts/gate-stats.js">source: gate-stats.js</a>
230
243
  </div>
231
244
  <div class="stat-card">
232
245
  <div class="stat-label">Estimated LLM dollars saved</div>
233
246
  <div class="stat-value">$0.00</div>
234
- <div class="stat-sub">blended Sonnet/Opus/Haiku 80/15/5 mix</div>
247
+ <div class="stat-sub">0 because no recorded block events feed this estimate</div>
235
248
  <a class="stat-source" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/scripts/token-savings.js">source: token-savings.js</a>
236
249
  </div>
237
250
  <div class="stat-card">
238
251
  <div class="stat-label">Tokens not spent</div>
239
252
  <div class="stat-value">0</div>
240
- <div class="stat-sub">2,000 input + 600 output per block, conservative</div>
253
+ <div class="stat-sub">0 because no recorded block events feed this estimate</div>
241
254
  <a class="stat-source" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/scripts/token-savings.js">source: token-savings.js</a>
242
255
  </div>
243
256
  <div class="stat-card">
244
257
  <div class="stat-label">Scorer Bayes error</div>
245
- <div class="stat-value">n/a (no feedback sequences recorded yet)</div>
246
- <div class="stat-sub">irreducible error given current feature set</div>
258
+ <div class="stat-value">n/a</div>
259
+ <div class="stat-sub">needs both safe and harmful feedback sequences</div>
247
260
  <a class="stat-source" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/scripts/bayes-optimal-gate.js">source: bayes-optimal-gate.js</a>
248
261
  </div>
249
262
  </div>
@@ -252,11 +265,11 @@
252
265
  <div class="method">
253
266
  <p><strong>Where the numbers come from.</strong> This page is regenerated from local scripts — no survey data, no hand-edited figures, no third-party attribution. Every number on this page is produced by code in the public <a href="https://github.com/IgorGanapolsky/ThumbGate">ThumbGate repo</a>.</p>
254
267
  <ul>
255
- <li><strong>Active checks</strong> — union of shipped default rules and the auto-promotion ledger (auto).</li>
256
- <li><strong>Actions blocked/warned</strong> — sum of <code>occurrences</code> across gates with the corresponding action.</li>
257
- <li><strong>Hours saved</strong> — conservative 15-minute/incident estimate for debugging a repeated AI mistake × (blocks + warns).</li>
258
- <li><strong>Dollars saved</strong> — blended per-call token estimate (2k input + 600 output) × blocks × 2026-04-15 Anthropic + OpenAI list prices. See <code>scripts/token-savings.js</code> for the full price snapshot.</li>
259
- <li><strong>Bayes error rate</strong> — irreducible classifier error of the current risk scorer given its feature set. High values mean "add features, don't tune thresholds."</li>
268
+ <li><strong>Configured checks</strong> — union of shipped default rules and the auto-promotion ledger. This is inventory, not proof that the check fired.</li>
269
+ <li><strong>Actions blocked/warned</strong> — recorded intervention counts from gate occurrence data. These are the evidence counters.</li>
270
+ <li><strong>Hours saved</strong> — conservative 15-minute/incident estimate, shown only as a function of recorded block/warn counts.</li>
271
+ <li><strong>Dollars saved</strong> — blended per-call token estimate (2k input + 600 output) × recorded blocks × 2026-04-15 Anthropic + OpenAI list prices. See <code>scripts/token-savings.js</code> for the full price snapshot.</li>
272
+ <li><strong>Bayes error rate</strong> — shown only when the feedback sample contains both safe and harmful outcomes. One-class samples are reported as n/a.</li>
260
273
  </ul>
261
274
  <p style="margin-top:12px;">Last auto-promotion: none. Regenerated on every release via <code>npm run numbers:generate</code> and on a weekly cadence.</p>
262
275
  </div>
@@ -264,7 +277,7 @@
264
277
  <div class="cta">
265
278
  <a href="https://www.npmjs.com/package/thumbgate">Install ThumbGate — npx thumbgate init</a>
266
279
  <div class="footer-note">Prefer the raw feed? See <a href="https://github.com/IgorGanapolsky/ThumbGate">GitHub</a> or run <code>npm run gate:stats</code> locally.</div>
267
- <div class="footer-note">Generated at 2026-05-06T21:53:14.685Z UTC.</div>
280
+ <div class="footer-note">Generated at 2026-05-07T14:59:51.816Z UTC.</div>
268
281
  </div>
269
282
  </main>
270
283
  </body>
@@ -1149,7 +1149,8 @@ function loadResolvedRevenueEvents(options = {}) {
1149
1149
  const derived = deriveRevenueEventFromPaidProviderEvent(entry);
1150
1150
  if (!derived) continue;
1151
1151
  if (hasRevenueEventMatch(resolved, derived)) continue;
1152
- resolved.push(derived);
1152
+ const priced = resolveGithubMarketplaceRevenueEntry(derived, { annotate: false }).entry;
1153
+ resolved.push(priced);
1153
1154
  }
1154
1155
 
1155
1156
  return mergeRevenueEvents(resolved, extraRevenueEvents);
@@ -1161,6 +1162,9 @@ function repairGithubMarketplaceRevenueLedger(options = {}) {
1161
1162
  const rows = loadJsonlFile(ledgerPath);
1162
1163
  const resolvedAt = new Date().toISOString();
1163
1164
  const repairs = [];
1165
+
1166
+ // Pass 1: in-place repair of rows already in revenue-events.jsonl that have
1167
+ // unknown amounts but resolvable plan metadata.
1164
1168
  const updatedRows = rows.map((entry) => {
1165
1169
  const result = resolveGithubMarketplaceRevenueEntry(entry, {
1166
1170
  annotate: true,
@@ -1178,21 +1182,76 @@ function repairGithubMarketplaceRevenueLedger(options = {}) {
1178
1182
  currency: normalizeCurrency(result.entry.currency),
1179
1183
  recurringInterval: normalizeText(result.entry.recurringInterval),
1180
1184
  pricingSource: normalizeText(metadata.githubMarketplaceAmountSource),
1185
+ source: 'in_place',
1181
1186
  });
1182
1187
  return result.entry;
1183
1188
  });
1184
1189
 
1190
+ // Pass 2: append rows for funnel-derived paid github_marketplace events that
1191
+ // never landed in the revenue ledger. The webhook handler at handleGithubWebhook
1192
+ // skips appendRevenueEvent when hasRevenueEventMatch is true, so duplicates from
1193
+ // a re-run are already prevented; the only way an order gets here is if the
1194
+ // revenue write was skipped at webhook time (e.g. funnel pre-existed, planPricing
1195
+ // had unknown amount at the time, or the row was created via a different path).
1196
+ const funnelRecords = loadFunnelLedger().filter(
1197
+ (e) =>
1198
+ e &&
1199
+ e.stage === 'paid' &&
1200
+ normalizeText((e.metadata && e.metadata.provider) || e.provider) === 'github_marketplace'
1201
+ );
1202
+
1203
+ for (const funnelEntry of funnelRecords) {
1204
+ const derived = deriveRevenueEventFromPaidProviderEvent(funnelEntry);
1205
+ if (!derived) continue;
1206
+ if (hasRevenueEventMatch(updatedRows, derived)) continue;
1207
+
1208
+ const resolved = resolveGithubMarketplaceRevenueEntry(derived, {
1209
+ annotate: true,
1210
+ resolvedAt,
1211
+ });
1212
+ // Skip funnel-derived rows that still have unknown amounts after resolving —
1213
+ // we don't want to permanently bake amountKnown:false rows when there's no
1214
+ // pricing data to attach. Better to leave them off-disk and keep the
1215
+ // read-time merge in loadResolvedRevenueEvents covering them.
1216
+ if (!resolved.changed || !resolved.entry.amountKnown) continue;
1217
+
1218
+ const persisted = {
1219
+ ...resolved.entry,
1220
+ metadata: {
1221
+ ...sanitizeMetadata(resolved.entry.metadata),
1222
+ recoveredFromFunnelLedger: true,
1223
+ funnelRecordedAt: normalizeText(funnelEntry.timestamp) || null,
1224
+ },
1225
+ };
1226
+ updatedRows.push(persisted);
1227
+
1228
+ const metadata = sanitizeMetadata(persisted.metadata);
1229
+ repairs.push({
1230
+ orderId: normalizeText(persisted.orderId),
1231
+ customerId: normalizeText(persisted.customerId),
1232
+ planId: normalizeText(metadata.planId ?? persisted.planId),
1233
+ amountCents: normalizeInteger(persisted.amountCents),
1234
+ currency: normalizeCurrency(persisted.currency),
1235
+ recurringInterval: normalizeText(persisted.recurringInterval),
1236
+ pricingSource: normalizeText(metadata.githubMarketplaceAmountSource),
1237
+ source: 'funnel_derived',
1238
+ });
1239
+ }
1240
+
1185
1241
  const writeResult = write && repairs.length > 0
1186
1242
  ? writeJsonlRecords(ledgerPath, updatedRows)
1187
- : { written: false, rowCount: rows.length };
1243
+ : { written: false, rowCount: updatedRows.length };
1188
1244
 
1189
1245
  return {
1190
1246
  ledgerPath,
1191
1247
  write,
1192
1248
  wrote: Boolean(writeResult.written),
1193
1249
  scanned: rows.length,
1250
+ funnelScanned: funnelRecords.length,
1194
1251
  repaired: repairs.length,
1195
- unchanged: rows.length - repairs.length,
1252
+ repairedInPlace: repairs.filter((r) => r.source === 'in_place').length,
1253
+ repairedFromFunnel: repairs.filter((r) => r.source === 'funnel_derived').length,
1254
+ unchanged: rows.length - repairs.filter((r) => r.source === 'in_place').length,
1196
1255
  repairs,
1197
1256
  writeResult,
1198
1257
  };
@@ -43,10 +43,12 @@ function calculateStats() {
43
43
  .filter((g) => g.action === 'warn')
44
44
  .reduce((sum, g) => sum + (g.occurrences || 0), 0);
45
45
 
46
- // Top blocked gate
46
+ // Top blocked gate. A configured block rule with zero occurrences is not a
47
+ // "top blocker"; only recorded block events should appear here.
47
48
  const topBlocked = [...allGates]
49
+ .filter((g) => g.action === 'block' && Number(g.occurrences || 0) > 0)
48
50
  .sort((a, b) => (b.occurrences || 0) - (a.occurrences || 0))
49
- .find((g) => g.action === 'block') || null;
51
+ .at(0) || null;
50
52
 
51
53
  // Last promotion event
52
54
  const lastPromotion = promotionLog.length > 0
@@ -58,11 +60,9 @@ function calculateStats() {
58
60
  const estimatedHoursSaved = (estimatedMinutesSaved / 60).toFixed(1);
59
61
 
60
62
  // Bayes error rate: irreducible error floor of the current scorer given its
61
- // feature set (tag signatures). If this is near zero, the scorer is already
62
- // close to optimal threshold tuning won't help, and new features are the
63
- // only lever. If this is high, the feature set can't discriminate the signal
64
- // and we should add features (file path, recency, commit context) rather
65
- // than tune thresholds. Null when no feedback sequences have been recorded.
63
+ // feature set (tag signatures). It is only meaningful when the local sample
64
+ // contains both harmful and safe outcomes; an all-negative or all-positive
65
+ // sample can produce a misleading 0.0% floor.
66
66
  const bayesErrorRate = tryComputeBayesErrorRate();
67
67
 
68
68
  return {
@@ -90,12 +90,33 @@ function tryComputeBayesErrorRate() {
90
90
  .filter(Boolean)
91
91
  .map((line) => { try { return JSON.parse(line); } catch { return null; } })
92
92
  .filter(Boolean);
93
+ if (!hasMixedOutcomeClasses(rows)) return null;
93
94
  return computeBayesErrorRate(rows);
94
95
  } catch {
95
96
  return null;
96
97
  }
97
98
  }
98
99
 
100
+ function hasMixedOutcomeClasses(rows) {
101
+ if (!Array.isArray(rows) || rows.length < 2) return false;
102
+ let harmful = 0;
103
+ let safe = 0;
104
+ for (const row of rows) {
105
+ if (sequenceIsHarmful(row)) harmful += 1;
106
+ else safe += 1;
107
+ if (harmful > 0 && safe > 0) return true;
108
+ }
109
+ return false;
110
+ }
111
+
112
+ function sequenceIsHarmful(row) {
113
+ if (!row || typeof row !== 'object') return false;
114
+ if (typeof row.targetRisk === 'number') return row.targetRisk > 0;
115
+ if (typeof row.accepted === 'boolean' && row.accepted === false) return true;
116
+ const label = String(row.label || row.signal || '').toLowerCase();
117
+ return label === 'negative';
118
+ }
119
+
99
120
  function formatLastPromotion(promo) {
100
121
  if (!promo) return 'none';
101
122
  const ts = promo.timestamp ? new Date(promo.timestamp) : null;
@@ -125,7 +146,7 @@ function formatStats(stats) {
125
146
  }
126
147
 
127
148
  function formatBayesErrorRate(rate) {
128
- if (rate === null || rate === undefined) return 'n/a (no feedback sequences yet)';
149
+ if (rate === null || rate === undefined) return 'n/a (need both safe and harmful feedback sequences)';
129
150
  const pct = (rate * 100).toFixed(1);
130
151
  if (rate < 0.02) return `${pct}% — scorer is near-optimal; add features, don't tune thresholds`;
131
152
  if (rate < 0.10) return `${pct}% — scorer has modest headroom`;
@@ -12,28 +12,28 @@ const {
12
12
  const USAGE_FILE = path.join(process.env.HOME || '/tmp', '.thumbgate', 'usage-limits.json');
13
13
 
14
14
  // ──────────────────────────────────────────────────────────
15
- // NEW: Lifetime caps on free tier users hit the wall fast
16
- // and must upgrade to keep using core features.
15
+ // Free tier: generous on captures (habit formation) and rules
16
+ // (5 active gates), gated on Pro-only features (recall, search,
17
+ // exports). Dashboard, exports, and unlimited rules drive Pro.
17
18
  // ──────────────────────────────────────────────────────────
18
19
  const FREE_TIER_LIMITS = {
19
- capture_feedback: { daily: Infinity, lifetime: 3, label: 'feedback captures' },
20
- prevention_rules: { daily: Infinity, lifetime: 1, label: 'prevention rules generated' },
21
- recall: { daily: 0, lifetime: 0, label: 'recall queries (Pro only)' },
22
- search_lessons: { daily: 0, lifetime: 0, label: 'lesson searches (Pro only)' },
23
- search_thumbgate: { daily: 0, lifetime: 0, label: 'ThumbGate searches (Pro only)' },
24
- commerce_recall: { daily: 0, lifetime: 0, label: 'commerce recalls (Pro only)' },
25
- export_dpo: { daily: 0, lifetime: 0, label: 'DPO exports (Pro only)' },
26
- export_databricks: { daily: 0, lifetime: 0, label: 'Databricks exports (Pro only)' },
27
- construct_context_pack: { daily: Infinity, lifetime: 3, label: 'context packs' },
20
+ capture_feedback: { daily: Infinity, lifetime: Infinity, label: 'feedback captures' },
21
+ prevention_rules: { daily: Infinity, lifetime: Infinity, label: 'prevention rules generated' },
22
+ recall: { daily: 0, lifetime: 0, label: 'recall queries (Pro only)' },
23
+ search_lessons: { daily: 0, lifetime: 0, label: 'lesson searches (Pro only)' },
24
+ search_thumbgate: { daily: 0, lifetime: 0, label: 'ThumbGate searches (Pro only)' },
25
+ commerce_recall: { daily: 0, lifetime: 0, label: 'commerce recalls (Pro only)' },
26
+ export_dpo: { daily: 0, lifetime: 0, label: 'DPO exports (Pro only)' },
27
+ export_databricks: { daily: 0, lifetime: 0, label: 'Databricks exports (Pro only)' },
28
+ construct_context_pack: { daily: Infinity, lifetime: Infinity, label: 'context packs' },
28
29
  };
29
30
 
30
- const FREE_TIER_MAX_GATES = 1; // Down from 5 one auto-promoted gate, then paywall
31
+ const FREE_TIER_MAX_GATES = 5; // 5 active prevention rules on free; Pro is unlimited
31
32
 
32
- const UPGRADE_MESSAGE = `Pro: ${PRO_PRICE_LABEL} — unlimited captures, recall, prevention rules, and dashboard: ${PRO_MONTHLY_PAYMENT_LINK}\n Team: ${TEAM_PRICE_LABEL} after workflow qualification.`;
33
+ const UPGRADE_MESSAGE = `Pro: ${PRO_PRICE_LABEL} — unlimited rules, recall, lesson search, dashboard, and exports: ${PRO_MONTHLY_PAYMENT_LINK}\n Team: ${TEAM_PRICE_LABEL} after workflow qualification.`;
33
34
 
34
35
  const PAYWALL_MESSAGES = {
35
- capture_feedback: 'You\'ve used all 3 free feedback captures. Your agent is still making mistakes upgrade to Pro to capture every one and build real prevention rules.',
36
- prevention_rules: 'Free tier includes 1 prevention rule. Your agents need more protection — upgrade to Pro for unlimited rules.',
36
+ prevention_rules: 'Free tier includes 5 active prevention rules. Promote more or unlock unlimited rules with Pro.',
37
37
  recall: 'Recall is a Pro feature. Your past feedback is stored locally — upgrade to search and reuse it.',
38
38
  search_lessons: 'Lesson search is a Pro feature. Upgrade to find patterns in your agent\'s mistakes.',
39
39
  default: 'This feature requires Pro. Start Pro — card required; billed today.',
package/src/api/server.js CHANGED
@@ -16,6 +16,8 @@ const POSTHOG_STATIC_PATH_PREFIX = '/static/';
16
16
  const FIRST_FAILURE_RULE_CHECKOUT_URL = 'https://buy.stripe.com/4gM6oHgH2bTw4lH6i73sI0z';
17
17
  const QUICK_READ_CHECKOUT_URL = 'https://buy.stripe.com/aFa8wPgH29Lo4lH35V3sI0w';
18
18
  const WORKFLOW_TEARDOWN_CHECKOUT_URL = 'https://buy.stripe.com/7sYfZhgH29LodWhdKz3sI0v';
19
+ const SPRINT_DIAGNOSTIC_CHECKOUT_URL = 'https://buy.stripe.com/3cI7sLgH25v8dWh5e33sI0o';
20
+ const WORKFLOW_SPRINT_CHECKOUT_URL = 'https://buy.stripe.com/8x25kDcqMaPs9G15e33sI0p';
19
21
 
20
22
  function getPosthogProxyPath(pathname) {
21
23
  return pathname.slice('/ingest'.length) || '/';
@@ -210,6 +212,7 @@ const NUMBERS_PAGE_PATH = path.resolve(__dirname, '../../public/numbers.html');
210
212
  const LEARN_DIR = path.resolve(__dirname, '../../public/learn');
211
213
  const GUIDES_DIR = path.resolve(__dirname, '../../public/guides');
212
214
  const COMPARE_DIR = path.resolve(__dirname, '../../public/compare');
215
+ const USE_CASES_DIR = path.resolve(__dirname, '../../public/use-cases');
213
216
  const PUBLIC_DIR = path.resolve(__dirname, '../../public');
214
217
  const PUBLIC_ASSETS_DIR = path.resolve(__dirname, '../../public/assets');
215
218
  const BUYER_INTENT_SCRIPT_PATH = path.resolve(__dirname, '../../public/js/buyer-intent.js');
@@ -1587,6 +1590,12 @@ function normalizeTrackedLinkSlug(value) {
1587
1590
  return String(value || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
1588
1591
  }
1589
1592
 
1593
+ function normalizePublicPageSlug(value) {
1594
+ return String(value || '')
1595
+ .replace(/\.html$/i, '')
1596
+ .replace(/[^a-z0-9-]/g, '');
1597
+ }
1598
+
1590
1599
  function getTrackedLinkTarget(slug) {
1591
1600
  const normalizedSlug = normalizeTrackedLinkSlug(slug);
1592
1601
  return TRACKED_LINK_TARGETS[normalizedSlug]
@@ -1939,8 +1948,8 @@ function loadPublicMarketingTemplateHtml(templatePath, runtimeConfig, pageContex
1939
1948
  '__CHECKOUT_FALLBACK_URL__': runtimeConfig.checkoutFallbackUrl,
1940
1949
  '__PRO_PRICE_DOLLARS__': runtimeConfig.proPriceDollars,
1941
1950
  '__PRO_PRICE_LABEL__': runtimeConfig.proPriceLabel,
1942
- '__SPRINT_DIAGNOSTIC_CHECKOUT_URL__': runtimeConfig.sprintDiagnosticCheckoutUrl || '',
1943
- '__WORKFLOW_SPRINT_CHECKOUT_URL__': runtimeConfig.workflowSprintCheckoutUrl || '',
1951
+ '__SPRINT_DIAGNOSTIC_CHECKOUT_URL__': runtimeConfig.sprintDiagnosticCheckoutUrl || SPRINT_DIAGNOSTIC_CHECKOUT_URL,
1952
+ '__WORKFLOW_SPRINT_CHECKOUT_URL__': runtimeConfig.workflowSprintCheckoutUrl || WORKFLOW_SPRINT_CHECKOUT_URL,
1944
1953
  '__SPRINT_DIAGNOSTIC_PRICE_DOLLARS__': runtimeConfig.sprintDiagnosticPriceDollars || 499,
1945
1954
  '__WORKFLOW_SPRINT_PRICE_DOLLARS__': runtimeConfig.workflowSprintPriceDollars || 1500,
1946
1955
  '__GA_MEASUREMENT_ID__': runtimeConfig.gaMeasurementId || '',
@@ -4193,6 +4202,15 @@ async function addContext(){
4193
4202
  return;
4194
4203
  }
4195
4204
 
4205
+ if (isGetLikeRequest && pathname === '/lessons/') {
4206
+ res.writeHead(302, {
4207
+ Location: '/lessons',
4208
+ 'Cache-Control': 'no-store',
4209
+ });
4210
+ res.end();
4211
+ return;
4212
+ }
4213
+
4196
4214
  if (isGetLikeRequest && pathname === '/lessons') {
4197
4215
  try {
4198
4216
  const html = loadLessonsPageHtml(req, expectedApiKey);
@@ -4245,7 +4263,7 @@ async function addContext(){
4245
4263
  return;
4246
4264
  }
4247
4265
 
4248
- if (isGetLikeRequest && pathname === '/guide') {
4266
+ if (isGetLikeRequest && (pathname === '/guide' || pathname === '/guide.html')) {
4249
4267
  try {
4250
4268
  const html = fs.readFileSync(GUIDE_PAGE_PATH, 'utf-8');
4251
4269
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
@@ -4255,7 +4273,7 @@ async function addContext(){
4255
4273
  return;
4256
4274
  }
4257
4275
 
4258
- if (isGetLikeRequest && pathname === '/codex-plugin') {
4276
+ if (isGetLikeRequest && (pathname === '/codex-plugin' || pathname === '/codex-plugin.html')) {
4259
4277
  try {
4260
4278
  const html = fs.readFileSync(CODEX_PLUGIN_PAGE_PATH, 'utf-8');
4261
4279
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
@@ -4265,7 +4283,7 @@ async function addContext(){
4265
4283
  return;
4266
4284
  }
4267
4285
 
4268
- if (isGetLikeRequest && pathname === '/compare') {
4286
+ if (isGetLikeRequest && (pathname === '/compare' || pathname === '/compare.html')) {
4269
4287
  try {
4270
4288
  const html = fs.readFileSync(COMPARE_PAGE_PATH, 'utf-8');
4271
4289
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
@@ -4275,7 +4293,7 @@ async function addContext(){
4275
4293
  return;
4276
4294
  }
4277
4295
 
4278
- if (isGetLikeRequest && pathname === '/blog') {
4296
+ if (isGetLikeRequest && (pathname === '/blog' || pathname === '/blog.html')) {
4279
4297
  try {
4280
4298
  const blogPath = path.resolve(__dirname, '../../public/blog.html');
4281
4299
  const html = fs.readFileSync(blogPath, 'utf-8');
@@ -4286,7 +4304,7 @@ async function addContext(){
4286
4304
  return;
4287
4305
  }
4288
4306
 
4289
- if (isGetLikeRequest && pathname === '/learn') {
4307
+ if (isGetLikeRequest && (pathname === '/learn' || pathname === '/learn.html')) {
4290
4308
  try {
4291
4309
  const html = fs.readFileSync(LEARN_PAGE_PATH, 'utf-8');
4292
4310
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
@@ -4296,6 +4314,24 @@ async function addContext(){
4296
4314
  return;
4297
4315
  }
4298
4316
 
4317
+ if (isGetLikeRequest && (pathname === '/guides' || pathname === '/guides/' || pathname === '/guides.html')) {
4318
+ res.writeHead(302, {
4319
+ Location: '/learn',
4320
+ 'Cache-Control': 'no-store',
4321
+ });
4322
+ res.end();
4323
+ return;
4324
+ }
4325
+
4326
+ if (isGetLikeRequest && (pathname === '/services' || pathname === '/services.html')) {
4327
+ res.writeHead(302, {
4328
+ Location: '/#workflow-sprint-intake',
4329
+ 'Cache-Control': 'no-store',
4330
+ });
4331
+ res.end();
4332
+ return;
4333
+ }
4334
+
4299
4335
  if (isGetLikeRequest && (pathname === '/numbers' || pathname === '/numbers.html')) {
4300
4336
  // Route through servePublicMarketingPage so landing_page_view telemetry
4301
4337
  // + funnel-events.jsonl `discovery/landing_view` get captured with UTM
@@ -4331,7 +4367,7 @@ async function addContext(){
4331
4367
 
4332
4368
  if (isGetLikeRequest && pathname.startsWith('/learn/')) {
4333
4369
  try {
4334
- const slug = pathname.replace('/learn/', '').replace(/[^a-z0-9-]/g, '');
4370
+ const slug = normalizePublicPageSlug(pathname.replace('/learn/', ''));
4335
4371
  const articlePath = path.join(LEARN_DIR, `${slug}.html`);
4336
4372
  if (!articlePath.startsWith(LEARN_DIR)) {
4337
4373
  sendJson(res, 403, { error: 'Forbidden' });
@@ -4347,7 +4383,7 @@ async function addContext(){
4347
4383
 
4348
4384
  if (isGetLikeRequest && pathname.startsWith('/guides/')) {
4349
4385
  try {
4350
- const slug = pathname.replace('/guides/', '').replace(/[^a-z0-9-]/g, '');
4386
+ const slug = normalizePublicPageSlug(pathname.replace('/guides/', ''));
4351
4387
  const guidePath = path.join(GUIDES_DIR, `${slug}.html`);
4352
4388
  if (!guidePath.startsWith(GUIDES_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
4353
4389
  const html = fs.readFileSync(guidePath, 'utf-8');
@@ -4358,7 +4394,7 @@ async function addContext(){
4358
4394
 
4359
4395
  if (isGetLikeRequest && pathname.startsWith('/compare/') && pathname !== '/compare') {
4360
4396
  try {
4361
- const slug = pathname.replace('/compare/', '').replace(/[^a-z0-9-]/g, '');
4397
+ const slug = normalizePublicPageSlug(pathname.replace('/compare/', ''));
4362
4398
  const comparePath = path.join(COMPARE_DIR, `${slug}.html`);
4363
4399
  if (!comparePath.startsWith(COMPARE_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
4364
4400
  const html = fs.readFileSync(comparePath, 'utf-8');
@@ -4367,6 +4403,17 @@ async function addContext(){
4367
4403
  return;
4368
4404
  }
4369
4405
 
4406
+ if (isGetLikeRequest && pathname.startsWith('/use-cases/')) {
4407
+ try {
4408
+ const slug = normalizePublicPageSlug(pathname.replace('/use-cases/', ''));
4409
+ const useCasePath = path.join(USE_CASES_DIR, `${slug}.html`);
4410
+ if (!useCasePath.startsWith(USE_CASES_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
4411
+ const html = fs.readFileSync(useCasePath, 'utf-8');
4412
+ sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
4413
+ } catch { sendJson(res, 404, { error: 'Use case not found' }); }
4414
+ return;
4415
+ }
4416
+
4370
4417
  if (isGetLikeRequest && pathname.startsWith('/assets/')) {
4371
4418
  const rel = pathname.slice('/assets/'.length);
4372
4419
  const resolved = path.resolve(PUBLIC_ASSETS_DIR, rel);
@@ -4500,24 +4547,28 @@ async function addContext(){
4500
4547
  ctaPlacement: 'checkout_interstitial',
4501
4548
  planId: 'workflow_teardown',
4502
4549
  });
4503
- const diagnosticCheckoutHref = hostedConfig.sprintDiagnosticCheckoutUrl
4504
- ? buildCheckoutIntentHref(hostedConfig.sprintDiagnosticCheckoutUrl, analyticsMetadata, {
4550
+ const diagnosticCheckoutHref = buildCheckoutIntentHref(
4551
+ hostedConfig.sprintDiagnosticCheckoutUrl || SPRINT_DIAGNOSTIC_CHECKOUT_URL,
4552
+ analyticsMetadata,
4553
+ {
4505
4554
  utmMedium: 'checkout_interstitial_paid_path',
4506
4555
  utmCampaign: analyticsMetadata.utmCampaign || 'checkout_interstitial_diagnostic',
4507
4556
  ctaId: 'checkout_interstitial_sprint_diagnostic_checkout',
4508
4557
  ctaPlacement: 'checkout_interstitial',
4509
4558
  planId: 'sprint_diagnostic',
4510
- })
4511
- : '';
4512
- const sprintCheckoutHref = hostedConfig.workflowSprintCheckoutUrl
4513
- ? buildCheckoutIntentHref(hostedConfig.workflowSprintCheckoutUrl, analyticsMetadata, {
4559
+ }
4560
+ );
4561
+ const sprintCheckoutHref = buildCheckoutIntentHref(
4562
+ hostedConfig.workflowSprintCheckoutUrl || WORKFLOW_SPRINT_CHECKOUT_URL,
4563
+ analyticsMetadata,
4564
+ {
4514
4565
  utmMedium: 'checkout_interstitial_paid_path',
4515
4566
  utmCampaign: analyticsMetadata.utmCampaign || 'checkout_interstitial_workflow_sprint',
4516
4567
  ctaId: 'checkout_interstitial_workflow_sprint_checkout',
4517
4568
  ctaPlacement: 'checkout_interstitial',
4518
4569
  planId: 'workflow_sprint',
4519
- })
4520
- : '';
4570
+ }
4571
+ );
4521
4572
  const html = renderCheckoutIntentPage({
4522
4573
  confirmHref: buildCheckoutConfirmHref(parsed),
4523
4574
  firstRuleCheckoutHref,