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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +32 -10
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +1 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +39 -2
- package/config/model-candidates.json +31 -0
- package/package.json +33 -8
- package/public/compare.html +12 -0
- package/public/federal.html +375 -0
- package/public/guide.html +2 -2
- package/public/index.html +61 -5
- package/public/learn.html +43 -0
- package/public/numbers.html +2 -2
- package/public/pro.html +3 -22
- package/scripts/activation-tracker.js +127 -0
- package/scripts/auto-promote-gates.js +153 -7
- package/scripts/auto-wire-hooks.js +50 -29
- package/scripts/cli-feedback.js +9 -1
- package/scripts/feedback-loop.js +14 -1
- package/scripts/memory-scope-readiness.js +315 -0
- package/scripts/plausible-server-events.js +162 -0
- package/scripts/rate-limiter.js +11 -0
- package/scripts/seo-gsd.js +75 -2
- package/scripts/statusline-links.js +2 -0
- package/scripts/telemetry-analytics.js +1 -0
- package/src/api/server.js +536 -109
package/public/numbers.html
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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>
|
|
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
|
|
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 || '').
|
|
130
|
+
const ctx = (entry.context || entry.whatWentWrong || '').trim();
|
|
75
131
|
if (ctx.length < 10) return null;
|
|
76
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
package/scripts/cli-feedback.js
CHANGED
|
@@ -14,7 +14,15 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const { captureFeedback } = require('./feedback-loop');
|
|
17
|
-
const {
|
|
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';
|
package/scripts/feedback-loop.js
CHANGED
|
@@ -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
|
|