thumbgate 1.18.0 → 1.20.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.
@@ -25,7 +25,7 @@
25
25
  "alternateName": "thumbgate",
26
26
  "applicationCategory": "DeveloperApplication",
27
27
  "operatingSystem": "Cross-platform, Node.js >=18.18.0",
28
- "softwareVersion": "1.18.0",
28
+ "softwareVersion": "1.20.0",
29
29
  "url": "https://thumbgate-production.up.railway.app/numbers",
30
30
  "dateModified": "2026-05-07",
31
31
  "creator": {
@@ -202,7 +202,7 @@
202
202
  <main class="container">
203
203
  <h1>The Numbers</h1>
204
204
  <p class="subtitle">Generated first-party operational snapshot from the ThumbGate runtime. This is not customer traction, install volume, revenue, or proof that a configured gate has fired.</p>
205
- <div class="freshness">Updated: 2026-05-07 · Version 1.18.0</div>
205
+ <div class="freshness">Updated: 2026-05-07 · Version 1.20.0</div>
206
206
  <div class="truth-note"><strong>Read this first:</strong> configured checks are inventory. Recorded blocks and warnings are usage evidence. This snapshot currently reports 0 recorded hard-block event(s) and 0 recorded warning event(s).</div>
207
207
 
208
208
  <h2>Gate enforcement</h2>
package/public/pro.html CHANGED
@@ -718,7 +718,7 @@ __GA_BOOTSTRAP__
718
718
  <a href="#pricing">Pricing</a>
719
719
  <a href="#faq">FAQ</a>
720
720
  <a href="/dashboard">Demo</a>
721
- <a class="nav-cta" data-quick-read-link href="https://buy.stripe.com/aFa8wPgH29Lo4lH35V3sI0w" onclick="sendFirstPartyTelemetry('quick_read_checkout_started',{ctaId:'pro_page_nav_quick_read_checkout',price:19});sendGa4Event('begin_checkout',{currency:'USD',value:19,items:[{item_id:'quick_read',item_name:'AI Agent Failure Quick Read'}]});">Pay $19 quick read</a>
721
+ <a class="nav-cta btn-pro-checkout" href="/checkout/pro?utm_source=website&utm_medium=pro_page_nav&utm_campaign=pro_pack&cta_id=pro_page_nav&cta_placement=nav&plan_id=pro&landing_path=%2Fpro">Start Pro</a>
722
722
  </div>
723
723
  </div>
724
724
  </nav>
@@ -730,7 +730,7 @@ __GA_BOOTSTRAP__
730
730
  <h1>Buy the operator loop that proves your AI agent stopped repeating the mistake.</h1>
731
731
  <p style="font-size:13px;opacity:0.8;margin-bottom:0.5rem;">Updated: <time datetime="2026-04-20">2026-04-20</time> · by <a href="https://github.com/IgorGanapolsky" style="color:inherit;">Igor Ganapolsky</a></p>
732
732
  <p>ThumbGate Pro is for one operator who already hit a repeated AI-agent failure and now needs proof: what was blocked, why it was blocked, and what changed before the next risky run.</p>
733
- <p>If you need help today, start with the $19 quick read: send one failed tool call or workflow snippet and get the likely rule shape plus proof check. Use Pro when you want the local dashboard and DPO export.</p>
733
+ <p>Start Pro when you want the local dashboard, DPO export, and a single proof lane for the repeated mistake you need to stop. Team diagnostics and custom services are handled through intake, not this buyer path.</p>
734
734
  <div class="hero-proof">
735
735
  <div class="proof-pill">Personal local dashboard</div>
736
736
  <div class="proof-pill">DPO export from real corrections</div>
@@ -738,8 +738,7 @@ __GA_BOOTSTRAP__
738
738
  <div class="proof-pill">Founder support on risky flows</div>
739
739
  </div>
740
740
  <div class="hero-actions">
741
- <a class="btn-primary" data-quick-read-link href="https://buy.stripe.com/aFa8wPgH29Lo4lH35V3sI0w" onclick="sendFirstPartyTelemetry('quick_read_checkout_started',{ctaId:'pro_page_hero_quick_read_checkout',price:19});sendGa4Event('begin_checkout',{currency:'USD',value:19,items:[{item_id:'quick_read',item_name:'AI Agent Failure Quick Read'}]});">Pay $19 quick read</a>
742
- <a class="btn-secondary btn-pro-checkout" href="/checkout/pro?utm_source=website&utm_medium=pro_page_hero&utm_campaign=pro_pack&cta_id=pro_page_primary&cta_placement=hero&plan_id=pro&landing_path=%2Fpro">Start Pro dashboard</a>
741
+ <a class="btn-primary btn-pro-checkout" href="/checkout/pro?utm_source=website&utm_medium=pro_page_hero&utm_campaign=pro_pack&cta_id=pro_page_primary&cta_placement=hero&plan_id=pro&landing_path=%2Fpro">Start Pro dashboard</a>
743
742
  <a class="btn-secondary btn-demo" href="/dashboard?utm_source=website&utm_medium=pro_page&utm_campaign=pro_pack">Open dashboard demo</a>
744
743
  <a class="btn-ghost btn-free-path" href="/guide?utm_source=website&utm_medium=pro_page&utm_campaign=free_install">Stay on Free and install locally</a>
745
744
  </div>
@@ -775,18 +774,6 @@ __GA_BOOTSTRAP__
775
774
  <p>Visual check debugger, DPO export, auto-connect after activation, Model Hardening Advisor, and founder support for the risky flow you need to harden first.</p>
776
775
  </div>
777
776
 
778
- <div class="aside-card" data-pro-paid-recovery>
779
- <div class="aside-kicker">Team workflow blocked?</div>
780
- <h3>Buy the paid diagnostic</h3>
781
- <p>Skip self-serve Pro and pay for the smallest useful review now.</p>
782
- <div class="price-stack">
783
- <a class="btn-secondary" data-first-rule-link href="https://buy.stripe.com/4gM6oHgH2bTw4lH6i73sI0z" onclick="sendFirstPartyTelemetry('first_failure_rule_checkout_started',{ctaId:'pro_page_first_failure_rule_checkout',price:1});sendGa4Event('begin_checkout',{currency:'USD',value:1,items:[{item_id:'first_failure_rule',item_name:'First AI Agent Failure Rule'}]});">Pay $1 first rule</a>
784
- <a class="btn-secondary" data-quick-read-link href="https://buy.stripe.com/aFa8wPgH29Lo4lH35V3sI0w" onclick="sendFirstPartyTelemetry('quick_read_checkout_started',{ctaId:'pro_page_quick_read_checkout',price:19});">Pay $19 quick read</a>
785
- <a class="btn-primary" data-sprint-diagnostic-link href="__SPRINT_DIAGNOSTIC_CHECKOUT_URL__" onclick="sendFirstPartyTelemetry('workflow_sprint_diagnostic_checkout_started',{ctaId:'pro_page_sprint_diagnostic_checkout'});sendGa4Event('begin_checkout',{currency:'USD',value:__SPRINT_DIAGNOSTIC_PRICE_DOLLARS__});">Pay $__SPRINT_DIAGNOSTIC_PRICE_DOLLARS__ diagnostic</a>
786
- <a class="btn-secondary" data-workflow-sprint-link href="__WORKFLOW_SPRINT_CHECKOUT_URL__" onclick="sendFirstPartyTelemetry('workflow_sprint_checkout_started',{ctaId:'pro_page_workflow_sprint_checkout'});sendGa4Event('begin_checkout',{currency:'USD',value:__WORKFLOW_SPRINT_PRICE_DOLLARS__});">Pay $__WORKFLOW_SPRINT_PRICE_DOLLARS__ sprint</a>
787
- </div>
788
- </div>
789
-
790
777
  <div class="aside-card">
791
778
  <div class="aside-kicker">Keep the buyer path warm</div>
792
779
  <h3>Save your work email before you decide</h3>
@@ -1024,7 +1011,6 @@ function sendFirstPartyTelemetry(eventType, props) {
1024
1011
  }
1025
1012
 
1026
1013
  function sendGa4Event(e,p){if(typeof gtag==='function')gtag('event',e,p||{})}
1027
- function initializeProPaidRecovery(){var c=document.querySelector('[data-pro-paid-recovery]');if(!c)return;var n=0;c.querySelectorAll('a[href]').forEach(function(a){var h=a.getAttribute('href')||'';if(/^https?:\/\//.test(h))n+=1;else a.hidden=true});c.hidden=n===0}
1028
1014
 
1029
1015
  function initializeBuyerIntent() {
1030
1016
  globalThis.ThumbGateBuyerIntent.initializeBuyerIntent({
@@ -1067,7 +1053,6 @@ trackClick('.btn-pro-checkout', 'pro_checkout_start', { tier: 'pro', page: 'pro'
1067
1053
  trackClick('.btn-demo', 'pro_demo_click', { page: 'pro' });
1068
1054
  trackClick('.btn-free-path', 'pro_free_path_click', { page: 'pro' });
1069
1055
  trackClick('.proof-links a', 'pro_proof_click', { page: 'pro' });
1070
- initializeProPaidRecovery();
1071
1056
  initializeBuyerIntent();
1072
1057
  globalThis.buyerJourney = globalThis.ThumbGateBuyerIntent.initializeBehaviorAnalytics({
1073
1058
  pageType: 'marketing',
@@ -1084,10 +1069,6 @@ globalThis.buyerJourney = globalThis.ThumbGateBuyerIntent.initializeBehaviorAnal
1084
1069
  ],
1085
1070
  ctaImpressions: [
1086
1071
  { selector: '.btn-pro-checkout', ctaId: 'pro_checkout', ctaPlacement: 'pro_page', planId: 'pro' },
1087
- { selector: '[data-first-rule-link]', ctaId: 'pro_page_first_failure_rule_checkout', ctaPlacement: 'pro_paid_recovery', planId: 'first_failure_rule' },
1088
- { selector: '[data-quick-read-link]', ctaId: 'pro_page_quick_read_checkout', ctaPlacement: 'pro_paid_recovery', planId: 'quick_read' },
1089
- { selector: '[data-sprint-diagnostic-link]', ctaId: 'pro_page_sprint_diagnostic_checkout', ctaPlacement: 'pro_paid_recovery', planId: 'sprint_diagnostic' },
1090
- { selector: '[data-workflow-sprint-link]', ctaId: 'pro_page_workflow_sprint_checkout', ctaPlacement: 'pro_paid_recovery', planId: 'workflow_sprint' },
1091
1072
  { selector: '.btn-demo', ctaId: 'pro_demo', ctaPlacement: 'pro_page', planId: 'proof' },
1092
1073
  { selector: '.btn-free-path', ctaId: 'pro_free_path', ctaPlacement: 'pro_page', planId: 'free' }
1093
1074
  ]
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Activation tracker — fires `activation_first_rule_promoted` exactly once
5
+ * per install, the first time a prevention rule auto-promotes from feedback.
6
+ *
7
+ * Why: v1.17.0 opened the free tier (5 active rules, unlimited captures).
8
+ * The metric that decides whether that worked is the % of `npx thumbgate init`
9
+ * runs that produce a first auto-promoted prevention rule within 24h. Without
10
+ * this telemetry, every funnel decision is guessing. This is improvement #1
11
+ * from the 2026-05-13 revenue-ROI critique.
12
+ *
13
+ * Payload (anonymous):
14
+ * - eventType: 'activation_first_rule_promoted'
15
+ * - installId: stable random UUID (from cli-telemetry.getInstallId)
16
+ * - daysToFirstRule: days between INSTALL_ID_PATH creation and now
17
+ * - visitorType: ci | owner | real_user (from cli-telemetry.classifyInstall)
18
+ *
19
+ * No personal data, no rule content, no feedback text. Just the activation
20
+ * signal. Respects THUMBGATE_NO_TELEMETRY=1 / DO_NOT_TRACK=1 opt-out via
21
+ * the underlying trackEvent helper.
22
+ *
23
+ * Idempotency: writes a marker file. After the first firing the function
24
+ * is a no-op for the lifetime of that install.
25
+ */
26
+
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+
30
+ const { trackEvent, getInstallId, classifyInstall, INSTALL_ID_PATH } = require('./cli-telemetry');
31
+
32
+ const MARKER_DIR = path.join(path.dirname(INSTALL_ID_PATH), 'activation');
33
+ const MARKER_PATH = path.join(MARKER_DIR, 'first-rule-promoted.json');
34
+
35
+ function readMarker() {
36
+ try {
37
+ if (!fs.existsSync(MARKER_PATH)) return null;
38
+ return JSON.parse(fs.readFileSync(MARKER_PATH, 'utf8'));
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function writeMarker(record) {
45
+ try {
46
+ if (!fs.existsSync(MARKER_DIR)) fs.mkdirSync(MARKER_DIR, { recursive: true });
47
+ fs.writeFileSync(MARKER_PATH, JSON.stringify(record, null, 2));
48
+ } catch {
49
+ // non-fatal: worst case we double-fire on a future run; the telemetry
50
+ // backend can dedup by installId. Better to be silent than to throw.
51
+ }
52
+ }
53
+
54
+ function computeDaysSinceInstall() {
55
+ try {
56
+ if (!fs.existsSync(INSTALL_ID_PATH)) return null;
57
+ const installed = fs.statSync(INSTALL_ID_PATH).mtimeMs;
58
+ if (!Number.isFinite(installed) || installed <= 0) return null;
59
+ const diffMs = Date.now() - installed;
60
+ if (diffMs < 0) return 0;
61
+ // 1 decimal of precision; the metric uses 24h buckets but a finer-grained
62
+ // number lets analytics chart same-day vs. 1d / 2d / week-of activation.
63
+ return Math.round((diffMs / 86_400_000) * 10) / 10;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Fire the activation event if this is the first rule promotion for this
71
+ * install. Returns true if an event was actually emitted, false if skipped
72
+ * (already fired before, or environment opted out of telemetry).
73
+ *
74
+ * Always synchronous and never throws — safe to call from any side-effect
75
+ * site without try/catch wrapping. The underlying telemetry call is itself
76
+ * fire-and-forget over HTTPS with a 3s timeout.
77
+ */
78
+ function recordFirstRulePromotion(metadata = {}) {
79
+ if (readMarker()) return false; // already fired
80
+
81
+ if (process.env.THUMBGATE_NO_TELEMETRY === '1' || process.env.DO_NOT_TRACK === '1') {
82
+ // Still write the marker so a later run with telemetry re-enabled does not
83
+ // fire stale activation data weeks after the actual first promotion.
84
+ writeMarker({
85
+ installId: getInstallId(),
86
+ promotedAt: new Date().toISOString(),
87
+ telemetryOptOut: true,
88
+ });
89
+ return false;
90
+ }
91
+
92
+ const installId = getInstallId();
93
+ const daysToFirstRule = computeDaysSinceInstall();
94
+ const visitorType = classifyInstall();
95
+
96
+ writeMarker({
97
+ installId,
98
+ promotedAt: new Date().toISOString(),
99
+ daysToFirstRule,
100
+ visitorType,
101
+ ...metadata,
102
+ });
103
+
104
+ trackEvent('activation_first_rule_promoted', {
105
+ daysToFirstRule,
106
+ visitorType,
107
+ ...metadata,
108
+ });
109
+ return true;
110
+ }
111
+
112
+ function resetForTesting() {
113
+ // Test-only helper. Removes the marker so a subsequent recordFirstRulePromotion
114
+ // call re-fires. Never used in production code paths.
115
+ try {
116
+ if (fs.existsSync(MARKER_PATH)) fs.unlinkSync(MARKER_PATH);
117
+ } catch {}
118
+ }
119
+
120
+ module.exports = {
121
+ recordFirstRulePromotion,
122
+ computeDaysSinceInstall,
123
+ readMarker,
124
+ writeMarker,
125
+ MARKER_PATH,
126
+ resetForTesting,
127
+ };
@@ -13,6 +13,22 @@ const WARN_THRESHOLD = 1;
13
13
  const BLOCK_THRESHOLD = 3; // 3+ repeated failures hard-block the action
14
14
  const WINDOW_DAYS = 30;
15
15
 
16
+ // Default TTL on auto-promoted gates. Reddit reviewer @MomSausageandPeppers
17
+ // (2026-05-13) flagged that without expiry, "accidental dislikes become policy
18
+ // forever." Gates expire 90 days after promotion UNLESS they keep firing —
19
+ // every fire refreshes lastFiredAt, and expireGates() keeps any gate fired
20
+ // within the last TTL window regardless of original promotion date. Manual
21
+ // force-promote bypasses TTL (operator says "permanent"). Override via
22
+ // THUMBGATE_RULE_TTL_DAYS env var.
23
+ const DEFAULT_RULE_TTL_DAYS = 90;
24
+ function getRuleTtlDays() {
25
+ const raw = Number(process.env.THUMBGATE_RULE_TTL_DAYS);
26
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_RULE_TTL_DAYS;
27
+ }
28
+ function getRuleTtlMs() {
29
+ return getRuleTtlDays() * 24 * 60 * 60 * 1000;
30
+ }
31
+
16
32
  const NEG_SIGNALS = new Set(['negative', 'negative_strong', 'down', 'thumbs_down']);
17
33
 
18
34
  function getFeedbackLogPath() {
@@ -66,15 +82,54 @@ function isNegative(entry) {
66
82
  return NEG_SIGNALS.has(sig);
67
83
  }
68
84
 
85
+ /**
86
+ * Normalize a captured command/context string so trivial variants collapse
87
+ * to the same gate signature.
88
+ *
89
+ * Reddit critique (MomSausageandPeppers, 2026-05-17): "commands are matched
90
+ * by string equality, so `rm -rf node_modules` and `rm -rf ./node_modules`
91
+ * create separate gates."
92
+ *
93
+ * Conservative — only collapse variants that are *unambiguously* the same
94
+ * intent. Does NOT reorder flags, strip `&&` chains, or canonicalize
95
+ * subcommands (each can change semantics).
96
+ *
97
+ * 1. Lowercase
98
+ * 2. Strip `/Users/<name>` and `/home/<name>` home-dir prefixes (→ `~`)
99
+ * 3. Drop `:LINE` and `:LINE:COL` refs
100
+ * 4. Per-token: strip one layer of matching outer quotes/backticks
101
+ * 5. Per-token: drop leading `./`
102
+ * 6. Collapse whitespace + trim
103
+ */
104
+ function normalizeCommandSignature(input) {
105
+ let text = String(input || '');
106
+ if (!text) return '';
107
+ text = text.toLowerCase();
108
+ text = text.replace(/\/users\/[^\s/]+/g, '~').replace(/\/home\/[^\s/]+/g, '~');
109
+ text = text.replace(/:\d+(?::\d+)?\b/g, '');
110
+ const tokens = text.split(/\s+/).filter(Boolean).map((tok) => {
111
+ let t = tok;
112
+ if (t.length >= 2) {
113
+ const first = t[0];
114
+ const last = t[t.length - 1];
115
+ if ((first === '"' || first === "'" || first === '`') && first === last) {
116
+ t = t.slice(1, -1);
117
+ }
118
+ }
119
+ if (t.startsWith('./')) t = t.slice(2);
120
+ return t;
121
+ }).filter(Boolean);
122
+ return tokens.join(' ').trim();
123
+ }
124
+
69
125
  function extractPatternKey(entry) {
70
126
  // Use tags as primary grouping key; fall back to context normalization
71
127
  const tags = (entry.tags || []).filter((t) => !['feedback', 'negative', 'positive'].includes(t));
72
128
  if (tags.length > 0) return tags.sort().join('+');
73
129
 
74
- const ctx = (entry.context || entry.whatWentWrong || '').toLowerCase().trim();
130
+ const ctx = (entry.context || entry.whatWentWrong || '').trim();
75
131
  if (ctx.length < 10) return null;
76
- // Normalize paths and numbers for grouping
77
- return ctx.replace(/\/Users\/[^\s/]+/g, '~').replace(/:[0-9]+/g, '').replace(/\s+/g, ' ').slice(0, 100);
132
+ return normalizeCommandSignature(ctx).slice(0, 100);
78
133
  }
79
134
 
80
135
  function extractDiagnosticKeys(entry) {
@@ -147,10 +202,17 @@ function buildGateRule(group) {
147
202
  : group.key.startsWith('constraint:')
148
203
  ? 'repeated constraint violation'
149
204
  : 'repeated pattern';
150
-
205
+
151
206
  const occurrencesText = group.count === 'MANUAL' ? 'manual' : `${group.count} occurrences`;
152
207
  const suggestedMessage = `Auto-promoted ${kind}: "${context}" (${occurrencesText} in ${WINDOW_DAYS} days)`;
153
208
 
209
+ // TTL: auto-promoted rules expire after the configured window unless
210
+ // refreshed by a fresh fire. Manual force-promote bypasses TTL — operator
211
+ // says "permanent" by going through the force path.
212
+ const nowMs = Date.now();
213
+ const isManual = group.count === 'MANUAL';
214
+ const expiresAt = isManual ? null : new Date(nowMs + getRuleTtlMs()).toISOString();
215
+
154
216
  return {
155
217
  id: patternToGateId(group.key),
156
218
  trigger: `auto:${group.key}`,
@@ -160,10 +222,82 @@ function buildGateRule(group) {
160
222
  severity,
161
223
  occurrences: group.count,
162
224
  promotedAt: new Date().toISOString(),
225
+ expiresAt,
226
+ lastFiredAt: null,
163
227
  source: group.source || 'auto-promote',
164
228
  };
165
229
  }
166
230
 
231
+ /**
232
+ * Drop expired gates from the data and return the gates removed.
233
+ *
234
+ * A gate is expired when its `expiresAt` is in the past AND its
235
+ * `lastFiredAt` (if set) is also outside the TTL window — high-signal
236
+ * gates that keep firing get continuously renewed and never expire.
237
+ *
238
+ * `expiresAt: null` is treated as "permanent" (used by force-promote /
239
+ * legacy gates without TTL data).
240
+ */
241
+ function expireGates(data, now = Date.now()) {
242
+ const safeData = data && typeof data === 'object'
243
+ ? { version: data.version || 1, gates: Array.isArray(data.gates) ? data.gates : [], promotionLog: Array.isArray(data.promotionLog) ? data.promotionLog : [] }
244
+ : { version: 1, gates: [], promotionLog: [] };
245
+ const ttlMs = getRuleTtlMs();
246
+ const kept = [];
247
+ const expired = [];
248
+ for (const gate of safeData.gates) {
249
+ if (!gate || typeof gate !== 'object') continue;
250
+ // No expiresAt → treat as permanent (manual force-promote, legacy gates).
251
+ if (gate.expiresAt == null) {
252
+ kept.push(gate);
253
+ continue;
254
+ }
255
+ const expiresMs = Date.parse(gate.expiresAt);
256
+ if (!Number.isFinite(expiresMs)) {
257
+ kept.push(gate);
258
+ continue;
259
+ }
260
+ // If last fire is within TTL window, refresh the gate (extend expiresAt).
261
+ const lastFiredMs = gate.lastFiredAt ? Date.parse(gate.lastFiredAt) : NaN;
262
+ if (Number.isFinite(lastFiredMs) && now - lastFiredMs < ttlMs) {
263
+ kept.push({ ...gate, expiresAt: new Date(lastFiredMs + ttlMs).toISOString() });
264
+ continue;
265
+ }
266
+ if (now < expiresMs) {
267
+ kept.push(gate);
268
+ } else {
269
+ expired.push({ id: gate.id, expiresAt: gate.expiresAt, lastFiredAt: gate.lastFiredAt });
270
+ }
271
+ }
272
+ safeData.gates = kept;
273
+ if (expired.length > 0) {
274
+ safeData.promotionLog.push(
275
+ ...expired.map((e) => ({ type: 'expired', gateId: e.id, expiredAt: e.expiresAt, timestamp: new Date(now).toISOString() }))
276
+ );
277
+ }
278
+ return { data: safeData, expired };
279
+ }
280
+
281
+ /**
282
+ * Mark a gate as fired now. Refreshes lastFiredAt AND extends expiresAt by
283
+ * the full TTL — a gate that keeps catching repeats sharpens, doesn't
284
+ * decay. Caller passes the gate ID; returns the updated gate (or null).
285
+ */
286
+ function recordGateFire(data, gateId, now = Date.now()) {
287
+ if (!data || !Array.isArray(data.gates)) return null;
288
+ const idx = data.gates.findIndex((g) => g && g.id === gateId);
289
+ if (idx === -1) return null;
290
+ const gate = data.gates[idx];
291
+ const lastFiredAtIso = new Date(now).toISOString();
292
+ const updated = {
293
+ ...gate,
294
+ lastFiredAt: lastFiredAtIso,
295
+ expiresAt: gate.expiresAt == null ? null : new Date(now + getRuleTtlMs()).toISOString(),
296
+ };
297
+ data.gates[idx] = updated;
298
+ return updated;
299
+ }
300
+
167
301
  function forcePromote(context, action = 'block') {
168
302
  if (!context) throw new Error('context is required for force-promote');
169
303
  const data = loadAutoGates();
@@ -202,9 +336,15 @@ function promote(feedbackLogPath) {
202
336
  const logPath = feedbackLogPath || getFeedbackLogPath();
203
337
  const entries = readJSONL(logPath);
204
338
  const groups = groupNegativeFeedback(entries, WINDOW_DAYS);
205
- const data = loadAutoGates();
206
- const existingIds = new Set(data.gates.map((g) => g.id));
207
- const promotions = [];
339
+ // Expire stale gates BEFORE running the promotion loop so an expiring
340
+ // gate that's about to be re-promoted gets a fresh TTL via the normal
341
+ // path rather than carrying a near-stale expiresAt.
342
+ const { data: expiredData, expired } = expireGates(loadAutoGates());
343
+ const data = expiredData;
344
+ if (expired.length > 0) {
345
+ saveAutoGates(data);
346
+ }
347
+ const promotions = expired.map((e) => ({ type: 'expired', gateId: e.id, expiredAt: e.expiresAt }));
208
348
 
209
349
  for (const group of Object.values(groups)) {
210
350
  if (group.count < WARN_THRESHOLD) continue;
@@ -298,9 +438,15 @@ module.exports = {
298
438
  patternToGateId,
299
439
  buildGateRule,
300
440
  extractPatternKey,
441
+ normalizeCommandSignature,
301
442
  isNegative,
443
+ expireGates,
444
+ recordGateFire,
445
+ getRuleTtlDays,
446
+ getRuleTtlMs,
302
447
  MAX_AUTO_GATES,
303
448
  WARN_THRESHOLD,
304
449
  BLOCK_THRESHOLD,
305
450
  WINDOW_DAYS,
451
+ DEFAULT_RULE_TTL_DAYS,
306
452
  };
@@ -186,48 +186,69 @@ function hookAlreadyPresent(hookArray, command) {
186
186
  * (defaults to process.cwd()).
187
187
  * @returns {{ hooks: Array, removedPaths: string[] }}
188
188
  */
189
+ // Shell-style variable expansion limited to the env vars Claude Code
190
+ // documents for hook commands (CLAUDE_PROJECT_DIR), plus other process env
191
+ // vars. Surrounding ASCII quotes are stripped first so tokens like
192
+ // `"$CLAUDE_PROJECT_DIR"/.claude/hooks/x.sh` resolve correctly.
193
+ function expandShellToken(token, resolveBase) {
194
+ let s = token;
195
+ if (s.startsWith('"') && s.includes('"', 1)) {
196
+ s = s.slice(1, s.indexOf('"', 1)) + s.slice(s.indexOf('"', 1) + 1);
197
+ } else if (s.startsWith("'") && s.includes("'", 1)) {
198
+ s = s.slice(1, s.indexOf("'", 1)) + s.slice(s.indexOf("'", 1) + 1);
199
+ }
200
+ const lookup = (name) => (name === 'CLAUDE_PROJECT_DIR'
201
+ ? process.env.CLAUDE_PROJECT_DIR || resolveBase
202
+ : process.env[name]);
203
+ s = s.replace(/\$\{([A-Za-z_]\w*)\}/g, (_, n) => {
204
+ const v = lookup(n);
205
+ return v == null ? `\${${n}}` : v;
206
+ });
207
+ s = s.replace(/\$([A-Za-z_]\w*)/g, (_, n) => {
208
+ const v = lookup(n);
209
+ return v == null ? `$${n}` : v;
210
+ });
211
+ return s;
212
+ }
213
+
214
+ // Returns the raw (unexpanded) script-path token if the command points at a
215
+ // missing script file, else null. Anything that doesn't look like a file
216
+ // reference, or contains unresolved $VAR after expansion, returns null —
217
+ // caller treats null as "keep the hook" (err on the side of NOT pruning).
218
+ function staleHookPath(command, resolveBase) {
219
+ if (!command) return null;
220
+ const rawFirstToken = command.split(/\s+/)[0];
221
+ const firstToken = expandShellToken(rawFirstToken, resolveBase);
222
+ const looksLikePath =
223
+ firstToken.includes('/') ||
224
+ firstToken.includes('\\') ||
225
+ firstToken.endsWith('.sh');
226
+ if (!looksLikePath) return null;
227
+ if (firstToken.includes('$')) return null;
228
+ const resolved = path.isAbsolute(firstToken)
229
+ ? firstToken
230
+ : path.resolve(resolveBase, firstToken);
231
+ return fs.existsSync(resolved) ? null : rawFirstToken;
232
+ }
233
+
189
234
  function pruneStaleFileHooks(hookArray, baseDir) {
190
235
  if (!Array.isArray(hookArray)) {
191
236
  return { hooks: [], removedPaths: [] };
192
237
  }
193
-
194
238
  const resolveBase = baseDir || process.cwd();
195
239
  const removedPaths = [];
196
-
197
240
  const hooks = hookArray.filter((entry) => {
198
241
  const entryHooks = Array.isArray(entry && entry.hooks) ? entry.hooks : [];
199
- let shouldRemove = false;
200
-
201
242
  for (const hook of entryHooks) {
202
243
  const command = hook && typeof hook.command === 'string' ? hook.command : '';
203
- if (!command) continue;
204
-
205
- // Extract the first token as the potential script path.
206
- const firstToken = command.split(/\s+/)[0];
207
-
208
- // Only treat it as a file reference if it looks like a path.
209
- const looksLikePath =
210
- firstToken.includes('/') ||
211
- firstToken.includes('\\') ||
212
- firstToken.endsWith('.sh');
213
-
214
- if (!looksLikePath) continue;
215
-
216
- // Resolve the path (absolute or relative to baseDir).
217
- const resolved = path.isAbsolute(firstToken)
218
- ? firstToken
219
- : path.resolve(resolveBase, firstToken);
220
-
221
- if (!fs.existsSync(resolved)) {
222
- removedPaths.push(firstToken);
223
- shouldRemove = true;
224
- break;
244
+ const stale = staleHookPath(command, resolveBase);
245
+ if (stale !== null) {
246
+ removedPaths.push(stale);
247
+ return false;
225
248
  }
226
249
  }
227
-
228
- return !shouldRemove;
250
+ return true;
229
251
  });
230
-
231
252
  return { hooks, removedPaths };
232
253
  }
233
254
 
@@ -14,7 +14,15 @@
14
14
  */
15
15
 
16
16
  const { captureFeedback } = require('./feedback-loop');
17
- const { distillFromHistory } = require('./history-distiller');
17
+ const { loadOptionalModule } = require('./private-core-boundary');
18
+ // `history-distiller` is a PRIVATE_CORE_MODULE — present in this checkout and
19
+ // in ThumbGate-Core, but intentionally excluded from the public npm tarball.
20
+ // The hard `require('./history-distiller')` form crashed `hook-auto-capture`
21
+ // in published 1.19.0 with MODULE_NOT_FOUND. Public-shell fallback returns
22
+ // null distillation; caller already handles a null distillResult.
23
+ const { distillFromHistory } = loadOptionalModule('./history-distiller', () => ({
24
+ distillFromHistory: () => null,
25
+ }));
18
26
  const { getRecentLesson, getLessonStats } = require('./lesson-inference');
19
27
 
20
28
  const G = '\x1b[32m';
@@ -1398,7 +1398,20 @@ function captureFeedback(params) {
1398
1398
  if (feedbackEvent.signal === 'negative') {
1399
1399
  try {
1400
1400
  const autoPromote = require('./auto-promote-gates');
1401
- autoPromote.promote(FEEDBACK_LOG_PATH);
1401
+ const promoteResult = autoPromote.promote(FEEDBACK_LOG_PATH);
1402
+ // First-rule activation telemetry: anonymous ping the first time
1403
+ // a prevention rule auto-promotes for this install. Idempotent —
1404
+ // see scripts/activation-tracker.js. Critical for activation funnel
1405
+ // analytics; no rule content, just install_id + days_to_first_rule.
1406
+ if (promoteResult && Array.isArray(promoteResult.promotions) && promoteResult.promotions.length > 0) {
1407
+ try {
1408
+ const { recordFirstRulePromotion } = require('./activation-tracker');
1409
+ recordFirstRulePromotion({
1410
+ promotionCount: promoteResult.promotions.length,
1411
+ totalGates: promoteResult.totalGates,
1412
+ });
1413
+ } catch { /* activation telemetry is non-critical */ }
1414
+ }
1402
1415
  } catch { /* Gate promotion is non-critical */ }
1403
1416
  }
1404
1417