thumbgate 1.26.1 → 1.26.3
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/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +2 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +138 -18
- package/config/gates/default.json +11 -2
- package/package.json +2 -3
- package/public/index.html +2 -2
- package/public/numbers.html +2 -2
- package/scripts/agent-readiness.js +20 -3
- package/scripts/cli-feedback.js +4 -2
- package/scripts/commercial-offer.js +0 -3
- package/scripts/feedback-quality.js +87 -0
- package/scripts/gates-engine.js +151 -18
- package/scripts/install-shim.js +14 -11
- package/src/api/server.js +8 -47
- package/scripts/operational-dashboard.js +0 -160
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thumbgate-marketplace",
|
|
3
|
-
"version": "1.26.
|
|
3
|
+
"version": "1.26.0",
|
|
4
4
|
"owner": {
|
|
5
5
|
"name": "Igor Ganapolsky",
|
|
6
6
|
"email": "ig5973700@gmail.com"
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"source": "npm",
|
|
15
15
|
"package": "thumbgate"
|
|
16
16
|
},
|
|
17
|
-
"version": "1.26.
|
|
17
|
+
"version": "1.26.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Igor Ganapolsky",
|
|
20
20
|
"email": "ig5973700@gmail.com",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thumbgate",
|
|
3
3
|
"description": "One 👎 becomes a hard rule the agent cannot bypass. Captures thumbs-down feedback, distills it into PreToolUse Pre-Action Checks, enforced across every future Claude Code session.",
|
|
4
|
-
"version": "1.26.
|
|
4
|
+
"version": "1.26.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Igor Ganapolsky",
|
|
7
7
|
"email": "ig5973700@gmail.com",
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
"mcpServers": {
|
|
3
3
|
"thumbgate": {
|
|
4
4
|
"command": "npx",
|
|
5
|
-
"args": ["--yes", "--package", "thumbgate@1.26.
|
|
5
|
+
"args": ["--yes", "--package", "thumbgate@1.26.0", "thumbgate", "serve"]
|
|
6
6
|
}
|
|
7
7
|
},
|
|
8
8
|
"hooks": {
|
|
9
9
|
"preToolUse": {
|
|
10
10
|
"command": "npx",
|
|
11
|
-
"args": ["--yes", "--package", "thumbgate@1.26.
|
|
11
|
+
"args": ["--yes", "--package", "thumbgate@1.26.0", "thumbgate", "gate-check"]
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
}
|
|
@@ -77,6 +77,7 @@ const {
|
|
|
77
77
|
recordActionAttempt,
|
|
78
78
|
isRepeatAttempt,
|
|
79
79
|
} = require('../../scripts/noop-detect');
|
|
80
|
+
const { recordAuditEvent } = require('../../scripts/audit-trail');
|
|
80
81
|
const {
|
|
81
82
|
recordReceipt,
|
|
82
83
|
getReceiptForAction,
|
|
@@ -230,7 +231,7 @@ const {
|
|
|
230
231
|
finalizeSession: finalizeFeedbackSession,
|
|
231
232
|
} = require('../../scripts/feedback-session');
|
|
232
233
|
|
|
233
|
-
const SERVER_INFO = { name: 'thumbgate-mcp', version: '1.26.
|
|
234
|
+
const SERVER_INFO = { name: 'thumbgate-mcp', version: '1.26.0' };
|
|
234
235
|
const COMMERCE_CATEGORIES = [
|
|
235
236
|
'product_recommendation',
|
|
236
237
|
'brand_compliance',
|
package/bin/cli.js
CHANGED
|
@@ -239,6 +239,21 @@ function parseArgs(argv) {
|
|
|
239
239
|
return args;
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
function parseTtlMs(value, fallbackMs = 5 * 60 * 1000) {
|
|
243
|
+
if (value === undefined || value === null || value === true || value === '') return fallbackMs;
|
|
244
|
+
const raw = String(value).trim().toLowerCase();
|
|
245
|
+
const match = raw.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/);
|
|
246
|
+
if (!match) return fallbackMs;
|
|
247
|
+
const amount = Number(match[1]);
|
|
248
|
+
if (!Number.isFinite(amount) || amount <= 0) return fallbackMs;
|
|
249
|
+
const unit = match[2] || 'ms';
|
|
250
|
+
const factor = unit === 'h' ? 60 * 60 * 1000
|
|
251
|
+
: unit === 'm' ? 60 * 1000
|
|
252
|
+
: unit === 's' ? 1000
|
|
253
|
+
: 1;
|
|
254
|
+
return Math.round(amount * factor);
|
|
255
|
+
}
|
|
256
|
+
|
|
242
257
|
function readStdinText() {
|
|
243
258
|
try {
|
|
244
259
|
return fs.readFileSync(0, 'utf8');
|
|
@@ -1003,10 +1018,21 @@ function capture() {
|
|
|
1003
1018
|
}
|
|
1004
1019
|
|
|
1005
1020
|
let signal = (args.feedback || '').toLowerCase();
|
|
1021
|
+
let consumedSignalArgs = 0;
|
|
1006
1022
|
if (!signal && positionalArgs[0]) {
|
|
1007
|
-
const
|
|
1008
|
-
|
|
1009
|
-
|
|
1023
|
+
const { detectFeedbackSignal } = require(path.join(PKG_ROOT, 'scripts', 'feedback-quality'));
|
|
1024
|
+
const oneWord = positionalArgs[0];
|
|
1025
|
+
const twoWords = positionalArgs.slice(0, 2).join(' ');
|
|
1026
|
+
const detected = detectFeedbackSignal(twoWords) || detectFeedbackSignal(oneWord);
|
|
1027
|
+
if (detected) {
|
|
1028
|
+
signal = detected.signal;
|
|
1029
|
+
consumedSignalArgs = detectFeedbackSignal(twoWords) ? Math.min(2, positionalArgs.length) : 1;
|
|
1030
|
+
} else {
|
|
1031
|
+
const firstPos = positionalArgs[0].toLowerCase();
|
|
1032
|
+
if (['up', 'down', 'thumbsup', 'thumbsdown', 'thumbs_up', 'thumbs_down', 'positive', 'negative'].some(v => firstPos.includes(v))) {
|
|
1033
|
+
signal = firstPos;
|
|
1034
|
+
consumedSignalArgs = 1;
|
|
1035
|
+
}
|
|
1010
1036
|
}
|
|
1011
1037
|
}
|
|
1012
1038
|
|
|
@@ -1026,19 +1052,25 @@ function capture() {
|
|
|
1026
1052
|
}
|
|
1027
1053
|
|
|
1028
1054
|
let context = args.context || '';
|
|
1029
|
-
if (!context &&
|
|
1055
|
+
if (!context && consumedSignalArgs > 0) {
|
|
1056
|
+
context = positionalArgs.slice(consumedSignalArgs).join(' ');
|
|
1057
|
+
} else if (!context && positionalArgs[1]) {
|
|
1030
1058
|
context = positionalArgs[1];
|
|
1031
1059
|
}
|
|
1032
1060
|
|
|
1033
1061
|
let whatWentWrong = args['what-went-wrong'];
|
|
1034
|
-
if (!whatWentWrong && positionalArgs
|
|
1062
|
+
if (!whatWentWrong && consumedSignalArgs > 0 && positionalArgs.length > consumedSignalArgs + 1) {
|
|
1063
|
+
whatWentWrong = positionalArgs.slice(consumedSignalArgs + 1).join(' ');
|
|
1064
|
+
} else if (!whatWentWrong && positionalArgs[2]) {
|
|
1035
1065
|
whatWentWrong = positionalArgs[2];
|
|
1036
1066
|
} else if (!whatWentWrong && normalized === 'down' && context) {
|
|
1037
1067
|
whatWentWrong = context;
|
|
1038
1068
|
}
|
|
1039
1069
|
|
|
1040
1070
|
let whatToChange = args['what-to-change'];
|
|
1041
|
-
if (!whatToChange && positionalArgs
|
|
1071
|
+
if (!whatToChange && consumedSignalArgs > 0 && positionalArgs.length > consumedSignalArgs + 2) {
|
|
1072
|
+
whatToChange = positionalArgs.slice(consumedSignalArgs + 2).join(' ');
|
|
1073
|
+
} else if (!whatToChange && positionalArgs[3]) {
|
|
1042
1074
|
whatToChange = positionalArgs[3];
|
|
1043
1075
|
} else if (!whatToChange && normalized === 'down' && context) {
|
|
1044
1076
|
whatToChange = `avoid: ${context}`;
|
|
@@ -2375,6 +2407,43 @@ function optimize() {
|
|
|
2375
2407
|
doOptimize();
|
|
2376
2408
|
}
|
|
2377
2409
|
|
|
2410
|
+
function cleanup() {
|
|
2411
|
+
console.log('Cleaning up ThumbGate processes...');
|
|
2412
|
+
try {
|
|
2413
|
+
const { execSync } = require('child_process');
|
|
2414
|
+
// Kill all 'thumbgate serve' and 'thumbgate dashboard' processes except this one
|
|
2415
|
+
const pids = execSync("ps aux | grep 'thumbgate' | grep -v 'grep' | awk '{print $2}'", { encoding: 'utf8' })
|
|
2416
|
+
.split('\n')
|
|
2417
|
+
.filter(Boolean)
|
|
2418
|
+
.map(Number)
|
|
2419
|
+
.filter(pid => pid !== process.pid);
|
|
2420
|
+
|
|
2421
|
+
if (pids.length > 0) {
|
|
2422
|
+
console.log(`Killing ${pids.length} process(es): ${pids.join(', ')}`);
|
|
2423
|
+
pids.forEach(pid => {
|
|
2424
|
+
try { process.kill(pid, 'SIGTERM'); } catch (_) {}
|
|
2425
|
+
});
|
|
2426
|
+
// Give them a moment to die
|
|
2427
|
+
execSync('sleep 1');
|
|
2428
|
+
} else {
|
|
2429
|
+
console.log('No other ThumbGate processes found.');
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
// Check port 3456 specifically
|
|
2433
|
+
try {
|
|
2434
|
+
const portPid = execSync("lsof -ti :3456", { encoding: 'utf8' }).trim();
|
|
2435
|
+
if (portPid) {
|
|
2436
|
+
console.log(`Killing process ${portPid} holding port 3456`);
|
|
2437
|
+
try { process.kill(Number(portPid), 'SIGKILL'); } catch (_) {}
|
|
2438
|
+
}
|
|
2439
|
+
} catch (_) { /* port already free */ }
|
|
2440
|
+
|
|
2441
|
+
console.log('✅ Cleanup complete. Run "npx thumbgate pro" to restart the dashboard.');
|
|
2442
|
+
} catch (err) {
|
|
2443
|
+
console.error(`Cleanup failed: ${err.message}`);
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2378
2447
|
function serve() {
|
|
2379
2448
|
try {
|
|
2380
2449
|
const { repairCodexHooks } = require(path.join(PKG_ROOT, 'scripts', 'codex-self-heal'));
|
|
@@ -2411,11 +2480,21 @@ function install() {
|
|
|
2411
2480
|
}
|
|
2412
2481
|
|
|
2413
2482
|
async function gateCheck() {
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2483
|
+
try {
|
|
2484
|
+
const payload = readStdinText();
|
|
2485
|
+
const input = payload ? JSON.parse(payload) : {};
|
|
2486
|
+
const gatesEngine = require(path.join(PKG_ROOT, 'scripts', 'gates-engine'));
|
|
2487
|
+
const output = await gatesEngine.runAsync(input);
|
|
2488
|
+
process.stdout.write(output + '\n');
|
|
2489
|
+
} catch (err) {
|
|
2490
|
+
process.stderr.write(`gate-check error: ${err.message}\n`);
|
|
2491
|
+
process.stdout.write(JSON.stringify({
|
|
2492
|
+
hookSpecificOutput: {
|
|
2493
|
+
hookEventName: 'PreToolUse',
|
|
2494
|
+
additionalContext: `[ThumbGate Error] ${err.message}`,
|
|
2495
|
+
}
|
|
2496
|
+
}) + '\n');
|
|
2497
|
+
}
|
|
2419
2498
|
}
|
|
2420
2499
|
|
|
2421
2500
|
function cacheUpdate() {
|
|
@@ -2442,9 +2521,14 @@ function statuslineRender() {
|
|
|
2442
2521
|
|
|
2443
2522
|
function hookAutoCapture() {
|
|
2444
2523
|
syncActiveProjectContext();
|
|
2445
|
-
const prompt = process.env.CLAUDE_USER_PROMPT
|
|
2524
|
+
const prompt = process.env.CLAUDE_USER_PROMPT
|
|
2525
|
+
|| process.env.THUMBGATE_USER_PROMPT
|
|
2526
|
+
|| process.env.CODEX_USER_PROMPT
|
|
2527
|
+
|| process.env.USER_PROMPT
|
|
2528
|
+
|| readStdinText().trim();
|
|
2446
2529
|
const { evaluatePromptGuard } = require(path.join(PKG_ROOT, 'scripts', 'prompt-guard'));
|
|
2447
2530
|
const { processInlineFeedback, formatCliOutput } = require(path.join(PKG_ROOT, 'scripts', 'cli-feedback'));
|
|
2531
|
+
const { detectFeedbackSignal } = require(path.join(PKG_ROOT, 'scripts', 'feedback-quality'));
|
|
2448
2532
|
const { loadOptionalModule } = require(path.join(PKG_ROOT, 'scripts', 'private-core-boundary'));
|
|
2449
2533
|
const { recordConversationEntry, readRecentConversationWindow } = loadOptionalModule(
|
|
2450
2534
|
path.join(PKG_ROOT, 'scripts', 'feedback-history-distiller'),
|
|
@@ -2466,14 +2550,12 @@ function hookAutoCapture() {
|
|
|
2466
2550
|
return;
|
|
2467
2551
|
}
|
|
2468
2552
|
|
|
2469
|
-
const
|
|
2470
|
-
|
|
2471
|
-
const isDown = /(thumbs?\s*down|that failed|that was wrong|fix this)/i.test(lower);
|
|
2472
|
-
if (!isUp && !isDown) {
|
|
2553
|
+
const detected = detectFeedbackSignal(prompt);
|
|
2554
|
+
if (!detected) {
|
|
2473
2555
|
return;
|
|
2474
2556
|
}
|
|
2475
2557
|
|
|
2476
|
-
const signal =
|
|
2558
|
+
const signal = detected.signal;
|
|
2477
2559
|
const conversationWindow = readRecentConversationWindow({ limit: 8 });
|
|
2478
2560
|
const result = processInlineFeedback({
|
|
2479
2561
|
signal,
|
|
@@ -2673,6 +2755,30 @@ function startApi() {
|
|
|
2673
2755
|
}
|
|
2674
2756
|
}
|
|
2675
2757
|
|
|
2758
|
+
function breakGlass() {
|
|
2759
|
+
const args = parseArgs(process.argv.slice(3));
|
|
2760
|
+
const positionalReason = process.argv.slice(3).find((arg) => !arg.startsWith('--'));
|
|
2761
|
+
const reason = String(args.reason || positionalReason || '').trim();
|
|
2762
|
+
if (!reason) {
|
|
2763
|
+
console.error('Usage: npx thumbgate break-glass --reason "why this recovery is needed" [--ttl=5m] [--json]');
|
|
2764
|
+
process.exit(1);
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
const ttlMs = parseTtlMs(args.ttl, 5 * 60 * 1000);
|
|
2768
|
+
const { breakGlassEmergency } = require(path.join(PKG_ROOT, 'scripts', 'gates-engine'));
|
|
2769
|
+
const result = breakGlassEmergency({ reason, ttlMs });
|
|
2770
|
+
if (args.json) {
|
|
2771
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2772
|
+
return;
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
console.log('ThumbGate break-glass active.');
|
|
2776
|
+
console.log(` Reason : ${result.reason}`);
|
|
2777
|
+
console.log(` Expires : ${result.expiresAt}`);
|
|
2778
|
+
console.log(' Unlocked : hook settings edits, pr_create_allowed, pr_threads_checked');
|
|
2779
|
+
console.log(' Still gated: local-only scope, force-push, protected branch push, unsafe chmod, broad rm -rf');
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2676
2782
|
function help() {
|
|
2677
2783
|
const v = pkgVersion();
|
|
2678
2784
|
const helpArgs = process.argv.slice(3);
|
|
@@ -2695,6 +2801,7 @@ function help() {
|
|
|
2695
2801
|
console.log(' explore Interactive TUI for lessons, gates, stats');
|
|
2696
2802
|
console.log(' dashboard Open the local ThumbGate dashboard');
|
|
2697
2803
|
console.log(' doctor Audit runtime isolation + bootstrap context');
|
|
2804
|
+
console.log(' break-glass --reason="..." Short TTL recovery if gates over-fire');
|
|
2698
2805
|
console.log(' brain [--write] Build the agent-readable context brain (lessons + rules + gates)');
|
|
2699
2806
|
console.log(' pro ThumbGate Pro (dashboard, exports, sync)');
|
|
2700
2807
|
console.log(' subscribe <email> Get the 5-min setup guide + weekly tips by email');
|
|
@@ -2747,6 +2854,7 @@ function help() {
|
|
|
2747
2854
|
console.log(' default: machine-wide (~/.claude/settings.json — shared dashboard)');
|
|
2748
2855
|
console.log(' --project: per-repo (<cwd>/.claude/settings.json — isolated dashboard)');
|
|
2749
2856
|
console.log(' --no-hooks: MCP only, skip hook wiring');
|
|
2857
|
+
console.log(' break-glass Short TTL recovery if gates over-fire');
|
|
2750
2858
|
console.log(' cfo Hosted billing summary (local fallback JSON)');
|
|
2751
2859
|
console.log(' billing:setup Generate operator key + print Railway setup instructions');
|
|
2752
2860
|
console.log(' repair-github-marketplace Repair legacy GitHub Marketplace amount mappings');
|
|
@@ -2842,6 +2950,11 @@ const SUBCOMMAND_HELP = {
|
|
|
2842
2950
|
lessons: 'Usage: npx thumbgate lessons [--query="..."] [--limit=N]\n\nSearch the lesson database (Pro feature).',
|
|
2843
2951
|
search: 'Usage: npx thumbgate search <query>\n\nSearch ThumbGate knowledge base (Pro feature).',
|
|
2844
2952
|
'gate-check': 'Usage: npx thumbgate gate-check\n\nPreToolUse hook interface: reads tool call JSON from stdin, outputs gate verdict.',
|
|
2953
|
+
'break-glass': 'Usage: npx thumbgate break-glass --reason="why" [--ttl=5m] [--json]\n\nShort-lived recovery path for over-firing gates. Allows hook settings edits and satisfies PR-create/thread-check gates without disabling core destructive-action protections.',
|
|
2954
|
+
serve: 'Usage: npx thumbgate serve\n\nStart the MCP stdio server. This is for agent runtimes, not the local HTTP dashboard.',
|
|
2955
|
+
mcp: 'Usage: npx thumbgate mcp\n\nAlias for `thumbgate serve`.',
|
|
2956
|
+
dashboard: 'Usage: npx thumbgate dashboard [--window=today|7d|30d]\n\nPrint the operational dashboard summary. Use `npx thumbgate start-api` for the local HTTP dashboard on :3456.',
|
|
2957
|
+
'start-api': 'Usage: npx thumbgate start-api\n\nStart the local ThumbGate HTTP API/dashboard. Defaults to PORT=8787; use PORT=3456 for statusline localhost links.',
|
|
2845
2958
|
'export-dpo': 'Usage: npx thumbgate export-dpo [--format=jsonl|csv]\n\nExport feedback as DPO training pairs (Pro feature).',
|
|
2846
2959
|
status: 'Usage: npx thumbgate status\n\nShow ThumbGate system health and active configuration.',
|
|
2847
2960
|
watch: 'Usage: npx thumbgate watch\n\nWatch for feedback changes and auto-regenerate prevention rules.',
|
|
@@ -3004,6 +3117,9 @@ switch (COMMAND) {
|
|
|
3004
3117
|
case 'mcp':
|
|
3005
3118
|
serve();
|
|
3006
3119
|
break;
|
|
3120
|
+
case 'cleanup':
|
|
3121
|
+
cleanup();
|
|
3122
|
+
break;
|
|
3007
3123
|
case 'gate-check':
|
|
3008
3124
|
gateCheck().catch((err) => {
|
|
3009
3125
|
console.error(err && err.message ? err.message : err);
|
|
@@ -3058,7 +3174,7 @@ switch (COMMAND) {
|
|
|
3058
3174
|
}
|
|
3059
3175
|
case 'brain': {
|
|
3060
3176
|
const brainArgs = parseArgs(process.argv.slice(3));
|
|
3061
|
-
process.
|
|
3177
|
+
process.exitCode = cmdBrain(brainArgs);
|
|
3062
3178
|
break;
|
|
3063
3179
|
}
|
|
3064
3180
|
case 'billing:setup':
|
|
@@ -3371,6 +3487,10 @@ switch (COMMAND) {
|
|
|
3371
3487
|
case 'status':
|
|
3372
3488
|
status();
|
|
3373
3489
|
break;
|
|
3490
|
+
case 'break-glass':
|
|
3491
|
+
case 'breakglass':
|
|
3492
|
+
breakGlass();
|
|
3493
|
+
break;
|
|
3374
3494
|
case 'funnel':
|
|
3375
3495
|
funnel();
|
|
3376
3496
|
break;
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"id": "task-scope-required",
|
|
24
24
|
"layer": "Decisions",
|
|
25
25
|
"toolNames": ["Bash"],
|
|
26
|
-
"pattern": "^(git\\s+(add|commit|push)|gh\\s+pr\\s+(create|merge)|gh\\s+release\\s+create|git\\s+tag\\b|npm\\s+publish|yarn\\s+publish|pnpm\\s+publish)",
|
|
26
|
+
"pattern": "^(git\\s+(add|commit|push)|gh\\s+pr\\s+(create|merge)|gh\\s+api\\b(?=.*(?:/pulls\\b|repos/[^\\s]+/[^\\s]+/pulls\\b))(?=.*(?:-f\\b|--field\\b|-F\\b|--raw-field\\b|--method\\s+POST\\b|-X\\s+POST\\b))|gh\\s+release\\s+create|git\\s+tag\\b|npm\\s+publish|yarn\\s+publish|pnpm\\s+publish)",
|
|
27
27
|
"requireTaskScope": true,
|
|
28
28
|
"action": "block",
|
|
29
29
|
"message": "Git write, PR, release, and publish operations require an explicit task scope.",
|
|
@@ -72,6 +72,15 @@
|
|
|
72
72
|
"message": "PR creation requires explicit 'pr_create_allowed' satisfaction with evidence of user permission.",
|
|
73
73
|
"severity": "high"
|
|
74
74
|
},
|
|
75
|
+
{
|
|
76
|
+
"id": "gh-api-pr-create-restricted",
|
|
77
|
+
"layer": "Identity",
|
|
78
|
+
"pattern": "gh\\s+api\\b(?=.*(?:/pulls\\b|repos/[^\\s]+/[^\\s]+/pulls\\b))(?=.*(?:-f\\b|--field\\b|-F\\b|--raw-field\\b|--method\\s+POST\\b|-X\\s+POST\\b))",
|
|
79
|
+
"action": "block",
|
|
80
|
+
"unless": "pr_create_allowed",
|
|
81
|
+
"message": "GitHub API PR creation requires explicit 'pr_create_allowed' satisfaction with evidence of user permission. Use the same approval path as gh pr create.",
|
|
82
|
+
"severity": "high"
|
|
83
|
+
},
|
|
75
84
|
{
|
|
76
85
|
"id": "gh-pr-merge-restricted",
|
|
77
86
|
"layer": "Identity",
|
|
@@ -85,7 +94,7 @@
|
|
|
85
94
|
"id": "branch-governance-required",
|
|
86
95
|
"layer": "Decisions",
|
|
87
96
|
"toolNames": ["Bash"],
|
|
88
|
-
"pattern": "^(gh\\s+pr\\s+(create|merge)|gh\\s+release\\s+create|git\\s+tag\\b|npm\\s+publish|yarn\\s+publish|pnpm\\s+publish)",
|
|
97
|
+
"pattern": "^(gh\\s+pr\\s+(create|merge)|gh\\s+api\\b(?=.*(?:/pulls\\b|repos/[^\\s]+/[^\\s]+/pulls\\b))(?=.*(?:-f\\b|--field\\b|-F\\b|--raw-field\\b|--method\\s+POST\\b|-X\\s+POST\\b))|gh\\s+release\\s+create|git\\s+tag\\b|npm\\s+publish|yarn\\s+publish|pnpm\\s+publish)",
|
|
89
98
|
"requireBranchGovernance": true,
|
|
90
99
|
"action": "block",
|
|
91
100
|
"message": "PR, release, and publish actions require explicit branch governance.",
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thumbgate",
|
|
3
|
-
"version": "1.26.
|
|
3
|
+
"version": "1.26.3",
|
|
4
4
|
"description": "ThumbGate self-improving agent governance: thumbs-up/down turns every mistake into a prevention rule and blocks repeat patterns. 36 pre-action checks, budget enforcement, and self-protection for Claude Code, Cursor, Codex, Gemini CLI, and Amp.",
|
|
5
5
|
"homepage": "https://thumbgate.ai",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
|
-
"url": "https://github.com/IgorGanapolsky/ThumbGate.git"
|
|
8
|
+
"url": "git+https://github.com/IgorGanapolsky/ThumbGate.git"
|
|
9
9
|
},
|
|
10
10
|
"bugs": {
|
|
11
11
|
"url": "https://github.com/IgorGanapolsky/ThumbGate/issues"
|
|
@@ -135,7 +135,6 @@
|
|
|
135
135
|
"scripts/natural-language-harness.js",
|
|
136
136
|
"scripts/noop-detect.js",
|
|
137
137
|
"scripts/obsidian-export.js",
|
|
138
|
-
"scripts/operational-dashboard.js",
|
|
139
138
|
"scripts/operational-integrity.js",
|
|
140
139
|
"scripts/oss-pr-opportunity-scout.js",
|
|
141
140
|
"scripts/otel-declarative-config.js",
|
package/public/index.html
CHANGED
|
@@ -20,7 +20,7 @@ __GOOGLE_SITE_VERIFICATION_META__
|
|
|
20
20
|
<meta property="og:image" content="https://thumbgate.ai/og.png">
|
|
21
21
|
<meta name="twitter:card" content="summary_large_image">
|
|
22
22
|
<meta name="twitter:image" content="https://thumbgate.ai/og.png">
|
|
23
|
-
<meta name="thumbgate-version" content="1.26.
|
|
23
|
+
<meta name="thumbgate-version" content="1.26.0">
|
|
24
24
|
<meta name="keywords" content="ThumbGate, thumbgate, AI agent orchestration, AI experience orchestration, agentic development cycle, AC/DC framework, Guide Generate Verify Solve, agent enforcement layer, save LLM tokens, reduce Claude API cost, reduce OpenAI cost, AI agent token savings, prevent LLM retries, prevent hallucination retries, stop AI token waste, pre-action checks, agent governance, Claude Code, Cursor, Codex, Gemini, Amp, Cline, OpenCode, workflow hardening, context engineering, AI authenticity, brand authenticity AI">
|
|
25
25
|
<link rel="canonical" href="__APP_ORIGIN__/">
|
|
26
26
|
<link rel="alternate" type="text/markdown" title="ThumbGate LLM context" href="__APP_ORIGIN__/llm-context.md">
|
|
@@ -1594,7 +1594,7 @@ __GA_BOOTSTRAP__
|
|
|
1594
1594
|
<a href="https://www.linkedin.com/in/igorganapolsky" target="_blank" rel="noopener">LinkedIn</a>
|
|
1595
1595
|
<a href="/blog">Blog</a>
|
|
1596
1596
|
</div>
|
|
1597
|
-
<span class="footer-copy">© 2026 ThumbGate · MIT License · npm v1.26.
|
|
1597
|
+
<span class="footer-copy">© 2026 ThumbGate · MIT License · npm v1.26.0</span>
|
|
1598
1598
|
</div>
|
|
1599
1599
|
</footer>
|
|
1600
1600
|
|
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.26.
|
|
28
|
+
"softwareVersion": "1.26.0",
|
|
29
29
|
"url": "https://thumbgate.ai/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.26.
|
|
205
|
+
<div class="freshness">Updated: 2026-05-07 · Version 1.26.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>
|
|
@@ -94,9 +94,26 @@ function detectRuntimeIsolation() {
|
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
function
|
|
97
|
+
function findProjectRoot(startDir = process.cwd()) {
|
|
98
|
+
try {
|
|
99
|
+
let curr = path.resolve(startDir);
|
|
100
|
+
while (true) {
|
|
101
|
+
const indicators = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.mcp.json', '.git'];
|
|
102
|
+
if (indicators.some((f) => fs.existsSync(path.join(curr, f)))) {
|
|
103
|
+
return curr;
|
|
104
|
+
}
|
|
105
|
+
const parent = path.dirname(curr);
|
|
106
|
+
if (parent === curr) break;
|
|
107
|
+
curr = parent;
|
|
108
|
+
}
|
|
109
|
+
} catch (_) { /* fallback to startDir */ }
|
|
110
|
+
return startDir;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function collectBootstrapFiles(projectRoot) {
|
|
114
|
+
const effectiveRoot = projectRoot || findProjectRoot();
|
|
98
115
|
const files = BOOTSTRAP_FILES.map((file) => {
|
|
99
|
-
const absolutePath = path.join(
|
|
116
|
+
const absolutePath = path.join(effectiveRoot, file.path);
|
|
100
117
|
return {
|
|
101
118
|
id: file.id,
|
|
102
119
|
path: file.path,
|
|
@@ -118,7 +135,7 @@ function collectBootstrapFiles(projectRoot = PROJECT_ROOT) {
|
|
|
118
135
|
missingRequired,
|
|
119
136
|
recommendation: missingRequired.length === 0
|
|
120
137
|
? 'Bootstrap context is present.'
|
|
121
|
-
: `Add missing bootstrap files: ${missingRequired.join(', ')}`,
|
|
138
|
+
: `Add missing bootstrap files to project root (${effectiveRoot}): ${missingRequired.join(', ')}`,
|
|
122
139
|
};
|
|
123
140
|
}
|
|
124
141
|
|
package/scripts/cli-feedback.js
CHANGED
|
@@ -88,11 +88,13 @@ function formatCliOutput(result) {
|
|
|
88
88
|
if (result.feedbackResult && result.feedbackResult.accepted !== false) {
|
|
89
89
|
lines.push(`${isDown ? R : G}${BD}${isDown ? '👎 Thumbs down recorded' : '👍 Thumbs up recorded'}${RST}`);
|
|
90
90
|
const feedbackId = (result.feedbackResult.feedbackEvent && result.feedbackResult.feedbackEvent.id) || result.feedbackResult.id;
|
|
91
|
+
const memoryId = (result.feedbackResult.memoryRecord && result.feedbackResult.memoryRecord.id) || result.feedbackResult.memoryId;
|
|
91
92
|
if (feedbackId) {
|
|
92
|
-
lines.push(`${D} ID: ${feedbackId}${RST}`);
|
|
93
|
+
lines.push(`${D} Feedback ID: ${feedbackId}${RST}`);
|
|
94
|
+
if (memoryId) lines.push(`${D} Memory ID : ${memoryId}${RST}`);
|
|
93
95
|
// Echo feedback ID to stderr so it's visible directly in the terminal,
|
|
94
96
|
// not hidden behind Claude Code's "ctrl+o to expand" MCP call collapse.
|
|
95
|
-
process.stderr.write(`✅ Feedback captured (${feedbackId})\n`);
|
|
97
|
+
process.stderr.write(`✅ Feedback captured (${feedbackId}${memoryId ? `, ${memoryId}` : ''})\n`);
|
|
96
98
|
}
|
|
97
99
|
} else {
|
|
98
100
|
lines.push(`${R}Feedback not accepted: ${(result.feedbackResult && result.feedbackResult.reason) || 'unknown'}${RST}`);
|
|
@@ -68,8 +68,6 @@ function buildCaptureReceipt({ signal, feedbackId, memoryId, actionType } = {})
|
|
|
68
68
|
'',
|
|
69
69
|
` Solo Pro : ${PRO_PRICE_LABEL} for hosted sync, search, dashboard, and exports`,
|
|
70
70
|
` Upgrade : ${trackedProUrl('cli_capture_receipt', actionType || normalizedSignal.toLowerCase())}`,
|
|
71
|
-
` Team path : ${TEAM_PRICE_LABEL}; start with one repeated workflow failure`,
|
|
72
|
-
' https://thumbgate.ai/#workflow-sprint-intake',
|
|
73
71
|
'',
|
|
74
72
|
];
|
|
75
73
|
return lines.join('\n');
|
|
@@ -102,7 +100,6 @@ function buildStatsReceipt(stats = {}) {
|
|
|
102
100
|
lines.push(' Show the buyer : npx thumbgate cost');
|
|
103
101
|
lines.push(' Pro sync value : keep these lessons/rules visible across laptops, CI, containers, and agent runtimes');
|
|
104
102
|
lines.push(` Solo Pro : ${trackedProUrl('cli_stats_receipt', 'proof_seen')}`);
|
|
105
|
-
lines.push(' Team workflow : https://thumbgate.ai/#workflow-sprint-intake');
|
|
106
103
|
lines.push('');
|
|
107
104
|
return lines.join('\n');
|
|
108
105
|
}
|
|
@@ -62,6 +62,92 @@ function normalizeFeedbackText(value) {
|
|
|
62
62
|
.trim();
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
function editDistance(a, b) {
|
|
66
|
+
const left = String(a || '');
|
|
67
|
+
const right = String(b || '');
|
|
68
|
+
const dp = Array.from({ length: left.length + 1 }, () => Array(right.length + 1).fill(0));
|
|
69
|
+
for (let i = 0; i <= left.length; i++) dp[i][0] = i;
|
|
70
|
+
for (let j = 0; j <= right.length; j++) dp[0][j] = j;
|
|
71
|
+
for (let i = 1; i <= left.length; i++) {
|
|
72
|
+
for (let j = 1; j <= right.length; j++) {
|
|
73
|
+
const cost = left[i - 1] === right[j - 1] ? 0 : 1;
|
|
74
|
+
dp[i][j] = Math.min(
|
|
75
|
+
dp[i - 1][j] + 1,
|
|
76
|
+
dp[i][j - 1] + 1,
|
|
77
|
+
dp[i - 1][j - 1] + cost,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return dp[left.length][right.length];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isNearThumbToken(token) {
|
|
85
|
+
const value = String(token || '');
|
|
86
|
+
if (value.length < 4) return false;
|
|
87
|
+
return editDistance(value, 'thumb') <= 1 || editDistance(value, 'thumbs') <= 2;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isNearUpToken(token) {
|
|
91
|
+
const value = String(token || '');
|
|
92
|
+
return value === 'up' || editDistance(value, 'up') <= 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isNearDownToken(token) {
|
|
96
|
+
const value = String(token || '');
|
|
97
|
+
if (value.length < 2) return false;
|
|
98
|
+
return editDistance(value, 'down') <= 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function detectFeedbackSignal(value) {
|
|
102
|
+
const raw = String(value || '');
|
|
103
|
+
if (/[👎👎🏻👎🏼👎🏽👎🏾👎🏿]/u.test(raw)) return { signal: 'down', confidence: 'emoji', match: '👎' };
|
|
104
|
+
if (/[👍👍🏻👍🏼👍🏽👍🏾👍🏿]/u.test(raw)) return { signal: 'up', confidence: 'emoji', match: '👍' };
|
|
105
|
+
|
|
106
|
+
const normalized = normalizeFeedbackText(raw);
|
|
107
|
+
if (!normalized) return null;
|
|
108
|
+
|
|
109
|
+
const exactDown = [
|
|
110
|
+
/\bthumbs?\s*down\b/,
|
|
111
|
+
/\bthumbs?down\b/,
|
|
112
|
+
/\bthat failed\b/,
|
|
113
|
+
/\bit failed\b/,
|
|
114
|
+
/\bthat was wrong\b/,
|
|
115
|
+
/\bfix this\b/,
|
|
116
|
+
];
|
|
117
|
+
if (exactDown.some((pattern) => pattern.test(normalized))) {
|
|
118
|
+
return { signal: 'down', confidence: 'exact', match: normalized };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const exactUp = [
|
|
122
|
+
/\bthumbs?\s*up\b/,
|
|
123
|
+
/\bthumbs?up\b/,
|
|
124
|
+
/\bthat worked\b/,
|
|
125
|
+
/\bit worked\b/,
|
|
126
|
+
/\blooks good\b/,
|
|
127
|
+
/\bgood job\b/,
|
|
128
|
+
/\bgood work\b/,
|
|
129
|
+
/\bnice work\b/,
|
|
130
|
+
/\bperfect\b/,
|
|
131
|
+
/\blgtm\b/,
|
|
132
|
+
];
|
|
133
|
+
if (exactUp.some((pattern) => pattern.test(normalized))) {
|
|
134
|
+
return { signal: 'up', confidence: 'exact', match: normalized };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const words = normalized.split(/\s+/).filter(Boolean);
|
|
138
|
+
for (let i = 0; i < words.length - 1; i++) {
|
|
139
|
+
if (!isNearThumbToken(words[i])) continue;
|
|
140
|
+
if (isNearDownToken(words[i + 1])) {
|
|
141
|
+
return { signal: 'down', confidence: 'fuzzy', match: `${words[i]} ${words[i + 1]}` };
|
|
142
|
+
}
|
|
143
|
+
if (isNearUpToken(words[i + 1])) {
|
|
144
|
+
return { signal: 'up', confidence: 'fuzzy', match: `${words[i]} ${words[i + 1]}` };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
65
151
|
function isGenericFeedbackText(value, signal) {
|
|
66
152
|
const normalized = normalizeFeedbackText(value);
|
|
67
153
|
if (!normalized) return false;
|
|
@@ -131,6 +217,7 @@ function buildClarificationMessage(params = {}) {
|
|
|
131
217
|
|
|
132
218
|
module.exports = {
|
|
133
219
|
GENERIC_PHRASE_RULES,
|
|
220
|
+
detectFeedbackSignal,
|
|
134
221
|
normalizeFeedbackSignal,
|
|
135
222
|
normalizeFeedbackText,
|
|
136
223
|
isGenericFeedbackText,
|
package/scripts/gates-engine.js
CHANGED
|
@@ -109,9 +109,21 @@ const DEFAULT_PROTECTED_FILE_GLOBS = [
|
|
|
109
109
|
const EDIT_LIKE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
|
|
110
110
|
const HIGH_RISK_BASH_PATTERN = /\b(?:git\s+(?:add|commit|push)|gh\s+pr\s+(?:create|merge)|npm\s+publish|yarn\s+publish|pnpm\s+publish|rm\s+-rf)\b/i;
|
|
111
111
|
const REMOTE_SIDE_EFFECT_BASH_PATTERN = /\b(?:git\s+push\b|gh\s+pr\s+(?:create|merge|close|reopen|ready|edit)\b|gh\s+release\s+(?:create|delete|edit|upload)\b|npm\s+publish\b|yarn\s+publish\b|pnpm\s+publish\b)\b/i;
|
|
112
|
+
const GH_API_PR_CREATE_PATTERN = /\bgh\s+api\b(?=.*(?:\/pulls\b|repos\/[^\s]+\/[^\s]+\/pulls\b))(?=.*(?:-f\b|--field\b|-F\b|--raw-field\b|--method\s+POST\b|-X\s+POST\b))/i;
|
|
112
113
|
const BOOSTED_RISK_BLOCK_SCORE = 0.8;
|
|
113
114
|
const BOOSTED_RISK_MIN_EXAMPLES = 3;
|
|
114
115
|
const PR_THREAD_RESOLUTION_ACTION = 'pr_thread_resolution_verified_after_commit';
|
|
116
|
+
const KNOWLEDGE_ENTROPY_THRESHOLD = 0.7;
|
|
117
|
+
const KNOWLEDGE_CONFLICT_STRICT_BASH_PATTERN = /\b(?:git\s+push\b|gh\s+pr\s+merge\b|gh\s+release\s+(?:create|delete|edit|upload)\b|(?:npm|yarn|pnpm)\s+publish\b|rm\s+-rf\b|git\s+reset\s+--hard\b|git\s+clean\s+-f|railway\s+(?:deploy|up)\b|gcloud\s+(?:run\s+deploy|app\s+deploy)\b|firebase\s+deploy\b|vercel\s+--prod\b|kubectl\s+(?:apply|delete)\b|terraform\s+(?:apply|destroy)\b)\b/i;
|
|
118
|
+
const BREAK_GLASS_CONDITION = 'thumbgate_break_glass';
|
|
119
|
+
const BREAK_GLASS_SETTINGS_GLOBS = [
|
|
120
|
+
'.claude/settings.local.json',
|
|
121
|
+
'.claude/settings.json',
|
|
122
|
+
'**/.claude/settings.local.json',
|
|
123
|
+
'**/.claude/settings.json',
|
|
124
|
+
'.codex/config.toml',
|
|
125
|
+
'**/.codex/config.toml',
|
|
126
|
+
];
|
|
115
127
|
|
|
116
128
|
function isRuntimePlanGateEnabled() {
|
|
117
129
|
return process.env.THUMBGATE_PLAN_GATE === '1' || process.env.THUMBGATE_PLAN_GATE === 'true';
|
|
@@ -360,6 +372,38 @@ function approveProtectedAction(input = {}) {
|
|
|
360
372
|
return entry;
|
|
361
373
|
}
|
|
362
374
|
|
|
375
|
+
function breakGlassEmergency(input = {}) {
|
|
376
|
+
const reason = String(input.reason || '').trim();
|
|
377
|
+
if (!reason) {
|
|
378
|
+
throw new Error('reason is required');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const ttlMs = Math.min(clampTtlMs(input.ttlMs, TTL_MS), TTL_MS);
|
|
382
|
+
const evidence = `BREAK GLASS: ${reason}`;
|
|
383
|
+
const gates = ['pr_create_allowed', 'pr_threads_checked', BREAK_GLASS_CONDITION];
|
|
384
|
+
const satisfied = {};
|
|
385
|
+
for (const gateId of gates) {
|
|
386
|
+
satisfied[gateId] = satisfyCondition(gateId, evidence);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const approval = approveProtectedAction({
|
|
390
|
+
pathGlobs: BREAK_GLASS_SETTINGS_GLOBS,
|
|
391
|
+
reason: evidence,
|
|
392
|
+
evidence,
|
|
393
|
+
ttlMs,
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
ok: true,
|
|
398
|
+
reason,
|
|
399
|
+
ttlMs,
|
|
400
|
+
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
|
401
|
+
satisfied,
|
|
402
|
+
approval,
|
|
403
|
+
settingsGlobs: BREAK_GLASS_SETTINGS_GLOBS.slice(),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
363
407
|
function setBranchGovernance(input = {}) {
|
|
364
408
|
if (input && input.clear === true) {
|
|
365
409
|
const state = loadGovernanceState();
|
|
@@ -693,7 +737,7 @@ function extractAffectedFiles(toolName, toolInput = {}) {
|
|
|
693
737
|
}
|
|
694
738
|
}
|
|
695
739
|
|
|
696
|
-
if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command)) {
|
|
740
|
+
if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command) || GH_API_PR_CREATE_PATTERN.test(command)) {
|
|
697
741
|
for (const filePath of getBranchDiffFiles(repoRoot)) {
|
|
698
742
|
files.add(normalizePosix(filePath));
|
|
699
743
|
}
|
|
@@ -712,6 +756,7 @@ function isHighRiskAction(toolName, toolInput = {}, affectedFiles = []) {
|
|
|
712
756
|
const command = String(toolInput.command || '');
|
|
713
757
|
// Original high-risk pattern (git writes, publishes, destructive ops)
|
|
714
758
|
if (HIGH_RISK_BASH_PATTERN.test(command)) return true;
|
|
759
|
+
if (GH_API_PR_CREATE_PATTERN.test(command)) return true;
|
|
715
760
|
// Broadened: any Bash command that modifies files or has side effects.
|
|
716
761
|
// Excludes pure read/analysis commands (node --test, cat, ls, echo, etc.)
|
|
717
762
|
// to avoid false positives on benign operations.
|
|
@@ -949,7 +994,8 @@ function getLocalOnlyScopeSources(governanceState = {}, constraints = {}) {
|
|
|
949
994
|
|
|
950
995
|
function isRemoteSideEffectCommand(toolName, toolInput = {}) {
|
|
951
996
|
if (toolName !== 'Bash') return false;
|
|
952
|
-
|
|
997
|
+
const command = String(toolInput.command || '');
|
|
998
|
+
return REMOTE_SIDE_EFFECT_BASH_PATTERN.test(command) || GH_API_PR_CREATE_PATTERN.test(command);
|
|
953
999
|
}
|
|
954
1000
|
|
|
955
1001
|
function evaluateLocalOnlyRemoteSideEffectGate(toolName, toolInput = {}, governanceState = {}, constraints = {}) {
|
|
@@ -1003,6 +1049,18 @@ function shouldEnforceTaskScope(gate, governanceState, toolName, toolInput = {},
|
|
|
1003
1049
|
return isScopeEnforcedAction(toolName, toolInput, affectedFiles);
|
|
1004
1050
|
}
|
|
1005
1051
|
|
|
1052
|
+
function isAgentHookSettingsFile(filePath) {
|
|
1053
|
+
return matchesAnyGlob(filePath, BREAK_GLASS_SETTINGS_GLOBS);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function isBreakGlassSettingsBypass(gate, affectedFiles) {
|
|
1057
|
+
if (!gate || !['task-scope-edit-boundary', 'protected-file-approval-required'].includes(gate.id)) {
|
|
1058
|
+
return false;
|
|
1059
|
+
}
|
|
1060
|
+
if (!isConditionSatisfied(BREAK_GLASS_CONDITION)) return false;
|
|
1061
|
+
return Array.isArray(affectedFiles) && affectedFiles.length > 0 && affectedFiles.every(isAgentHookSettingsFile);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1006
1064
|
function formatFileList(files, limit = 5) {
|
|
1007
1065
|
const items = Array.isArray(files) ? files.filter(Boolean) : [];
|
|
1008
1066
|
if (items.length === 0) return 'none';
|
|
@@ -1234,6 +1292,12 @@ function matchGate(gate, toolName, toolInput = {}) {
|
|
|
1234
1292
|
try {
|
|
1235
1293
|
const regex = new RegExp(gate.pattern);
|
|
1236
1294
|
if (!regex.test(matchText)) return { matched: false, matchText, affectedFiles };
|
|
1295
|
+
if (gate.id === 'permission-change-approval' && isSafeLocalCredentialHardeningCommand(toolName, toolInput)) {
|
|
1296
|
+
return { matched: false, matchText, affectedFiles };
|
|
1297
|
+
}
|
|
1298
|
+
if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
|
|
1299
|
+
return { matched: false, matchText, affectedFiles };
|
|
1300
|
+
}
|
|
1237
1301
|
} catch {
|
|
1238
1302
|
return { matched: false, matchText, affectedFiles };
|
|
1239
1303
|
}
|
|
@@ -1251,6 +1315,9 @@ function matchGate(gate, toolName, toolInput = {}) {
|
|
|
1251
1315
|
|
|
1252
1316
|
let taskScopeViolation = null;
|
|
1253
1317
|
if (gate.requireTaskScope) {
|
|
1318
|
+
if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
|
|
1319
|
+
return { matched: false, matchText, affectedFiles };
|
|
1320
|
+
}
|
|
1254
1321
|
if (!shouldEnforceTaskScope(gate, governanceState, toolName, toolInput, affectedFiles)) {
|
|
1255
1322
|
return { matched: false, matchText, affectedFiles };
|
|
1256
1323
|
}
|
|
@@ -1260,6 +1327,9 @@ function matchGate(gate, toolName, toolInput = {}) {
|
|
|
1260
1327
|
|
|
1261
1328
|
let protectedApprovalViolation = null;
|
|
1262
1329
|
if (gate.requireProtectedApproval) {
|
|
1330
|
+
if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
|
|
1331
|
+
return { matched: false, matchText, affectedFiles };
|
|
1332
|
+
}
|
|
1263
1333
|
const protectedGlobs = sanitizeGlobList(
|
|
1264
1334
|
Array.isArray(gate.protectedGlobs) && gate.protectedGlobs.length > 0
|
|
1265
1335
|
? gate.protectedGlobs
|
|
@@ -1299,6 +1369,27 @@ function matchesGate(gate, toolName, toolInput) {
|
|
|
1299
1369
|
return matchGate(gate, toolName, toolInput).matched;
|
|
1300
1370
|
}
|
|
1301
1371
|
|
|
1372
|
+
function isSafeLocalCredentialHardeningCommand(toolName, toolInput = {}) {
|
|
1373
|
+
if (toolName !== 'Bash') return false;
|
|
1374
|
+
const command = String(toolInput.command || '').trim();
|
|
1375
|
+
if (!command || /(?:^|\s)chmod\s+[^&;|]*\s+-R\b/i.test(command)) return false;
|
|
1376
|
+
if (/[;&|`$()<>*?[\]{}]/.test(command)) return false;
|
|
1377
|
+
|
|
1378
|
+
const match = command.match(/(?:^|\s)chmod\s+(?:-[fv]\s+)?0?([46]00)\s+(['"]?)(\S+)\2\s*$/i);
|
|
1379
|
+
if (!match) return false;
|
|
1380
|
+
|
|
1381
|
+
const target = match[3];
|
|
1382
|
+
if (!target || target === '/' || target === '~') return false;
|
|
1383
|
+
if (/^\.\.?(?:\/|$)/.test(target)) return false;
|
|
1384
|
+
|
|
1385
|
+
const normalized = target.replace(/^['"]|['"]$/g, '').toLowerCase();
|
|
1386
|
+
const looksLikeCredentialPath = /(?:^|\/)(?:\.config|\.ssh|\.gnupg|\.aws|\.gcloud|\.gemini|\.resume_secrets|\.thumbgate|secrets?|credentials?)(?:\/|$)/.test(normalized)
|
|
1387
|
+
|| /(?:key|secret|token|credential|gemini|gcloud|google|operator).*\.(?:json|pem|key|env)$/i.test(normalized)
|
|
1388
|
+
|| /\.(?:pem|key)$/i.test(normalized);
|
|
1389
|
+
|
|
1390
|
+
return looksLikeCredentialPath;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1302
1393
|
function evaluateMemoryGuard(toolName, toolInput = {}) {
|
|
1303
1394
|
const affected = extractAffectedFiles(toolName, toolInput);
|
|
1304
1395
|
const affectedFiles = affected.files;
|
|
@@ -1315,7 +1406,10 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
|
|
|
1315
1406
|
}
|
|
1316
1407
|
|
|
1317
1408
|
const command = String(toolInput.command || '');
|
|
1318
|
-
|
|
1409
|
+
const isPrCreateCommand = toolName === 'Bash' && (
|
|
1410
|
+
/\bgh\s+pr\s+create\b/i.test(command) || GH_API_PR_CREATE_PATTERN.test(command)
|
|
1411
|
+
);
|
|
1412
|
+
if (isPrCreateCommand && isConditionSatisfied('pr_create_allowed')) {
|
|
1319
1413
|
const branchGovernanceViolation = buildBranchGovernanceViolation(
|
|
1320
1414
|
governanceState,
|
|
1321
1415
|
toolInput,
|
|
@@ -1328,7 +1422,10 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
|
|
|
1328
1422
|
}
|
|
1329
1423
|
}
|
|
1330
1424
|
|
|
1331
|
-
if (toolName === 'Bash' &&
|
|
1425
|
+
if (toolName === 'Bash' && (
|
|
1426
|
+
/\b(?:gh\s+pr\s+(?:create|merge)|gh\s+release\s+create|git\s+tag\b|(?:npm|yarn|pnpm)\s+publish\b)\b/i.test(command) ||
|
|
1427
|
+
GH_API_PR_CREATE_PATTERN.test(command)
|
|
1428
|
+
)) {
|
|
1332
1429
|
const branchGovernanceViolation = buildBranchGovernanceViolation(
|
|
1333
1430
|
governanceState,
|
|
1334
1431
|
toolInput,
|
|
@@ -1989,12 +2086,19 @@ function isApprovalGatesEnabled() {
|
|
|
1989
2086
|
// PreToolUse hook interface (stdin/stdout JSON)
|
|
1990
2087
|
// ---------------------------------------------------------------------------
|
|
1991
2088
|
|
|
1992
|
-
function
|
|
2089
|
+
function buildPreToolUseOutput(fields = {}) {
|
|
1993
2090
|
return {
|
|
2091
|
+
hookEventName: 'PreToolUse',
|
|
2092
|
+
...fields,
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
function buildReminderOutput(context) {
|
|
2097
|
+
return buildPreToolUseOutput({
|
|
1994
2098
|
additionalContext: context,
|
|
1995
2099
|
systemReminder: context,
|
|
1996
2100
|
thumbgateSystemReminder: context,
|
|
1997
|
-
};
|
|
2101
|
+
});
|
|
1998
2102
|
}
|
|
1999
2103
|
|
|
2000
2104
|
// ---------------------------------------------------------------------------
|
|
@@ -2047,6 +2151,7 @@ function formatOutput(result, behavioralContext) {
|
|
|
2047
2151
|
const proCta = buildBlockActionProCta() || '';
|
|
2048
2152
|
return JSON.stringify({
|
|
2049
2153
|
hookSpecificOutput: {
|
|
2154
|
+
hookEventName: 'PreToolUse',
|
|
2050
2155
|
...reminder,
|
|
2051
2156
|
permissionDecision: 'deny',
|
|
2052
2157
|
permissionDecisionReason: `[GATE:${result.gate}] ${result.message}${reasoningSuffix}${reminderSuffix}${proCta}`,
|
|
@@ -2059,6 +2164,7 @@ function formatOutput(result, behavioralContext) {
|
|
|
2059
2164
|
const reminderSuffix = behavioralContext ? `\n\nSystem reminder:\n${behavioralContext}` : '';
|
|
2060
2165
|
return JSON.stringify({
|
|
2061
2166
|
hookSpecificOutput: {
|
|
2167
|
+
hookEventName: 'PreToolUse',
|
|
2062
2168
|
...reminder,
|
|
2063
2169
|
permissionDecision: 'deny',
|
|
2064
2170
|
permissionDecisionReason: `[GATE:${result.gate}] APPROVAL REQUIRED: ${result.message} — Ask the human to confirm this action before proceeding.${reasoningSuffix}${reminderSuffix}`,
|
|
@@ -2071,6 +2177,7 @@ function formatOutput(result, behavioralContext) {
|
|
|
2071
2177
|
const context = `[GATE:${result.gate}] WARNING: ${result.message}${reasoningSuffix}${extra}`;
|
|
2072
2178
|
return JSON.stringify({
|
|
2073
2179
|
hookSpecificOutput: {
|
|
2180
|
+
hookEventName: 'PreToolUse',
|
|
2074
2181
|
additionalContext: context,
|
|
2075
2182
|
...(behavioralContext ? {
|
|
2076
2183
|
systemReminder: behavioralContext,
|
|
@@ -2204,9 +2311,8 @@ function buildRelevantLessonContext(toolName, toolInput) {
|
|
|
2204
2311
|
const lessons = retrieveRelevantLessons(toolName, actionContext, { maxResults: 3 });
|
|
2205
2312
|
|
|
2206
2313
|
const entropy = calculateRetrievalEntropy(lessons);
|
|
2207
|
-
if (entropy >
|
|
2208
|
-
|
|
2209
|
-
return { decision: "deny", gate: "knowledge-conflict-gate", message: "✗ THUMBGATE: Action blocked due to high Knowledge Entropy (conflicting past lessons).", severity: "high" };
|
|
2314
|
+
if (entropy > KNOWLEDGE_ENTROPY_THRESHOLD) {
|
|
2315
|
+
return buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy);
|
|
2210
2316
|
}
|
|
2211
2317
|
return formatNegativeLessonContext(lessons);
|
|
2212
2318
|
} catch {
|
|
@@ -2237,16 +2343,11 @@ async function buildRelevantLessonContextAsync(toolName, toolInput) {
|
|
|
2237
2343
|
: retrieveRelevantLessons(toolName, actionContext, { maxResults: 3 });
|
|
2238
2344
|
|
|
2239
2345
|
// Knowledge Conflict Detection: if retrieved lessons have high sentiment entropy,
|
|
2240
|
-
// it indicates conflicting past evidence.
|
|
2346
|
+
// it indicates conflicting past evidence. Warn by default; hard-block only in
|
|
2347
|
+
// strict mode for external/destructive side-effect commands.
|
|
2241
2348
|
const entropy = calculateRetrievalEntropy(lessons);
|
|
2242
|
-
if (entropy >
|
|
2243
|
-
|
|
2244
|
-
return {
|
|
2245
|
-
decision: 'deny',
|
|
2246
|
-
gate: 'knowledge-conflict-gate',
|
|
2247
|
-
message: '✗ THUMBGATE: Action blocked due to high Knowledge Entropy (conflicting past lessons). Please disambiguate your instructions or verify the intended behavior manually.',
|
|
2248
|
-
severity: 'high',
|
|
2249
|
-
};
|
|
2349
|
+
if (entropy > KNOWLEDGE_ENTROPY_THRESHOLD) {
|
|
2350
|
+
return buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy);
|
|
2250
2351
|
}
|
|
2251
2352
|
|
|
2252
2353
|
return formatNegativeLessonContext(lessons);
|
|
@@ -2273,6 +2374,36 @@ function formatNegativeLessonContext(lessons) {
|
|
|
2273
2374
|
return `[ThumbGate] Past mistakes relevant to this action — read before proceeding:\n${formatted.join('\n')}`;
|
|
2274
2375
|
}
|
|
2275
2376
|
|
|
2377
|
+
function isStrictKnowledgeConflictMode() {
|
|
2378
|
+
return process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === '1'
|
|
2379
|
+
|| process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === 'true';
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
function isKnowledgeConflictHardBlockAction(toolName, toolInput = {}) {
|
|
2383
|
+
if (!isStrictKnowledgeConflictMode()) return false;
|
|
2384
|
+
if (EDIT_LIKE_TOOLS.has(toolName)) return true;
|
|
2385
|
+
if (toolName !== 'Bash') return false;
|
|
2386
|
+
return KNOWLEDGE_CONFLICT_STRICT_BASH_PATTERN.test(String(toolInput.command || ''));
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
function buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy) {
|
|
2390
|
+
const lessonContext = formatNegativeLessonContext(lessons);
|
|
2391
|
+
const message = `Knowledge conflict warning: retrieved lessons disagree for this action (entropy ${entropy}). Treat the reminders below as cautionary context, but do not stop unrelated work solely because memory is noisy.`;
|
|
2392
|
+
|
|
2393
|
+
if (isKnowledgeConflictHardBlockAction(toolName, toolInput)) {
|
|
2394
|
+
recordStat('retrieval_entropy_high', 'block');
|
|
2395
|
+
return {
|
|
2396
|
+
decision: 'deny',
|
|
2397
|
+
gate: 'knowledge-conflict-gate',
|
|
2398
|
+
message: `✗ THUMBGATE: ${message} Strict mode is enabled for destructive or external side-effect actions; verify intent or narrow the task before proceeding.`,
|
|
2399
|
+
severity: 'high',
|
|
2400
|
+
};
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
recordStat('retrieval_entropy_high', 'warn');
|
|
2404
|
+
return mergeContextStrings(`[ThumbGate] ${message}`, lessonContext);
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2276
2407
|
function extractActionContext(toolName, toolInput) {
|
|
2277
2408
|
if (!toolInput) return toolName;
|
|
2278
2409
|
const parts = [toolName];
|
|
@@ -2659,6 +2790,7 @@ module.exports = {
|
|
|
2659
2790
|
setTaskScope,
|
|
2660
2791
|
setBranchGovernance,
|
|
2661
2792
|
approveProtectedAction,
|
|
2793
|
+
breakGlassEmergency,
|
|
2662
2794
|
getScopeState,
|
|
2663
2795
|
getBranchGovernanceState,
|
|
2664
2796
|
isConditionSatisfied,
|
|
@@ -2717,6 +2849,7 @@ module.exports = {
|
|
|
2717
2849
|
getLocalOnlyScopeSources,
|
|
2718
2850
|
isRemoteSideEffectCommand,
|
|
2719
2851
|
evaluateLocalOnlyRemoteSideEffectGate,
|
|
2852
|
+
isAgentHookSettingsFile,
|
|
2720
2853
|
PR_THREAD_RESOLUTION_ACTION,
|
|
2721
2854
|
buildBlockActionProCta,
|
|
2722
2855
|
applyDailyBlockCap,
|
package/scripts/install-shim.js
CHANGED
|
@@ -3,13 +3,12 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* install-shim.js — Install a stable shim at ~/.thumbgate/bin/thumbgate-hook
|
|
5
5
|
*
|
|
6
|
-
* The shim is a tiny shell script that
|
|
7
|
-
* so hook commands in settings.local.json
|
|
8
|
-
*
|
|
9
|
-
* across thumbgate upgrades.
|
|
6
|
+
* The shim is a tiny shell script that resolves the cached ThumbGate runtime
|
|
7
|
+
* first, so hook commands in settings.local.json stay stable across projects
|
|
8
|
+
* and agent restarts.
|
|
10
9
|
*
|
|
11
10
|
* The shim checks for a cached runtime binary first (fast path), and falls
|
|
12
|
-
* back to `npx --yes thumbgate@latest` (slow path, self-
|
|
11
|
+
* back to `npx --yes thumbgate@latest` (slow path, first-time self-install).
|
|
13
12
|
*/
|
|
14
13
|
|
|
15
14
|
const fs = require('fs');
|
|
@@ -24,9 +23,10 @@ const RUNTIME_BIN = path.join(os.homedir(), '.thumbgate', 'runtime', 'node_modul
|
|
|
24
23
|
* The shim script. Key design choices:
|
|
25
24
|
* - Uses `exec` to replace the shell process (no zombie processes)
|
|
26
25
|
* - Fast path: if cached runtime binary exists, exec it directly
|
|
27
|
-
* - Slow path: npx --yes thumbgate@latest (auto-installs)
|
|
28
|
-
* -
|
|
29
|
-
*
|
|
26
|
+
* - Slow path: npx --yes thumbgate@latest (first-time auto-installs)
|
|
27
|
+
* - No default self-mutation: background upgrades are opt-in via
|
|
28
|
+
* THUMBGATE_SHIM_AUTO_UPDATE=1 so source checkouts, enterprise pins, and
|
|
29
|
+
* dogfood runtimes cannot be overwritten by a hook side effect.
|
|
30
30
|
*/
|
|
31
31
|
function shimContent() {
|
|
32
32
|
const escapedRuntimeBin = JSON.stringify(RUNTIME_BIN);
|
|
@@ -35,7 +35,7 @@ function shimContent() {
|
|
|
35
35
|
return `#!/usr/bin/env bash
|
|
36
36
|
# ThumbGate hook shim — DO NOT EDIT
|
|
37
37
|
# Installed by: thumbgate init
|
|
38
|
-
# Purpose:
|
|
38
|
+
# Purpose: stable hook entry point that runs the cached ThumbGate runtime
|
|
39
39
|
# Pattern: Volta-style stable shim (see https://volta.sh)
|
|
40
40
|
|
|
41
41
|
set -euo pipefail
|
|
@@ -45,8 +45,11 @@ RUNTIME_DIR=${escapedRuntimeDir}
|
|
|
45
45
|
|
|
46
46
|
# Fast path: cached runtime binary exists and is executable
|
|
47
47
|
if [ -x "$RUNTIME_BIN" ]; then
|
|
48
|
-
#
|
|
49
|
-
|
|
48
|
+
# Optional background upgrade. Disabled by default so hooks never mutate a
|
|
49
|
+
# source checkout, enterprise pin, or dogfood runtime behind the operator's back.
|
|
50
|
+
if [ "\${THUMBGATE_SHIM_AUTO_UPDATE:-0}" = "1" ]; then
|
|
51
|
+
( nohup npm install --prefix "$RUNTIME_DIR" --no-save --omit=dev thumbgate@latest >/dev/null 2>&1 & ) 2>/dev/null || true
|
|
52
|
+
fi
|
|
50
53
|
exec "$RUNTIME_BIN" "$@"
|
|
51
54
|
fi
|
|
52
55
|
|
package/src/api/server.js
CHANGED
|
@@ -418,34 +418,18 @@ const TRACKED_LINK_TARGETS = Object.freeze({
|
|
|
418
418
|
},
|
|
419
419
|
allowCustomerEmail: true,
|
|
420
420
|
},
|
|
421
|
-
// 2026-
|
|
422
|
-
// marketplaces and old outreach, but route it to workflow scope first
|
|
423
|
-
// instead of blind 3-seat checkout.
|
|
421
|
+
// 2026-06-02: Teams/Aiventyx deprecated. Redirect legacy links to Pro.
|
|
424
422
|
teams: {
|
|
425
|
-
path: '
|
|
426
|
-
ctaId: '
|
|
423
|
+
path: '/go/pro?utm_source=legacy_teams&utm_medium=redirect',
|
|
424
|
+
ctaId: 'go_pro',
|
|
427
425
|
ctaPlacement: 'link_router',
|
|
428
|
-
eventType: '
|
|
429
|
-
defaults: {
|
|
430
|
-
utm_source: 'website',
|
|
431
|
-
utm_medium: 'link_router',
|
|
432
|
-
utm_campaign: 'team_intake',
|
|
433
|
-
plan_id: 'team',
|
|
434
|
-
},
|
|
426
|
+
eventType: 'cta_click',
|
|
435
427
|
},
|
|
436
|
-
// Aliases: /go/team → same as /go/teams, /go/checkout → same as /go/pro,
|
|
437
|
-
// /go/trial → install guide (trial starts on init)
|
|
438
428
|
team: {
|
|
439
|
-
path: '
|
|
440
|
-
ctaId: '
|
|
429
|
+
path: '/go/pro?utm_source=legacy_teams&utm_medium=redirect',
|
|
430
|
+
ctaId: 'go_pro',
|
|
441
431
|
ctaPlacement: 'link_router',
|
|
442
|
-
eventType: '
|
|
443
|
-
defaults: {
|
|
444
|
-
utm_source: 'website',
|
|
445
|
-
utm_medium: 'link_router',
|
|
446
|
-
utm_campaign: 'team_intake',
|
|
447
|
-
plan_id: 'team',
|
|
448
|
-
},
|
|
432
|
+
eventType: 'cta_click',
|
|
449
433
|
},
|
|
450
434
|
checkout: {
|
|
451
435
|
path: '/checkout/pro',
|
|
@@ -6405,30 +6389,7 @@ ${hidden}
|
|
|
6405
6389
|
<h1>Case Studies</h1>
|
|
6406
6390
|
<p class="lede">Real integrations. No fabricated logos, no aspirational numbers — every claim below is reproducible.</p>
|
|
6407
6391
|
|
|
6408
|
-
<
|
|
6409
|
-
<h2>Aiventyx marketplace — Teams listing intake recovery</h2>
|
|
6410
|
-
<p class="meta">Integration partner: <a href="https://www.aiventyx.com">Aiventyx</a> · Reported by: Qaiser Mehdi · Verified: 2026-05-13</p>
|
|
6411
|
-
|
|
6412
|
-
<h3>The problem</h3>
|
|
6413
|
-
<p>Aiventyx is a marketplace for AI tools. ThumbGate's Teams listing was their highest-CTR external surface — <span class="metric">62% CTR</span> (5 clicks on 8 views, May 7–9 window). When their integrator rolled out canonical tracked URLs, every Teams click started landing on:</p>
|
|
6414
|
-
<p><code>{"error":"Tracked link not found","allowed":["gpt","pro","install","reddit","linkedin","x","github"]}</code></p>
|
|
6415
|
-
<p>The <code>/go/teams</code> slug wasn't registered in our redirector — a 404 was eating every paid-intent click from their strongest external surface.</p>
|
|
6416
|
-
|
|
6417
|
-
<h3>The fix</h3>
|
|
6418
|
-
<p>Added <code>teams</code> to <code>TRACKED_LINK_TARGETS</code> and now routes it to <code>/?plan_id=team#workflow-sprint-intake</code>. Caller-supplied UTMs flow through to the intake path so the workflow, owner, and proof boundary are explicit before any Team checkout.</p>
|
|
6419
|
-
|
|
6420
|
-
<h3>The verification</h3>
|
|
6421
|
-
<p>Qaiser's own incognito test, May 13 6:04 AM (full email on record):</p>
|
|
6422
|
-
<p><code>https://thumbgate.ai/go/teams?utm_source=aiventyx</code><br>
|
|
6423
|
-
→ 302 to the Team workflow intake<br>
|
|
6424
|
-
→ pricing source, campaign, and plan metadata preserved<br>
|
|
6425
|
-
→ buyer sees the scope-first path before any subscription decision</p>
|
|
6426
|
-
|
|
6427
|
-
<h3>What this proves</h3>
|
|
6428
|
-
<p>End-to-end attribution from a third-party marketplace through ThumbGate's redirector into the Team intake path, with the caller's UTM chain preserved. Regression tests pin the redirect contract so it can't silently break or regress into a blind Team checkout.</p>
|
|
6429
|
-
|
|
6430
|
-
<p><a href="/go/teams?utm_source=case-study">Try the live redirect →</a></p>
|
|
6431
|
-
</article>
|
|
6392
|
+
<p><em>New case studies for individual Pro operators coming soon.</em></p>
|
|
6432
6393
|
|
|
6433
6394
|
<footer>
|
|
6434
6395
|
<p>Want to be the next case study? The product is real, the integration is 30 seconds: <code>npx thumbgate init</code>. If you ship something with ThumbGate and want it documented here, email <a href="mailto:igor.ganapolsky@gmail.com">igor.ganapolsky@gmail.com</a>.</p>
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const { resolveAnalyticsWindow } = require('./analytics-window');
|
|
4
|
-
const { getBillingSummaryLive } = require('./billing');
|
|
5
|
-
const { generateDashboard } = require('./dashboard');
|
|
6
|
-
const { getFeedbackPaths } = require('./feedback-loop');
|
|
7
|
-
const { resolveHostedBillingConfig } = require('./hosted-config');
|
|
8
|
-
const { loadOperatorConfig } = require('./operational-summary');
|
|
9
|
-
|
|
10
|
-
function normalizeText(value) {
|
|
11
|
-
if (value === undefined || value === null) return null;
|
|
12
|
-
const text = String(value).trim();
|
|
13
|
-
return text || null;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function shouldPreferHostedDashboard() {
|
|
17
|
-
return String(process.env.THUMBGATE_METRICS_SOURCE || '').trim().toLowerCase() !== 'local';
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function resolveHostedDashboardConfig() {
|
|
21
|
-
const runtimeConfig = resolveHostedBillingConfig();
|
|
22
|
-
const operatorConfig = loadOperatorConfig();
|
|
23
|
-
// Match operational-summary's key priority chain so north-star and cfo
|
|
24
|
-
// authenticate against the same hosted deployment consistently. Prior to
|
|
25
|
-
// this change, north-star only read THUMBGATE_API_KEY, silently 401'ing
|
|
26
|
-
// on machines configured via operator.json or THUMBGATE_OPERATOR_KEY.
|
|
27
|
-
const apiKey = normalizeText(process.env.THUMBGATE_OPERATOR_KEY)
|
|
28
|
-
|| operatorConfig.operatorKey
|
|
29
|
-
|| normalizeText(process.env.THUMBGATE_API_KEY);
|
|
30
|
-
const apiBaseUrl = normalizeText(process.env.THUMBGATE_BILLING_API_BASE_URL)
|
|
31
|
-
|| operatorConfig.baseUrl
|
|
32
|
-
|| runtimeConfig.billingApiBaseUrl;
|
|
33
|
-
return {
|
|
34
|
-
apiBaseUrl,
|
|
35
|
-
apiKey,
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function buildOperationalDashboard(options = {}) {
|
|
40
|
-
const analyticsWindow = resolveAnalyticsWindow(options);
|
|
41
|
-
const feedbackDir = options.feedbackDir || getFeedbackPaths().FEEDBACK_DIR;
|
|
42
|
-
const billingSummary = await getBillingSummaryLive(analyticsWindow);
|
|
43
|
-
|
|
44
|
-
return generateDashboard(feedbackDir, {
|
|
45
|
-
analyticsWindow,
|
|
46
|
-
billingSummary,
|
|
47
|
-
billingSource: 'live',
|
|
48
|
-
billingFallbackReason: null,
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async function fetchHostedDashboard(options = {}, config = resolveHostedDashboardConfig()) {
|
|
53
|
-
const analyticsWindow = resolveAnalyticsWindow(options);
|
|
54
|
-
if (!shouldPreferHostedDashboard()) {
|
|
55
|
-
const err = new Error('Hosted operational dashboard is disabled.');
|
|
56
|
-
err.code = 'hosted_dashboard_disabled';
|
|
57
|
-
throw err;
|
|
58
|
-
}
|
|
59
|
-
if (!config.apiBaseUrl || !config.apiKey) {
|
|
60
|
-
const err = new Error('Hosted operational dashboard is not configured.');
|
|
61
|
-
err.code = 'hosted_dashboard_unconfigured';
|
|
62
|
-
throw err;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const requestUrl = new URL('/v1/dashboard', config.apiBaseUrl);
|
|
66
|
-
requestUrl.searchParams.set('window', analyticsWindow.window);
|
|
67
|
-
requestUrl.searchParams.set('timezone', analyticsWindow.timeZone);
|
|
68
|
-
requestUrl.searchParams.set('now', analyticsWindow.now);
|
|
69
|
-
|
|
70
|
-
const response = await fetch(requestUrl, {
|
|
71
|
-
method: 'GET',
|
|
72
|
-
headers: {
|
|
73
|
-
authorization: `Bearer ${config.apiKey}`,
|
|
74
|
-
accept: 'application/json',
|
|
75
|
-
},
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
if (!response.ok) {
|
|
79
|
-
const detail = await response.text().catch(() => '');
|
|
80
|
-
const err = new Error(`Hosted operational dashboard request failed (${response.status}): ${detail || 'unknown error'}`);
|
|
81
|
-
err.code = 'hosted_dashboard_http_error';
|
|
82
|
-
err.status = response.status;
|
|
83
|
-
throw err;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return response.json();
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async function getOperationalDashboard(options = {}) {
|
|
90
|
-
const analyticsWindow = resolveAnalyticsWindow(options);
|
|
91
|
-
try {
|
|
92
|
-
const data = await fetchHostedDashboard(analyticsWindow);
|
|
93
|
-
return {
|
|
94
|
-
source: 'hosted',
|
|
95
|
-
data,
|
|
96
|
-
fallbackReason: null,
|
|
97
|
-
hostedStatus: 200,
|
|
98
|
-
};
|
|
99
|
-
} catch (err) {
|
|
100
|
-
const reason = err && err.message ? err.message : 'hosted_dashboard_unavailable';
|
|
101
|
-
const status = err && typeof err.status === 'number' ? err.status : null;
|
|
102
|
-
const code = err && err.code ? err.code : null;
|
|
103
|
-
|
|
104
|
-
// Hosted deliberately disabled or never configured — local fallback is
|
|
105
|
-
// intentional, not a degraded state. Tag as plain 'local'.
|
|
106
|
-
if (code === 'hosted_dashboard_disabled' || code === 'hosted_dashboard_unconfigured') {
|
|
107
|
-
return {
|
|
108
|
-
source: 'local',
|
|
109
|
-
data: await buildOperationalDashboard(analyticsWindow),
|
|
110
|
-
fallbackReason: reason,
|
|
111
|
-
hostedStatus: null,
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Mirror operational-summary: auth failure is the dangerous case. A
|
|
116
|
-
// dashboard that silently shows $0 revenue (from the local ledger) when
|
|
117
|
-
// Stripe actually has paid customers is a lie the operator acts on.
|
|
118
|
-
// Refuse to guess — surface an actionable error.
|
|
119
|
-
if (status === 401 || status === 403) {
|
|
120
|
-
const authErr = new Error(
|
|
121
|
-
`Hosted operational dashboard rejected credentials (HTTP ${status}). ` +
|
|
122
|
-
`The operator key on this machine does not match the one on the ` +
|
|
123
|
-
`hosted deployment. Fix: set THUMBGATE_OPERATOR_KEY in this shell, ` +
|
|
124
|
-
`or update the operatorKey field in ~/.config/thumbgate/operator.json, ` +
|
|
125
|
-
`to match Railway's THUMBGATE_OPERATOR_KEY. ` +
|
|
126
|
-
`Running north-star without hosted auth would report local-only ` +
|
|
127
|
-
`data as ground truth, which may not reflect actual Stripe revenue. ` +
|
|
128
|
-
`Original response: ${reason}`
|
|
129
|
-
);
|
|
130
|
-
authErr.code = 'hosted_dashboard_unauthorized';
|
|
131
|
-
authErr.status = status;
|
|
132
|
-
throw authErr;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Non-auth failure — local fallback is still useful for dev workflows,
|
|
136
|
-
// but tag the source so downstream renderers do not mistake it for
|
|
137
|
-
// verified hosted truth.
|
|
138
|
-
//
|
|
139
|
-
// Log only the status code (trusted) — the full reason contains upstream
|
|
140
|
-
// response text and is only returned structurally via fallbackReason.
|
|
141
|
-
console.warn(
|
|
142
|
-
`[operational-dashboard] Hosted dashboard unreachable (status=${status ?? 'network'}); ` +
|
|
143
|
-
`falling back to LOCAL-UNVERIFIED state. Numbers below may not reflect actual Stripe revenue.`
|
|
144
|
-
);
|
|
145
|
-
return {
|
|
146
|
-
source: 'local-unverified',
|
|
147
|
-
data: await buildOperationalDashboard(analyticsWindow),
|
|
148
|
-
fallbackReason: reason,
|
|
149
|
-
hostedStatus: status,
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
module.exports = {
|
|
155
|
-
buildOperationalDashboard,
|
|
156
|
-
fetchHostedDashboard,
|
|
157
|
-
getOperationalDashboard,
|
|
158
|
-
resolveHostedDashboardConfig,
|
|
159
|
-
shouldPreferHostedDashboard,
|
|
160
|
-
};
|