thumbgate 1.19.0 → 1.21.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 +40 -12
- package/.claude-plugin/plugin.json +15 -6
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +34 -14
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +15 -5
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +131 -3
- package/bin/postinstall.js +19 -13
- package/config/merge-quality-checks.json +0 -1
- package/config/post-deploy-marketing-pages.json +46 -0
- package/package.json +75 -60
- package/public/agent-manager.html +139 -0
- package/public/compare.html +6 -0
- package/public/guide.html +23 -0
- package/public/index.html +100 -127
- package/public/learn.html +31 -0
- package/public/numbers.html +2 -2
- package/public/pricing.html +345 -0
- package/public/pro.html +3 -22
- package/scripts/auto-promote-gates.js +160 -13
- package/scripts/auto-wire-hooks.js +50 -29
- package/scripts/billing.js +64 -0
- package/scripts/cli-feedback.js +9 -1
- package/scripts/context-manager.js +42 -2
- package/scripts/feedback-loop.js +2 -1
- package/scripts/gates-engine.js +133 -7
- package/scripts/license.js +0 -1
- package/scripts/rate-limiter.js +47 -1
- package/scripts/tool-registry.js +28 -0
- package/scripts/verify-marketing-pages-deployed.js +195 -0
- package/src/api/server.js +514 -239
|
@@ -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) {
|
|
@@ -138,19 +193,26 @@ function patternToGateId(key) {
|
|
|
138
193
|
return 'auto-' + key.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').slice(0, 50).toLowerCase();
|
|
139
194
|
}
|
|
140
195
|
|
|
141
|
-
function buildGateRule(group) {
|
|
142
|
-
const action = group.count === 'MANUAL' ? group.manualAction || 'block' : (group.count >= BLOCK_THRESHOLD ? 'block' : 'warn');
|
|
143
|
-
const severity = action === 'block' ? 'critical' : 'medium';
|
|
196
|
+
function buildGateRule(group, actionOverride) {
|
|
197
|
+
const action = actionOverride || (group.count === 'MANUAL' ? group.manualAction || 'block' : (group.count >= BLOCK_THRESHOLD ? 'block' : 'warn'));
|
|
198
|
+
const severity = action === 'block' ? 'critical' : action === 'approve' ? 'high' : 'medium';
|
|
144
199
|
const context = group.latestContext.slice(0, 120);
|
|
145
200
|
const kind = group.key.startsWith('diagnosis:')
|
|
146
201
|
? 'repeated diagnosis'
|
|
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();
|
|
@@ -198,13 +332,20 @@ function forcePromote(context, action = 'block') {
|
|
|
198
332
|
return { gateId, action, totalGates: data.gates.length };
|
|
199
333
|
}
|
|
200
334
|
|
|
201
|
-
function promote(feedbackLogPath) {
|
|
335
|
+
function promote(feedbackLogPath, options) {
|
|
336
|
+
const opts = options || {};
|
|
202
337
|
const logPath = feedbackLogPath || getFeedbackLogPath();
|
|
203
338
|
const entries = readJSONL(logPath);
|
|
204
339
|
const groups = groupNegativeFeedback(entries, WINDOW_DAYS);
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
340
|
+
// Expire stale gates BEFORE running the promotion loop so an expiring
|
|
341
|
+
// gate that's about to be re-promoted gets a fresh TTL via the normal
|
|
342
|
+
// path rather than carrying a near-stale expiresAt.
|
|
343
|
+
const { data: expiredData, expired } = expireGates(loadAutoGates());
|
|
344
|
+
const data = expiredData;
|
|
345
|
+
if (expired.length > 0) {
|
|
346
|
+
saveAutoGates(data);
|
|
347
|
+
}
|
|
348
|
+
const promotions = expired.map((e) => ({ type: 'expired', gateId: e.id, expiredAt: e.expiresAt }));
|
|
208
349
|
|
|
209
350
|
for (const group of Object.values(groups)) {
|
|
210
351
|
if (group.count < WARN_THRESHOLD) continue;
|
|
@@ -226,8 +367,8 @@ function promote(feedbackLogPath) {
|
|
|
226
367
|
continue;
|
|
227
368
|
}
|
|
228
369
|
|
|
229
|
-
// New gate
|
|
230
|
-
const gate = buildGateRule(group);
|
|
370
|
+
// New gate — respect explicit gateAction override (e.g. 'approve' for human-approval rules)
|
|
371
|
+
const gate = buildGateRule(group, opts.gateAction);
|
|
231
372
|
|
|
232
373
|
// Enforce max limit — rotate oldest
|
|
233
374
|
if (data.gates.length >= MAX_AUTO_GATES) {
|
|
@@ -298,9 +439,15 @@ module.exports = {
|
|
|
298
439
|
patternToGateId,
|
|
299
440
|
buildGateRule,
|
|
300
441
|
extractPatternKey,
|
|
442
|
+
normalizeCommandSignature,
|
|
301
443
|
isNegative,
|
|
444
|
+
expireGates,
|
|
445
|
+
recordGateFire,
|
|
446
|
+
getRuleTtlDays,
|
|
447
|
+
getRuleTtlMs,
|
|
302
448
|
MAX_AUTO_GATES,
|
|
303
449
|
WARN_THRESHOLD,
|
|
304
450
|
BLOCK_THRESHOLD,
|
|
305
451
|
WINDOW_DAYS,
|
|
452
|
+
DEFAULT_RULE_TTL_DAYS,
|
|
306
453
|
};
|
|
@@ -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/billing.js
CHANGED
|
@@ -444,6 +444,57 @@ function buildCheckoutProductData({ name, description, appOrigin, planId }) {
|
|
|
444
444
|
};
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
+
/**
|
|
448
|
+
* Verify an ACTIVE Stripe product exists for the given plan name before
|
|
449
|
+
* we let buildSubscriptionPriceData create inline price_data under it.
|
|
450
|
+
*
|
|
451
|
+
* Stripe matches product_data by `name`. If only an archived product
|
|
452
|
+
* matches, new prices created via that path inherit active=false and the
|
|
453
|
+
* generated checkout URL renders "Something went wrong / The page you
|
|
454
|
+
* were looking for could not be found." for the buyer. Stripe Dashboard
|
|
455
|
+
* shows the session as `open` with no email captured — looks like the
|
|
456
|
+
* buyer abandoned, but they were never given a working page.
|
|
457
|
+
*
|
|
458
|
+
* Failing here surfaces the misconfiguration at the first checkout
|
|
459
|
+
* attempt instead of silently breaking every buyer for days.
|
|
460
|
+
*
|
|
461
|
+
* Verified incident: ThumbGate#2188 (May 2026) — 20 sessions abandoned in
|
|
462
|
+
* 7 days, all because the only product named "ThumbGate Pro" matching the
|
|
463
|
+
* inline product_data was archived (prod_UXxOHAfbDsPyRb), while an active
|
|
464
|
+
* product with the same name existed (prod_UW82THPxfNvwKT) that should
|
|
465
|
+
* have been used instead.
|
|
466
|
+
*/
|
|
467
|
+
async function verifyActiveProductForPlan(stripe, planId) {
|
|
468
|
+
const expectedName = planId === 'team' ? 'ThumbGate Team' : 'ThumbGate Pro';
|
|
469
|
+
let products;
|
|
470
|
+
try {
|
|
471
|
+
products = await stripe.products.list({ limit: 100 });
|
|
472
|
+
} catch (err) {
|
|
473
|
+
// Network/transient failures shouldn't block checkout creation.
|
|
474
|
+
// The original session.create call will surface real Stripe errors.
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const matching = (products && products.data ? products.data : [])
|
|
478
|
+
.filter((p) => p && p.name === expectedName);
|
|
479
|
+
if (matching.length === 0) {
|
|
480
|
+
// No product with this name exists; Stripe will create a new one when
|
|
481
|
+
// session.create fires with inline product_data. Safe path.
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const active = matching.find((p) => p.active === true);
|
|
485
|
+
if (!active) {
|
|
486
|
+
const archived = matching[0];
|
|
487
|
+
throw new Error(
|
|
488
|
+
`Refusing to create checkout session: Stripe product named "${expectedName}" ` +
|
|
489
|
+
`exists only in archived state (id=${archived.id}, active=false). New prices ` +
|
|
490
|
+
`created via inline product_data would inherit active=false, rendering ` +
|
|
491
|
+
`"page not found" on Stripe checkout for every buyer. Fix: reactivate the ` +
|
|
492
|
+
`archived product in Stripe Dashboard, or rename the active product to ` +
|
|
493
|
+
`match "${expectedName}". See ThumbGate#2188 for the May 2026 incident.`
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
447
498
|
function buildSubscriptionPriceData(checkoutSelection, appOrigin) {
|
|
448
499
|
const isTeam = checkoutSelection.planId === 'team';
|
|
449
500
|
const annual = checkoutSelection.billingCycle === 'annual';
|
|
@@ -2525,6 +2576,18 @@ async function createCheckoutSession({ successUrl, cancelUrl, customerEmail, ins
|
|
|
2525
2576
|
}
|
|
2526
2577
|
|
|
2527
2578
|
const stripe = getStripeClient();
|
|
2579
|
+
|
|
2580
|
+
// Defensive guard against ThumbGate#2188:
|
|
2581
|
+
// When buildSubscriptionPriceData passes inline `product_data` to Stripe,
|
|
2582
|
+
// Stripe name-matches existing products. If the only existing product with
|
|
2583
|
+
// that name is ARCHIVED (active=false), the new price inherits active=false
|
|
2584
|
+
// and every Stripe checkout page renders "page not found" for the buyer.
|
|
2585
|
+
// That bug burnt 20+ silent abandoned sessions in May 2026. Fail fast
|
|
2586
|
+
// instead of letting the broken page ship.
|
|
2587
|
+
if (!packId) {
|
|
2588
|
+
await verifyActiveProductForPlan(stripe, checkoutSelection.planId);
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2528
2591
|
const sessionPayload = buildCheckoutSessionPayload({
|
|
2529
2592
|
successUrl,
|
|
2530
2593
|
cancelUrl,
|
|
@@ -3127,6 +3190,7 @@ module.exports = {
|
|
|
3127
3190
|
_buildTrialActivationEmail: buildTrialActivationEmail,
|
|
3128
3191
|
_sendTrialActivationEmail: sendTrialActivationEmail,
|
|
3129
3192
|
_resolveSubscriptionCheckoutSelection: resolveSubscriptionCheckoutSelection,
|
|
3193
|
+
_verifyActiveProductForPlan: verifyActiveProductForPlan,
|
|
3130
3194
|
_API_KEYS_PATH: () => CONFIG.API_KEYS_PATH,
|
|
3131
3195
|
_FUNNEL_LEDGER_PATH: () => CONFIG.FUNNEL_LEDGER_PATH,
|
|
3132
3196
|
_REVENUE_LEDGER_PATH: () => CONFIG.REVENUE_LEDGER_PATH,
|
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';
|
|
@@ -111,13 +111,41 @@ function assembleGuards(toolName, toolInput) {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
function assembleContextPack(query, agentProfile) {
|
|
114
|
+
function assembleContextPack(query, agentProfile, options = {}) {
|
|
115
|
+
const { guards } = options;
|
|
115
116
|
try {
|
|
116
117
|
ensureContextFs();
|
|
118
|
+
|
|
119
|
+
// 1. Proactive Governance: Filter what the agent sees based on prevention rules
|
|
120
|
+
let structuredQuery = query;
|
|
121
|
+
if (guards && guards.mode === 'block') {
|
|
122
|
+
structuredQuery = `${query} (Active Block Policy: ${guards.reason})`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 2. Elevate Thompson Sampling to Architecture Level
|
|
126
|
+
let strategy = null;
|
|
127
|
+
try {
|
|
128
|
+
const ts = require('./thompson-sampling');
|
|
129
|
+
const model = ts.loadModel();
|
|
130
|
+
const bestCategory = ts.argmaxPosteriors(model);
|
|
131
|
+
|
|
132
|
+
// Route between context-building strategies based on TS posterior mean
|
|
133
|
+
if (bestCategory === 'architecture' || bestCategory === 'infra') {
|
|
134
|
+
strategy = 'hierarchical';
|
|
135
|
+
} else if (bestCategory === 'observability' || bestCategory === 'debugging') {
|
|
136
|
+
strategy = 'summarize-then-expand';
|
|
137
|
+
} else {
|
|
138
|
+
strategy = 'semantic';
|
|
139
|
+
}
|
|
140
|
+
} catch (e) {
|
|
141
|
+
// Fallback to default routing
|
|
142
|
+
}
|
|
143
|
+
|
|
117
144
|
return constructContextPack({
|
|
118
|
-
query,
|
|
145
|
+
query: structuredQuery,
|
|
119
146
|
maxItems: Math.min(8, Math.ceil(agentProfile.contextBudget / 1000)),
|
|
120
147
|
maxChars: agentProfile.contextBudget,
|
|
148
|
+
strategy
|
|
121
149
|
});
|
|
122
150
|
} catch {
|
|
123
151
|
return null;
|
|
@@ -228,6 +256,12 @@ function assembleUnifiedContext(params = {}) {
|
|
|
228
256
|
})),
|
|
229
257
|
visibility: contextPack.visibility || null,
|
|
230
258
|
cached: !!(contextPack.cache && contextPack.cache.hit),
|
|
259
|
+
layers: {
|
|
260
|
+
localState: session || null,
|
|
261
|
+
graphState: codeGraph || null,
|
|
262
|
+
policyState: guards || null,
|
|
263
|
+
sessionState: contextPack.items ? contextPack.items.filter(i => i.namespace === 'session') : []
|
|
264
|
+
}
|
|
231
265
|
} : null,
|
|
232
266
|
codeGraph: codeGraph || null,
|
|
233
267
|
assembledAt: new Date().toISOString(),
|
|
@@ -306,6 +340,12 @@ function formatUnifiedContext(ctx) {
|
|
|
306
340
|
|
|
307
341
|
// Context pack
|
|
308
342
|
if (ctx.contextPack) {
|
|
343
|
+
lines.push(`### Context Architecture Layers`);
|
|
344
|
+
lines.push(`- Local State: ${ctx.contextPack.layers.localState ? 'Active' : 'Empty'}`);
|
|
345
|
+
lines.push(`- Graph State: ${ctx.contextPack.layers.graphState ? 'Active' : 'Empty'}`);
|
|
346
|
+
lines.push(`- Policy State: ${ctx.contextPack.layers.policyState ? ctx.contextPack.layers.policyState.mode : 'Empty'}`);
|
|
347
|
+
lines.push(`- Session State: ${ctx.contextPack.layers.sessionState.length} items`);
|
|
348
|
+
lines.push('');
|
|
309
349
|
lines.push(`### Context Pack (${ctx.contextPack.itemCount} items)`);
|
|
310
350
|
ctx.contextPack.items.forEach((item) => {
|
|
311
351
|
lines.push(`- [${item.namespace}] ${item.title} (score: ${item.score})`);
|
package/scripts/feedback-loop.js
CHANGED
|
@@ -1059,6 +1059,7 @@ function captureFeedback(params) {
|
|
|
1059
1059
|
: null),
|
|
1060
1060
|
structuredRule: structuredRule || null,
|
|
1061
1061
|
...(reflection && { reflection }),
|
|
1062
|
+
gateAction: params.gateAction || null,
|
|
1062
1063
|
timestamp: now,
|
|
1063
1064
|
};
|
|
1064
1065
|
|
|
@@ -1398,7 +1399,7 @@ function captureFeedback(params) {
|
|
|
1398
1399
|
if (feedbackEvent.signal === 'negative') {
|
|
1399
1400
|
try {
|
|
1400
1401
|
const autoPromote = require('./auto-promote-gates');
|
|
1401
|
-
const promoteResult = autoPromote.promote(FEEDBACK_LOG_PATH);
|
|
1402
|
+
const promoteResult = autoPromote.promote(FEEDBACK_LOG_PATH, { gateAction: feedbackEvent.gateAction });
|
|
1402
1403
|
// First-rule activation telemetry: anonymous ping the first time
|
|
1403
1404
|
// a prevention rule auto-promotes for this install. Idempotent —
|
|
1404
1405
|
// see scripts/activation-tracker.js. Critical for activation funnel
|