thumbgate 1.4.0 → 1.4.1

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.
@@ -7,6 +7,8 @@ const ROOT = path.join(__dirname, '..');
7
7
  const PRODUCTHUNT_URL = 'https://www.producthunt.com/products/thumbgate';
8
8
  const CLAUDE_PLUGIN_LATEST_ASSET_NAME = 'thumbgate-claude-desktop.mcpb';
9
9
  const CLAUDE_PLUGIN_NEXT_ASSET_NAME = 'thumbgate-claude-desktop-next.mcpb';
10
+ const CLAUDE_PLUGIN_REVIEW_LATEST_ASSET_NAME = 'thumbgate-claude-plugin-review.zip';
11
+ const CLAUDE_PLUGIN_REVIEW_NEXT_ASSET_NAME = 'thumbgate-claude-plugin-review-next.zip';
10
12
  const CODEX_PLUGIN_LATEST_ASSET_NAME = 'thumbgate-codex-plugin.zip';
11
13
  const CODEX_PLUGIN_NEXT_ASSET_NAME = 'thumbgate-codex-plugin-next.zip';
12
14
 
@@ -27,6 +29,11 @@ function getClaudePluginVersionedAssetName(version = getPackageVersion(ROOT)) {
27
29
  return `thumbgate-claude-desktop-v${normalized}.mcpb`;
28
30
  }
29
31
 
32
+ function getClaudePluginReviewVersionedAssetName(version = getPackageVersion(ROOT)) {
33
+ const normalized = String(version || '').replace(/^v/, '');
34
+ return `thumbgate-claude-plugin-review-v${normalized}.zip`;
35
+ }
36
+
30
37
  function isPrereleaseVersion(version = getPackageVersion(ROOT)) {
31
38
  return /^\d+\.\d+\.\d+-[0-9A-Za-z.-]+$/.test(String(version || '').trim());
32
39
  }
@@ -35,6 +42,12 @@ function getClaudePluginChannelAssetName(version = getPackageVersion(ROOT)) {
35
42
  return isPrereleaseVersion(version) ? CLAUDE_PLUGIN_NEXT_ASSET_NAME : CLAUDE_PLUGIN_LATEST_ASSET_NAME;
36
43
  }
37
44
 
45
+ function getClaudePluginReviewChannelAssetName(version = getPackageVersion(ROOT)) {
46
+ return isPrereleaseVersion(version)
47
+ ? CLAUDE_PLUGIN_REVIEW_NEXT_ASSET_NAME
48
+ : CLAUDE_PLUGIN_REVIEW_LATEST_ASSET_NAME;
49
+ }
50
+
38
51
  function getClaudePluginLatestDownloadUrl(root = ROOT) {
39
52
  return `${getRepositoryUrl(root)}/releases/latest/download/${CLAUDE_PLUGIN_LATEST_ASSET_NAME}`;
40
53
  }
@@ -44,6 +57,15 @@ function getClaudePluginVersionedDownloadUrl(root = ROOT, version = getPackageVe
44
57
  return `${getRepositoryUrl(root)}/releases/download/v${normalized}/${getClaudePluginVersionedAssetName(normalized)}`;
45
58
  }
46
59
 
60
+ function getClaudePluginReviewLatestDownloadUrl(root = ROOT) {
61
+ return `${getRepositoryUrl(root)}/releases/latest/download/${CLAUDE_PLUGIN_REVIEW_LATEST_ASSET_NAME}`;
62
+ }
63
+
64
+ function getClaudePluginReviewVersionedDownloadUrl(root = ROOT, version = getPackageVersion(root)) {
65
+ const normalized = String(version || '').replace(/^v/, '');
66
+ return `${getRepositoryUrl(root)}/releases/download/v${normalized}/${getClaudePluginReviewVersionedAssetName(normalized)}`;
67
+ }
68
+
47
69
  function getCodexPluginVersionedAssetName(version = getPackageVersion(ROOT)) {
48
70
  const normalized = String(version || '').replace(/^v/, '');
49
71
  return `thumbgate-codex-plugin-v${normalized}.zip`;
@@ -65,11 +87,17 @@ function getCodexPluginVersionedDownloadUrl(root = ROOT, version = getPackageVer
65
87
  module.exports = {
66
88
  CLAUDE_PLUGIN_LATEST_ASSET_NAME,
67
89
  CLAUDE_PLUGIN_NEXT_ASSET_NAME,
90
+ CLAUDE_PLUGIN_REVIEW_LATEST_ASSET_NAME,
91
+ CLAUDE_PLUGIN_REVIEW_NEXT_ASSET_NAME,
68
92
  CODEX_PLUGIN_LATEST_ASSET_NAME,
69
93
  CODEX_PLUGIN_NEXT_ASSET_NAME,
70
94
  PRODUCTHUNT_URL,
71
95
  getClaudePluginChannelAssetName,
72
96
  getClaudePluginLatestDownloadUrl,
97
+ getClaudePluginReviewChannelAssetName,
98
+ getClaudePluginReviewLatestDownloadUrl,
99
+ getClaudePluginReviewVersionedAssetName,
100
+ getClaudePluginReviewVersionedDownloadUrl,
73
101
  getClaudePluginVersionedAssetName,
74
102
  getClaudePluginVersionedDownloadUrl,
75
103
  getCodexPluginChannelAssetName,
@@ -107,35 +107,54 @@ function analyze(entries) {
107
107
 
108
108
  function promoteToGates(recurringIssues) {
109
109
  const autoGatePath = getAutoGatesPath();
110
- const autoGates = { version: 1, gates: [] };
111
-
110
+
111
+ // Load existing auto-gates to MERGE, not overwrite
112
+ let autoGates = { version: 1, gates: [], promotionLog: [] };
113
+ if (fs.existsSync(autoGatePath)) {
114
+ try {
115
+ autoGates = JSON.parse(fs.readFileSync(autoGatePath, 'utf-8'));
116
+ if (!Array.isArray(autoGates.gates)) autoGates.gates = [];
117
+ if (!Array.isArray(autoGates.promotionLog)) autoGates.promotionLog = [];
118
+ } catch { /* start fresh if corrupt */ }
119
+ }
120
+
121
+ const existingIds = new Set(autoGates.gates.map(g => g.id));
122
+ let added = 0;
123
+
112
124
  for (const issue of recurringIssues) {
113
125
  if (issue.severity === 'critical') {
114
- // Extract key nouns/verbs for pattern matching
115
126
  const keywords = issue.pattern
116
127
  .toLowerCase()
117
128
  .replace(/[^a-z0-9\s]/g, '')
118
129
  .split(/\s+/)
119
130
  .filter(w => w.length > 4)
120
131
  .slice(0, 3);
121
-
132
+
122
133
  if (keywords.length >= 2) {
123
134
  const pattern = keywords.join('.*');
135
+ const id = `auto-${issue.hasHighRisk ? 'hardened' : 'promoted'}-${Date.now().toString(36)}-${added}`;
136
+
137
+ // Skip if a gate with the same pattern already exists
138
+ const patternExists = autoGates.gates.some(g => g.pattern === pattern);
139
+ if (patternExists || existingIds.has(id)) continue;
140
+
124
141
  autoGates.gates.push({
125
- id: `auto-${issue.hasHighRisk ? 'hardened' : 'promoted'}-${Date.now().toString(36)}`,
142
+ id,
126
143
  pattern,
127
144
  action: 'block',
128
145
  message: `Automatically blocked due to repeated failures: ${issue.suggestedRule}`,
129
146
  severity: 'critical',
130
- source: 'feedback-auto-promotion'
147
+ source: 'feedback-to-rules',
148
+ promotedAt: new Date().toISOString(),
131
149
  });
150
+ added++;
132
151
  }
133
152
  }
134
153
  }
135
154
 
136
- if (autoGates.gates.length > 0) {
155
+ if (added > 0) {
137
156
  fs.mkdirSync(path.dirname(autoGatePath), { recursive: true });
138
- fs.writeFileSync(autoGatePath, JSON.stringify(autoGates, null, 2));
157
+ fs.writeFileSync(autoGatePath, JSON.stringify(autoGates, null, 2) + '\n');
139
158
  }
140
159
  }
141
160
 
@@ -595,10 +595,18 @@ function extractAffectedFiles(toolName, toolInput = {}) {
595
595
  }
596
596
 
597
597
  function isHighRiskAction(toolName, toolInput = {}, affectedFiles = []) {
598
- if (EDIT_LIKE_TOOLS.has(toolName) && affectedFiles.length > 0) return true;
598
+ if (EDIT_LIKE_TOOLS.has(toolName)) return true;
599
599
  if (toolName !== 'Bash') return false;
600
600
  const command = String(toolInput.command || '');
601
- return HIGH_RISK_BASH_PATTERN.test(command);
601
+ // Original high-risk pattern (git writes, publishes, destructive ops)
602
+ if (HIGH_RISK_BASH_PATTERN.test(command)) return true;
603
+ // Broadened: any Bash command that modifies files or has side effects.
604
+ // Excludes pure read/analysis commands (node --test, cat, ls, echo, etc.)
605
+ // to avoid false positives on benign operations.
606
+ if (/\b(sed|awk|mv|cp|chmod|chown|truncate|tee|patch)\b/.test(command)) return true;
607
+ if (/\b(npm\s+(?:run|exec|install)|yarn|pnpm)\b/.test(command)) return true;
608
+ if (/\b(curl|wget)\b/.test(command)) return true;
609
+ return false;
602
610
  }
603
611
 
604
612
  function isScopeEnforcedAction(toolName, toolInput = {}, affectedFiles = []) {
@@ -1448,9 +1456,16 @@ function evaluateSecretGuard(input = {}) {
1448
1456
  // PreToolUse hook interface (stdin/stdout JSON)
1449
1457
  // ---------------------------------------------------------------------------
1450
1458
 
1451
- function formatOutput(result) {
1459
+ function formatOutput(result, behavioralContext) {
1452
1460
  if (!result) {
1453
- // No gate matched — pass through
1461
+ // No gate matched — inject behavioral context if available
1462
+ if (behavioralContext) {
1463
+ return JSON.stringify({
1464
+ hookSpecificOutput: {
1465
+ additionalContext: behavioralContext,
1466
+ },
1467
+ });
1468
+ }
1454
1469
  return JSON.stringify({});
1455
1470
  }
1456
1471
 
@@ -1468,9 +1483,10 @@ function formatOutput(result) {
1468
1483
  }
1469
1484
 
1470
1485
  if (result.decision === 'warn') {
1486
+ const extra = behavioralContext ? `\n${behavioralContext}` : '';
1471
1487
  return JSON.stringify({
1472
1488
  hookSpecificOutput: {
1473
- additionalContext: `[GATE:${result.gate}] WARNING: ${result.message}${reasoningSuffix}`,
1489
+ additionalContext: `[GATE:${result.gate}] WARNING: ${result.message}${reasoningSuffix}${extra}`,
1474
1490
  },
1475
1491
  });
1476
1492
  }
@@ -1478,6 +1494,30 @@ function formatOutput(result) {
1478
1494
  return JSON.stringify({});
1479
1495
  }
1480
1496
 
1497
+ /**
1498
+ * Build behavioral context string from recurring feedback patterns.
1499
+ * Injected as additionalContext on EVERY tool call so the AI constantly
1500
+ * sees its failure patterns — even when no gate blocks.
1501
+ */
1502
+ function buildBehavioralContext() {
1503
+ const hybrid = getHybridFeedbackModule();
1504
+ if (!hybrid || typeof hybrid.buildHybridState !== 'function') return null;
1505
+
1506
+ try {
1507
+ const state = hybrid.buildHybridState({});
1508
+ if (!state || !state.recurringNegativePatterns || state.recurringNegativePatterns.length === 0) {
1509
+ return null;
1510
+ }
1511
+
1512
+ const constraints = hybrid.deriveConstraints(state, 3);
1513
+ if (constraints.length === 0) return null;
1514
+
1515
+ return `[ThumbGate] Recurring failure patterns (enforce these):\n${constraints.map(c => ` - ${c}`).join('\n')}`;
1516
+ } catch {
1517
+ return null;
1518
+ }
1519
+ }
1520
+
1481
1521
  async function runAsync(input) {
1482
1522
  const secretGuard = evaluateSecretGuard(input);
1483
1523
  if (secretGuard) {
@@ -1504,7 +1544,8 @@ async function runAsync(input) {
1504
1544
  }
1505
1545
  }
1506
1546
 
1507
- return formatOutput(result);
1547
+ const behavioralContext = buildBehavioralContext();
1548
+ return formatOutput(result, behavioralContext);
1508
1549
  }
1509
1550
 
1510
1551
  function run(input) {
@@ -1533,7 +1574,8 @@ function run(input) {
1533
1574
  }
1534
1575
  }
1535
1576
 
1536
- return formatOutput(result);
1577
+ const behavioralContext = buildBehavioralContext();
1578
+ return formatOutput(result, behavioralContext);
1537
1579
  }
1538
1580
 
1539
1581
  // ---------------------------------------------------------------------------
@@ -1753,6 +1795,8 @@ module.exports = {
1753
1795
  SESSION_ACTION_TTL_MS,
1754
1796
  PROTECTED_APPROVAL_TTL_MS,
1755
1797
  DEFAULT_PROTECTED_FILE_GLOBS,
1798
+ buildBehavioralContext,
1799
+ isHighRiskAction,
1756
1800
  };
1757
1801
 
1758
1802
  // ---------------------------------------------------------------------------
@@ -123,6 +123,7 @@ function resolveHostedBillingConfig({ requestOrigin } = {}, env = process.env) {
123
123
  const proPriceDollars = normalizePriceDollars(env.THUMBGATE_PRO_PRICE_DOLLARS) || DEFAULT_PRO_PRICE_DOLLARS;
124
124
  const proPriceLabel = env.THUMBGATE_PRO_PRICE_LABEL || DEFAULT_PRO_PRICE_LABEL;
125
125
  const gaMeasurementId = normalizeTrackingId(env.THUMBGATE_GA_MEASUREMENT_ID, GA_MEASUREMENT_ID_PATTERN);
126
+ const posthogApiKey = env.POSTHOG_API_KEY || '';
126
127
  const googleSiteVerification = normalizeTrackingId(env.THUMBGATE_GOOGLE_SITE_VERIFICATION);
127
128
 
128
129
  return {
@@ -137,6 +138,7 @@ function resolveHostedBillingConfig({ requestOrigin } = {}, env = process.env) {
137
138
  proPriceLabel,
138
139
  gaMeasurementId,
139
140
  googleSiteVerification,
141
+ posthogApiKey,
140
142
  };
141
143
  }
142
144
 
@@ -508,25 +508,21 @@ function evaluateCompiledGuards(artifact, toolName, toolInput) {
508
508
  const normTool = (toolName || '').toLowerCase();
509
509
 
510
510
  for (const guard of artifact.guards) {
511
- // Check if tool context is relevant
512
511
  const guardText = normalize(guard.text || '');
513
512
  const toolMentioned = guardText.includes(normTool) || normTool === 'unknown';
514
513
 
515
- if (hasTwoKeywordHits(normInput, guard.words || [])) {
516
- return {
517
- mode: guard.mode || 'warn',
518
- reason: `Matched guard pattern (count: ${guard.count}): "${(guard.text || '').slice(0, 80)}"`,
519
- source: 'compiled',
520
- guardHash: guard.hash,
521
- attributed: guard.attributed,
522
- };
523
- }
514
+ const keywordMatch = hasTwoKeywordHits(normInput, guard.words || []);
524
515
 
525
- // Also check tool-level match when input is empty or short
526
- if (normInput.length < 10 && toolMentioned && guard.count >= (artifact.blockThreshold || 3)) {
516
+ // Match if: keyword hits in input, OR tool mentioned + high count.
517
+ // Previously tool-name matching only worked for short inputs this was
518
+ // a false-negative gap that let tool-specific patterns slip through.
519
+ if (keywordMatch || (toolMentioned && guard.count >= (artifact.blockThreshold || 3))) {
520
+ const reason = keywordMatch
521
+ ? `Matched guard pattern (count: ${guard.count}): "${(guard.text || '').slice(0, 80)}"`
522
+ : `Tool "${toolName}" has recurring negative patterns (count: ${guard.count})`;
527
523
  return {
528
524
  mode: guard.mode || 'warn',
529
- reason: `Tool "${toolName}" has recurring negative patterns (count: ${guard.count})`,
525
+ reason,
530
526
  source: 'compiled',
531
527
  guardHash: guard.hash,
532
528
  attributed: guard.attributed,
@@ -596,6 +592,12 @@ function evaluatePretoolFromState(state, toolName, toolInput) {
596
592
  * @param {string} [opts.attributedFeedbackPath]
597
593
  * @returns {{ mode: 'block'|'warn'|'allow', reason: string, source: string }}
598
594
  */
595
+ /**
596
+ * Max age (ms) before compiled guards are considered stale and live state
597
+ * is also consulted. Default: 1 hour.
598
+ */
599
+ const GUARD_STALENESS_MS = 60 * 60 * 1000;
600
+
599
601
  function evaluatePretool(toolName, toolInput, opts) {
600
602
  const o = opts || {};
601
603
 
@@ -605,11 +607,18 @@ function evaluatePretool(toolName, toolInput, opts) {
605
607
  if (artifact) {
606
608
  const result = evaluateCompiledGuards(artifact, toolName, toolInput);
607
609
  if (result.mode !== 'allow') return result;
608
- // Even if compiled says allow, we're done (trust compiled)
609
- return result;
610
+
611
+ // Check staleness: if compiled artifact is fresh enough, trust it
612
+ const compiledAt = artifact.compiledAt ? Date.parse(artifact.compiledAt) : 0;
613
+ const age = Date.now() - compiledAt;
614
+ if (age < GUARD_STALENESS_MS) {
615
+ return result; // Fresh compiled artifact says allow — trust it
616
+ }
617
+ // Stale artifact said allow — fall through to live evaluation
618
+ // in case new feedback was captured since compilation
610
619
  }
611
620
 
612
- // Slow path: build live state
621
+ // Slow path: build live state (also used when compiled guards are stale)
613
622
  const state = buildHybridState({
614
623
  feedbackLogPath: o.feedbackLogPath,
615
624
  attributedFeedbackPath: o.attributedFeedbackPath,
@@ -683,6 +692,7 @@ module.exports = {
683
692
  readJsonl,
684
693
  getHybridPaths,
685
694
  PATHS,
695
+ GUARD_STALENESS_MS,
686
696
  };
687
697
 
688
698
  if (require.main === module) {
@@ -1,23 +1,60 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
3
6
  const { getBillingSummaryLive } = require('./billing');
4
7
  const { resolveAnalyticsWindow } = require('./analytics-window');
5
8
  const { resolveHostedBillingConfig } = require('./hosted-config');
6
9
 
10
+ // Configure fetch proxy when running behind a corporate/sandbox proxy
11
+ (function configureProxy() {
12
+ const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy
13
+ || process.env.HTTP_PROXY || process.env.http_proxy;
14
+ if (!proxyUrl) return;
15
+ try {
16
+ const { ProxyAgent, setGlobalDispatcher } = require('undici');
17
+ setGlobalDispatcher(new ProxyAgent(proxyUrl));
18
+ } catch {
19
+ // undici not available — fetch will use default dispatcher
20
+ }
21
+ }());
22
+
23
+ const OPERATOR_CONFIG_PATH = path.join(os.homedir(), '.config', 'thumbgate', 'operator.json');
24
+
7
25
  function normalizeText(value) {
8
26
  if (value === undefined || value === null) return null;
9
27
  const text = String(value).trim();
10
28
  return text || null;
11
29
  }
12
30
 
31
+ function loadOperatorConfig(configPath = OPERATOR_CONFIG_PATH) {
32
+ try {
33
+ const raw = fs.readFileSync(configPath, 'utf8');
34
+ const parsed = JSON.parse(raw);
35
+ return {
36
+ operatorKey: normalizeText(parsed.operatorKey),
37
+ baseUrl: normalizeText(parsed.baseUrl),
38
+ };
39
+ } catch {
40
+ return { operatorKey: null, baseUrl: null };
41
+ }
42
+ }
43
+
13
44
  function shouldPreferHostedSummary() {
14
45
  return String(process.env.THUMBGATE_METRICS_SOURCE || '').trim().toLowerCase() !== 'local';
15
46
  }
16
47
 
17
48
  function resolveHostedSummaryConfig() {
18
49
  const runtimeConfig = resolveHostedBillingConfig();
19
- const apiBaseUrl = normalizeText(process.env.THUMBGATE_BILLING_API_BASE_URL) || runtimeConfig.billingApiBaseUrl;
20
- const apiKey = normalizeText(process.env.THUMBGATE_API_KEY);
50
+ const operatorConfig = loadOperatorConfig();
51
+ // Priority: env THUMBGATE_OPERATOR_KEY > local config file > env THUMBGATE_API_KEY
52
+ const apiKey = normalizeText(process.env.THUMBGATE_OPERATOR_KEY)
53
+ || operatorConfig.operatorKey
54
+ || normalizeText(process.env.THUMBGATE_API_KEY);
55
+ const apiBaseUrl = normalizeText(process.env.THUMBGATE_BILLING_API_BASE_URL)
56
+ || operatorConfig.baseUrl
57
+ || runtimeConfig.billingApiBaseUrl;
21
58
  return {
22
59
  apiBaseUrl,
23
60
  apiKey,
@@ -74,9 +111,7 @@ async function getOperationalBillingSummary(options = {}) {
74
111
  };
75
112
  } catch (err) {
76
113
  const reason = err && err.message ? err.message : 'hosted_summary_unavailable';
77
- // TODO: Configure hosted billing via THUMBGATE_BILLING_API_BASE_URL and THUMBGATE_API_KEY
78
- // to avoid falling back to local state. See docs/PRICING_RESEARCH_2026-03-10.md
79
- console.warn(`[operational-summary] Hosted billing not configured — falling back to local state. Reason: ${reason}`);
114
+ console.warn(`[operational-summary] Hosted billing unavailable falling back to local state. Reason: ${reason}`);
80
115
  return {
81
116
  source: 'local',
82
117
  summary: await getBillingSummaryLive(analyticsWindow),
@@ -90,4 +125,5 @@ module.exports = {
90
125
  getOperationalBillingSummary,
91
126
  resolveHostedSummaryConfig,
92
127
  shouldPreferHostedSummary,
128
+ loadOperatorConfig,
93
129
  };