thumbgate 1.27.19 → 1.28.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.
Files changed (116) hide show
  1. package/.claude/commands/dashboard.md +15 -0
  2. package/.claude/commands/thumbgate-blocked.md +27 -0
  3. package/.claude/commands/thumbgate-dashboard.md +15 -0
  4. package/.claude/commands/thumbgate-doctor.md +30 -0
  5. package/.claude/commands/thumbgate-guard.md +36 -0
  6. package/.claude/commands/thumbgate-protect.md +30 -0
  7. package/.claude/commands/thumbgate-rules.md +30 -0
  8. package/.claude-plugin/plugin.json +1 -1
  9. package/.well-known/mcp/server-card.json +1 -1
  10. package/README.md +63 -45
  11. package/adapters/claude/.mcp.json +2 -2
  12. package/adapters/gcp/dfcx-webhook-gate.js +295 -0
  13. package/adapters/letta/README.md +41 -0
  14. package/adapters/letta/thumbgate-letta-adapter.js +133 -0
  15. package/adapters/mcp/server-stdio.js +1 -1
  16. package/adapters/opencode/opencode.json +1 -1
  17. package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
  18. package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
  19. package/bench/observability-eval-suite.json +26 -0
  20. package/bin/cli.js +52 -9
  21. package/bin/dashboard-cli.js +7 -0
  22. package/bin/postinstall.js +14 -23
  23. package/commands/dashboard.md +15 -0
  24. package/commands/thumbgate-dashboard.md +15 -0
  25. package/config/entitlement-public-keys.json +6 -0
  26. package/config/gates/default.json +1 -1
  27. package/config/github-about.json +2 -2
  28. package/config/merge-quality-checks.json +3 -0
  29. package/package.json +233 -100
  30. package/public/about.html +159 -0
  31. package/public/agent-manager.html +179 -0
  32. package/public/agents-cost-savings.html +153 -0
  33. package/public/ai-malpractice-prevention.html +818 -0
  34. package/public/assets/brand/github-social-preview.png +0 -0
  35. package/public/assets/brand/thumbgate-icon-512.png +0 -0
  36. package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
  37. package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
  38. package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
  39. package/public/assets/brand/thumbgate-logo-transparent.svg +28 -0
  40. package/public/assets/brand/thumbgate-mark-inline-v3.svg +18 -0
  41. package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
  42. package/public/assets/brand/thumbgate-mark-team.svg +26 -0
  43. package/public/assets/brand/thumbgate-mark.svg +21 -0
  44. package/public/assets/brand/thumbgate-wordmark.svg +20 -0
  45. package/public/assets/claude-thumbgate-statusbar.svg +8 -0
  46. package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
  47. package/public/assets/legal-intake-control-flow.svg +66 -0
  48. package/public/blog.html +12 -9
  49. package/public/brand/thumbgate-mark.svg +19 -0
  50. package/public/brand/thumbgate-og.svg +16 -0
  51. package/public/chatgpt-app.html +330 -0
  52. package/public/codex-enterprise.html +123 -0
  53. package/public/codex-plugin.html +5 -5
  54. package/public/compare.html +10 -10
  55. package/public/dashboard.html +1 -1
  56. package/public/diagnostic.html +345 -0
  57. package/public/federal.html +2 -2
  58. package/public/guide.html +14 -14
  59. package/public/index.html +81 -70
  60. package/public/install.html +193 -0
  61. package/public/js/buyer-intent.js +672 -0
  62. package/public/learn.html +4 -17
  63. package/public/numbers.html +2 -2
  64. package/public/pricing.html +392 -0
  65. package/public/pro.html +2 -2
  66. package/scripts/action-receipts.js +324 -0
  67. package/scripts/activation-quickstart.js +187 -0
  68. package/scripts/agent-operations-planner.js +621 -0
  69. package/scripts/agent-reward-model.js +13 -0
  70. package/scripts/ai-component-inventory.js +367 -0
  71. package/scripts/async-eval-observability.js +236 -0
  72. package/scripts/audit.js +65 -0
  73. package/scripts/aws-blocks-guardrails.js +272 -0
  74. package/scripts/bayes-optimal-gate.js +6 -1
  75. package/scripts/classifier-routing.js +130 -0
  76. package/scripts/cli-feedback.js +17 -1
  77. package/scripts/commercial-offer.js +3 -3
  78. package/scripts/dashboard-chat.js +332 -0
  79. package/scripts/entitlement.js +250 -0
  80. package/scripts/export-databricks-bundle.js +5 -0
  81. package/scripts/export-dpo-pairs.js +6 -0
  82. package/scripts/export-hf-dataset.js +5 -0
  83. package/scripts/feedback-aggregate.js +281 -0
  84. package/scripts/feedback-sanitizer.js +105 -0
  85. package/scripts/gates-engine.js +15 -3
  86. package/scripts/hook-stop-anti-claim.js +301 -0
  87. package/scripts/imperative-detector.js +85 -0
  88. package/scripts/install-shim.js +87 -0
  89. package/scripts/intervention-policy.js +13 -0
  90. package/scripts/mcp-oauth.js +293 -0
  91. package/scripts/noop-detect.js +285 -0
  92. package/scripts/parallel-workflow-orchestrator.js +293 -0
  93. package/scripts/plan-gate.js +243 -0
  94. package/scripts/plausible-domain-config.js +99 -0
  95. package/scripts/pr-manager.js +9 -22
  96. package/scripts/pro-local-dashboard.js +198 -0
  97. package/scripts/qa-scenario-planner.js +136 -0
  98. package/scripts/repeat-metric.js +137 -0
  99. package/scripts/risk-scorer.js +6 -0
  100. package/scripts/secret-fixture-tokens.js +61 -0
  101. package/scripts/secret-redaction.js +166 -0
  102. package/scripts/self-harness-optimizer.js +141 -0
  103. package/scripts/self-healing-check.js +193 -0
  104. package/scripts/self-protection.js +90 -0
  105. package/scripts/seo-gsd.js +2 -2
  106. package/scripts/silent-failure-cluster.js +531 -0
  107. package/scripts/statusline-cache-read.js +57 -0
  108. package/scripts/sync-telemetry-from-prod.js +374 -0
  109. package/scripts/thompson-sampling.js +11 -2
  110. package/scripts/tool-contract-validator.js +76 -0
  111. package/scripts/trajectory-scorer.js +63 -0
  112. package/scripts/verify-marketing-pages-deployed.js +212 -0
  113. package/scripts/visitor-journey.js +172 -0
  114. package/src/api/server.js +81 -10
  115. package/.claude-plugin/marketplace.json +0 -85
  116. package/adapters/chatgpt/openapi.yaml +0 -1707
@@ -1,11 +1,4 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * PR Manager — High-Throughput Merge & Blocker Diagnosis
4
- *
5
- * Inspired by the 2026 GitHub 'Quick Access' update. Centralizes merge status
6
- * detection and triggers autonomous self-healing for common blockers.
7
- */
8
-
9
2
  'use strict';
10
3
 
11
4
  const fs = require('node:fs');
@@ -34,6 +27,8 @@ const PASSING_BUCKETS = new Set((MERGE_QUALITY_CHECKS.passingBuckets || []).map(
34
27
  const PENDING_BUCKETS = new Set((MERGE_QUALITY_CHECKS.pendingBuckets || []).map((value) => String(value || '').toLowerCase()));
35
28
  const FAILING_BUCKETS = new Set((MERGE_QUALITY_CHECKS.failingBuckets || []).map((value) => String(value || '').toLowerCase()));
36
29
 
30
+ const SELF_REFERENTIAL_CHECKS = new Set(MERGE_QUALITY_CHECKS.selfReferentialChecks || []);
31
+
37
32
  function assertSafeGhArgs(args) {
38
33
  if (!Array.isArray(args) || args.length === 0) {
39
34
  throw new Error('GH CLI args must be a non-empty array.');
@@ -119,9 +114,6 @@ function isMissingCurrentBranchPr(result, prNumber) {
119
114
  || /could not determine current branch:.*not on any branch/i.test(formatGhError(result));
120
115
  }
121
116
 
122
- /**
123
- * Fetch granular PR status using GH CLI
124
- */
125
117
  function getPrStatus(prNumber = '', runner = runGh) {
126
118
  const normalizedPrNumber = normalizePrNumber(prNumber);
127
119
  const args = ['pr', 'view'];
@@ -182,6 +174,9 @@ function summarizeChecks(checks = []) {
182
174
 
183
175
  for (const check of checks) {
184
176
  const name = check.name || 'unknown-check';
177
+
178
+ if (SELF_REFERENTIAL_CHECKS.has(name)) continue;
179
+
185
180
  const bucket = String(check.bucket || '').toLowerCase();
186
181
  if (bucket) {
187
182
  if (FAILING_BUCKETS.has(bucket)) {
@@ -225,9 +220,6 @@ function sleep(ms) {
225
220
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
226
221
  }
227
222
 
228
- /**
229
- * Diagnose and resolve blockers autonomously
230
- */
231
223
  async function resolveBlockers(pr, runner = runGh) {
232
224
  const title = pr.title || 'Untitled PR';
233
225
  const mergeState = pr.mergeStateStatus || 'UNKNOWN';
@@ -241,7 +233,6 @@ async function resolveBlockers(pr, runner = runGh) {
241
233
  return { status: 'skipped', reason: 'draft' };
242
234
  }
243
235
 
244
- // 1. Handle Outdated Branch (BEHIND)
245
236
  if (pr.mergeStateStatus === 'BEHIND') {
246
237
  console.log('[PR Manager] PR is behind main. Triggering auto-update...');
247
238
  const update = runner(['pr', 'update-branch', pr.number.toString()]);
@@ -250,13 +241,11 @@ async function resolveBlockers(pr, runner = runGh) {
250
241
  }
251
242
  }
252
243
 
253
- // 2. Handle Merge Conflicts (DIRTY)
254
244
  if (pr.mergeStateStatus === 'DIRTY' || pr.mergeable === 'CONFLICTING') {
255
245
  console.log('[PR Manager] CRITICAL: Merge conflicts detected. Manual intervention or advanced rebase required.');
256
246
  return { status: 'blocked', reason: 'conflicts' };
257
247
  }
258
248
 
259
- // 3. Handle CI Failures
260
249
  let checks = pr.statusCheckRollup || [];
261
250
  let checkSource = 'statusCheckRollup';
262
251
 
@@ -282,7 +271,6 @@ async function resolveBlockers(pr, runner = runGh) {
282
271
  return { status: 'blocked', reason: 'ci_pending', checks: checkSummary.pending, checkSource };
283
272
  }
284
273
 
285
- // 4. Handle Review Blockers
286
274
  if (pr.reviewDecision === 'CHANGES_REQUESTED') {
287
275
  console.log('[PR Manager] BLOCKED: Changes requested by reviewer.');
288
276
  return { status: 'blocked', reason: 'changes_requested' };
@@ -293,8 +281,10 @@ async function resolveBlockers(pr, runner = runGh) {
293
281
  return { status: 'blocked', reason: 'review_required' };
294
282
  }
295
283
 
296
- // 5. Ready to Merge
297
- if (pr.mergeStateStatus === 'CLEAN' && pr.mergeable === 'MERGEABLE') {
284
+ if (
285
+ (pr.mergeStateStatus === 'CLEAN' || pr.mergeStateStatus === 'UNSTABLE')
286
+ && pr.mergeable === 'MERGEABLE'
287
+ ) {
298
288
  console.log('[PR Manager] SUCCESS: PR is ready for protected autonomous merge.');
299
289
  return { status: 'ready' };
300
290
  }
@@ -368,9 +358,6 @@ function submitTrunkMergeRequest(prNumber, runner = runGh) {
368
358
  };
369
359
  }
370
360
 
371
- /**
372
- * Perform autonomous merge
373
- */
374
361
  function performMerge(prInput, runner = runGh, options = {}) {
375
362
  const pr = (prInput && typeof prInput === 'object')
376
363
  ? prInput
@@ -1,10 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
+ const crypto = require('crypto');
4
5
  const os = require('os');
5
6
  const path = require('path');
6
7
 
7
8
  const DEFAULT_PRO_API = 'https://thumbgate-production.up.railway.app';
9
+ const DEFAULT_PRO_ACTIVATION_ALERT_EMAIL = 'igor.ganapolsky@gmail.com';
8
10
  const CREATOR_BYPASS_VALUE = process.env.THUMBGATE_DEV_SECRET || '';
9
11
  const CREATOR_BYPASS_ENV = 'THUMBGATE_DEV_BYPASS';
10
12
  const CREATOR_SYNTHETIC_KEY = process.env.THUMBGATE_DEV_KEY || '';
@@ -55,6 +57,195 @@ function getLicensePath(homeDir = process.env.HOME || process.env.USERPROFILE ||
55
57
  return path.join(getLicenseDir(homeDir), 'license.json');
56
58
  }
57
59
 
60
+ function isTruthyEnv(value) {
61
+ return /^(1|true|yes|on)$/i.test(String(value || '').trim());
62
+ }
63
+
64
+ function normalizeText(value) {
65
+ return String(value || '').trim();
66
+ }
67
+
68
+ function escapeHtml(value) {
69
+ return String(value || '')
70
+ .replace(/&/g, '&')
71
+ .replace(/</g, '&lt;')
72
+ .replace(/>/g, '&gt;')
73
+ .replace(/"/g, '&quot;')
74
+ .replace(/'/g, '&#39;');
75
+ }
76
+
77
+ function fingerprintProKey(key) {
78
+ const normalized = normalizeText(key);
79
+ if (!normalized) return '';
80
+ return `sha256:${crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 12)}`;
81
+ }
82
+
83
+ function resolveProActivationAlertRecipient(env = process.env) {
84
+ return normalizeText(
85
+ env.THUMBGATE_PRO_ACTIVATION_ALERT_EMAIL ||
86
+ env.THUMBGATE_OPERATOR_ALERT_EMAIL ||
87
+ env.THUMBGATE_SUPPORT_EMAIL ||
88
+ DEFAULT_PRO_ACTIVATION_ALERT_EMAIL
89
+ );
90
+ }
91
+
92
+ function canSendProActivationAlert({ env = process.env, sendEmailImpl } = {}) {
93
+ if (isTruthyEnv(env.THUMBGATE_DISABLE_PRO_ACTIVATION_ALERTS)) return false;
94
+ if (!resolveProActivationAlertRecipient(env)) return false;
95
+ if (sendEmailImpl) return true;
96
+ return Boolean(normalizeText(env.RESEND_API_KEY || env.THUMBGATE_RESEND_API_KEY));
97
+ }
98
+
99
+ function renderProActivationAlertBodies({
100
+ keyFingerprint,
101
+ source,
102
+ version,
103
+ activatedAt,
104
+ hostname,
105
+ platform,
106
+ arch,
107
+ nodeVersion,
108
+ customerId,
109
+ installId,
110
+ usageCount,
111
+ } = {}) {
112
+ const occurredAt = activatedAt || new Date().toISOString();
113
+ const runtimePlatform = platform || process.platform;
114
+ const runtimeArch = arch || process.arch;
115
+ const runtimeNode = nodeVersion || process.version;
116
+ const text = [
117
+ 'ThumbGate Pro activation detected.',
118
+ '',
119
+ `Activated at: ${occurredAt}`,
120
+ `Key fingerprint: ${keyFingerprint || 'unknown'}`,
121
+ `Source: ${source || 'unknown'}`,
122
+ `Version: ${version || 'unknown'}`,
123
+ `Customer ID: ${customerId || 'unknown'}`,
124
+ `Install ID: ${installId || 'unknown'}`,
125
+ `Usage count: ${usageCount ?? 'unknown'}`,
126
+ `Host: ${hostname || 'unknown'}`,
127
+ `Runtime: ${runtimePlatform}/${runtimeArch} on ${runtimeNode}`,
128
+ '',
129
+ 'Secret hygiene: this alert intentionally does not include the raw Pro key.',
130
+ ].join('\n');
131
+ const html = `<!doctype html>
132
+ <html>
133
+ <body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;color:#17212b;line-height:1.5;">
134
+ <h1 style="font-size:20px;margin:0 0 12px;">ThumbGate Pro activation detected</h1>
135
+ <table role="presentation" cellspacing="0" cellpadding="0" style="border-collapse:collapse;">
136
+ <tr><td style="padding:4px 14px 4px 0;color:#64748b;">Activated at</td><td style="padding:4px 0;">${escapeHtml(occurredAt)}</td></tr>
137
+ <tr><td style="padding:4px 14px 4px 0;color:#64748b;">Key fingerprint</td><td style="padding:4px 0;"><code>${escapeHtml(keyFingerprint || 'unknown')}</code></td></tr>
138
+ <tr><td style="padding:4px 14px 4px 0;color:#64748b;">Source</td><td style="padding:4px 0;">${escapeHtml(source || 'unknown')}</td></tr>
139
+ <tr><td style="padding:4px 14px 4px 0;color:#64748b;">Version</td><td style="padding:4px 0;">${escapeHtml(version || 'unknown')}</td></tr>
140
+ <tr><td style="padding:4px 14px 4px 0;color:#64748b;">Customer ID</td><td style="padding:4px 0;">${escapeHtml(customerId || 'unknown')}</td></tr>
141
+ <tr><td style="padding:4px 14px 4px 0;color:#64748b;">Install ID</td><td style="padding:4px 0;">${escapeHtml(installId || 'unknown')}</td></tr>
142
+ <tr><td style="padding:4px 14px 4px 0;color:#64748b;">Usage count</td><td style="padding:4px 0;">${escapeHtml(usageCount ?? 'unknown')}</td></tr>
143
+ <tr><td style="padding:4px 14px 4px 0;color:#64748b;">Host</td><td style="padding:4px 0;">${escapeHtml(hostname || 'unknown')}</td></tr>
144
+ <tr><td style="padding:4px 14px 4px 0;color:#64748b;">Runtime</td><td style="padding:4px 0;">${escapeHtml(runtimePlatform)}/${escapeHtml(runtimeArch)} on ${escapeHtml(runtimeNode)}</td></tr>
145
+ </table>
146
+ <p style="margin-top:16px;color:#64748b;">Secret hygiene: this alert intentionally does not include the raw Pro key.</p>
147
+ </body>
148
+ </html>`;
149
+ return { text, html };
150
+ }
151
+
152
+ async function sendProActivationAlert({
153
+ key,
154
+ source = 'unknown',
155
+ version,
156
+ customerId,
157
+ installId,
158
+ usageCount,
159
+ env = process.env,
160
+ sendEmailImpl,
161
+ } = {}) {
162
+ const keyFingerprint = fingerprintProKey(key);
163
+ if (!keyFingerprint) return { sent: false, reason: 'missing_key' };
164
+ if (!canSendProActivationAlert({ env, sendEmailImpl })) {
165
+ return { sent: false, reason: 'activation_alert_disabled_or_unconfigured' };
166
+ }
167
+
168
+ const to = resolveProActivationAlertRecipient(env);
169
+ const { html, text } = renderProActivationAlertBodies({
170
+ keyFingerprint,
171
+ source,
172
+ version,
173
+ customerId,
174
+ installId,
175
+ usageCount,
176
+ activatedAt: new Date().toISOString(),
177
+ hostname: os.hostname(),
178
+ platform: process.platform,
179
+ arch: process.arch,
180
+ nodeVersion: process.version,
181
+ });
182
+ const sender = sendEmailImpl || require('./mailer').sendEmail;
183
+
184
+ try {
185
+ return await sender({
186
+ to,
187
+ subject: 'ThumbGate Pro activated',
188
+ html,
189
+ text,
190
+ });
191
+ } catch (error) {
192
+ return {
193
+ sent: false,
194
+ reason: 'exception',
195
+ error: error && error.message ? error.message : String(error),
196
+ };
197
+ }
198
+ }
199
+
200
+ async function notifyHostedProActivation({
201
+ key,
202
+ source = 'cli_pro_activate',
203
+ version,
204
+ apiBaseUrl = process.env.THUMBGATE_API_BASE_URL || DEFAULT_PRO_API,
205
+ fetchImpl = globalThis.fetch,
206
+ } = {}) {
207
+ const normalizedKey = normalizeText(key);
208
+ if (!normalizedKey) return { notified: false, reason: 'missing_key' };
209
+ if (typeof fetchImpl !== 'function') return { notified: false, reason: 'no_fetch' };
210
+
211
+ const keyFingerprint = fingerprintProKey(normalizedKey);
212
+ const endpoint = new URL('/v1/billing/pro-activation', apiBaseUrl).toString();
213
+ try {
214
+ const response = await fetchImpl(endpoint, {
215
+ method: 'POST',
216
+ headers: {
217
+ authorization: `Bearer ${normalizedKey}`,
218
+ 'content-type': 'application/json',
219
+ },
220
+ body: JSON.stringify({
221
+ keyFingerprint,
222
+ source,
223
+ version: version || null,
224
+ }),
225
+ });
226
+ const body = await response.json().catch(() => ({}));
227
+ if (!response.ok) {
228
+ return {
229
+ notified: false,
230
+ reason: body && body.detail ? body.detail : `http_${response.status}`,
231
+ status: response.status,
232
+ };
233
+ }
234
+ return {
235
+ notified: true,
236
+ status: response.status,
237
+ alert: body.alert || null,
238
+ keyFingerprint: body.keyFingerprint || keyFingerprint,
239
+ };
240
+ } catch (error) {
241
+ return {
242
+ notified: false,
243
+ reason: 'exception',
244
+ error: error && error.message ? error.message : String(error),
245
+ };
246
+ }
247
+ }
248
+
58
249
  function readLicense({ homeDir } = {}) {
59
250
  try {
60
251
  return JSON.parse(fs.readFileSync(getLicensePath(homeDir), 'utf8'));
@@ -162,12 +353,19 @@ module.exports = {
162
353
  CREATOR_BYPASS_VALUE,
163
354
  CREATOR_SYNTHETIC_KEY,
164
355
  DEFAULT_PRO_API,
356
+ DEFAULT_PRO_ACTIVATION_ALERT_EMAIL,
357
+ canSendProActivationAlert,
358
+ fingerprintProKey,
165
359
  getLicenseDir,
166
360
  getLicensePath,
167
361
  hasDevOverride,
168
362
  isCreatorDev,
169
363
  readLicense,
364
+ renderProActivationAlertBodies,
365
+ notifyHostedProActivation,
366
+ resolveProActivationAlertRecipient,
170
367
  saveLicense,
368
+ sendProActivationAlert,
171
369
  resolveProKey,
172
370
  validateProKey,
173
371
  startLocalProDashboard,
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+
6
+ const RUNTIME_PATTERNS = [
7
+ { pattern: /^public\/.*\.(html|css|js)$/i, surface: 'browser', reason: 'public UI asset changed' },
8
+ { pattern: /^src\/api\//i, surface: 'api', reason: 'API route or server behavior changed' },
9
+ { pattern: /^bin\//i, surface: 'cli', reason: 'CLI entrypoint changed' },
10
+ { pattern: /^scripts\/(dashboard|pro-local-dashboard|.*gate|.*scanner|.*reward|.*routing).*\.js$/i, surface: 'agent-runtime', reason: 'agent runtime or gate behavior changed' },
11
+ { pattern: /^adapters\//i, surface: 'agent-adapter', reason: 'agent adapter changed' },
12
+ { pattern: /^plugins\//i, surface: 'plugin', reason: 'plugin install path changed' },
13
+ { pattern: /^package\.json$/i, surface: 'package', reason: 'package manifest changed' },
14
+ ];
15
+
16
+ const SKIP_PATTERNS = [
17
+ /^README\.md$/i,
18
+ /^docs\//i,
19
+ /^reports\//i,
20
+ /^proof\//i,
21
+ /^tests\/.*\.test\.js$/i,
22
+ /^\.claude\/implementation-notes\//i,
23
+ ];
24
+
25
+ function normalizeFiles(files = []) {
26
+ return Array.from(new Set(files
27
+ .map((file) => String(file || '').trim().replace(/^\.?\//, ''))
28
+ .filter(Boolean)));
29
+ }
30
+
31
+ function classifyFile(file) {
32
+ for (const entry of RUNTIME_PATTERNS) {
33
+ if (entry.pattern.test(file)) return { ...entry, file };
34
+ }
35
+ for (const pattern of SKIP_PATTERNS) {
36
+ if (pattern.test(file)) return { surface: 'skip', reason: 'no runtime impact', file };
37
+ }
38
+ return { surface: 'focused', reason: 'unknown runtime impact; run focused checks', file };
39
+ }
40
+
41
+ function parseChangedFilesFromDiff(diff = '') {
42
+ const files = [];
43
+ for (const line of String(diff || '').split('\n')) {
44
+ const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
45
+ if (match) files.push(match[2]);
46
+ }
47
+ return normalizeFiles(files);
48
+ }
49
+
50
+ function planQaScenario(input = {}) {
51
+ const files = normalizeFiles(input.files || parseChangedFilesFromDiff(input.diff || ''));
52
+ const classifications = files.map(classifyFile);
53
+ const surfaces = Array.from(new Set(classifications.map((entry) => entry.surface)));
54
+ const runtimeChanges = classifications.filter((entry) => entry.surface !== 'skip');
55
+ const skipOnly = files.length > 0 && runtimeChanges.length === 0;
56
+
57
+ const recommendedRunner = chooseRunner(surfaces, input);
58
+ const userScenario = buildUserScenario(runtimeChanges, input);
59
+ return {
60
+ name: 'thumbgate-user-impact-qa-scenario',
61
+ status: skipOnly ? 'skip' : 'actionable',
62
+ files,
63
+ classifications,
64
+ recommendedRunner,
65
+ userScenario,
66
+ commands: buildCommands(recommendedRunner, runtimeChanges),
67
+ regressionPolicy: skipOnly
68
+ ? 'skip durable QA; no runtime-impact files changed'
69
+ : 'if the QA agent finds a deterministic failure, convert it into a focused regression test before opening a fix PR',
70
+ transientFailurePolicy: 'doctor the browser/computer-use runner once, retry once, then label as infrastructure-flaky instead of product-regression',
71
+ };
72
+ }
73
+
74
+ function chooseRunner(surfaces, input = {}) {
75
+ if (input.forceComputerUse || surfaces.includes('plugin') || surfaces.includes('agent-adapter')) return 'computer-use-qa';
76
+ if (surfaces.includes('browser') || surfaces.includes('api')) return 'browser-qa';
77
+ if (surfaces.includes('cli') || surfaces.includes('agent-runtime') || surfaces.includes('package')) return 'focused-node-qa';
78
+ if (surfaces.every((surface) => surface === 'skip')) return 'skip';
79
+ return 'focused-node-qa';
80
+ }
81
+
82
+ function buildUserScenario(runtimeChanges, input = {}) {
83
+ if (runtimeChanges.length === 0) return 'No user-impact scenario required; changed files are docs, tests, reports, or proof artifacts only.';
84
+ const surfaces = Array.from(new Set(runtimeChanges.map((entry) => entry.surface)));
85
+ if (surfaces.includes('browser') || surfaces.includes('api')) {
86
+ return 'Open the affected page as a user, perform the primary CTA or dashboard action, verify visible state changes, then check the related API response.';
87
+ }
88
+ if (surfaces.includes('plugin') || surfaces.includes('agent-adapter')) {
89
+ return 'Install or reload the affected agent integration, run one thumbs-up and one thumbs-down capture, then verify the next risky action is gated.';
90
+ }
91
+ if (surfaces.includes('cli')) {
92
+ return 'Run the changed CLI command with --help and one realistic command path, then verify exit code, JSON output, and no stale command copy.';
93
+ }
94
+ return input.scenario || 'Run the focused test for the changed runtime surface, then verify the behavior with one realistic operator workflow.';
95
+ }
96
+
97
+ function buildCommands(runner, runtimeChanges) {
98
+ if (runner === 'skip') return [];
99
+ const commands = ['npm test -- --test-concurrency=1'];
100
+ if (runner === 'browser-qa') commands.push('npx playwright test tests/e2e --project=chromium');
101
+ if (runner === 'computer-use-qa') commands.push('node scripts/qa-scenario-planner.js --doctor-runner');
102
+ if (runtimeChanges.some((entry) => entry.surface === 'package')) commands.push('npm pack --dry-run');
103
+ return commands;
104
+ }
105
+
106
+ function parseArgs(argv = process.argv.slice(2)) {
107
+ const args = {};
108
+ for (const arg of argv) {
109
+ if (arg === '--json') args.json = true;
110
+ else if (arg === '--doctor-runner') args.doctorRunner = true;
111
+ else if (arg.startsWith('--files=')) args.files = arg.slice('--files='.length).split(',');
112
+ else if (arg.startsWith('--diff-file=')) args.diff = fs.readFileSync(arg.slice('--diff-file='.length), 'utf8');
113
+ else if (arg.startsWith('--scenario=')) args.scenario = arg.slice('--scenario='.length);
114
+ }
115
+ return args;
116
+ }
117
+
118
+ if (require.main === module) {
119
+ const args = parseArgs();
120
+ if (args.doctorRunner) {
121
+ console.log('QA runner doctor: verify browser/computer-use target, screenshot capture, and network reachability before blaming product code.');
122
+ process.exit(0);
123
+ }
124
+ const report = planQaScenario(args);
125
+ if (args.json) console.log(JSON.stringify(report, null, 2));
126
+ else {
127
+ console.log(`${report.status.toUpperCase()}: ${report.userScenario}`);
128
+ for (const command of report.commands) console.log(`- ${command}`);
129
+ }
130
+ }
131
+
132
+ module.exports = {
133
+ classifyFile,
134
+ parseChangedFilesFromDiff,
135
+ planQaScenario,
136
+ };
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // repeat-metric — first-class "repeat-attempts blocked before execution" metric
5
+ //
6
+ // This module exposes data ThumbGate already collects in gate-stats state. It
7
+ // does NOT write to disk; it is a pure function over gates-engine.loadStats().
8
+ //
9
+ // The headline number is stats.recurringBlocks — incremented by recordStat()
10
+ // in gates-engine.js every time the same gate blocks/warns the same sanitized
11
+ // action fingerprint within one session bucket. That is "a pre-action gate fire
12
+ // that stopped a tool call the agent had already been blocked on", rather than
13
+ // merely "the same noisy gate fired again."
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const gatesEngine = require('./gates-engine');
17
+
18
+ /**
19
+ * Derive a per-gate { firstBlocks, repeatBlocks } split from the raw stats.
20
+ *
21
+ * Modern stats record, per session bucket, which sanitized action fingerprints
22
+ * each gate fired on:
23
+ * stats.sessionFiredActions[sessionKey][gateId][fingerprint] === true
24
+ *
25
+ * firstBlocks is the count of distinct first action fingerprints. Legacy stats
26
+ * without fingerprints fall back to the old per-session-gate split.
27
+ *
28
+ * total block+warn events come from stats.byGate[id] (blocked + warned), which
29
+ * recordStat() also maintains. repeatBlocks is clamped to >= 0 to stay robust
30
+ * against partially-written / legacy state.
31
+ *
32
+ * @param {object} stats raw object returned by gates-engine.loadStats()
33
+ * @returns {Object<string,{firstBlocks:number, repeatBlocks:number}>}
34
+ */
35
+ function computeByGateSplit(stats) {
36
+ const byGate = {};
37
+ const sessionFiredActions = (stats && stats.sessionFiredActions) || {};
38
+ const sessionFiredGates = (stats && stats.sessionFiredGates) || {};
39
+ const rawByGate = (stats && stats.byGate) || {};
40
+
41
+ // Count distinct action fingerprints each gate fired on => firstBlocks.
42
+ const firstBlocksByGate = {};
43
+ const gatesWithActionStats = new Set();
44
+ for (const sessionKey of Object.keys(sessionFiredActions)) {
45
+ const fired = sessionFiredActions[sessionKey] || {};
46
+ for (const gateId of Object.keys(fired)) {
47
+ const fingerprints = fired[gateId] || {};
48
+ const count = Object.values(fingerprints).filter(Boolean).length;
49
+ if (count > 0) {
50
+ gatesWithActionStats.add(gateId);
51
+ firstBlocksByGate[gateId] = (firstBlocksByGate[gateId] || 0) + count;
52
+ }
53
+ }
54
+ }
55
+
56
+ // Legacy fallback: old stats only tracked gate fired per session bucket.
57
+ for (const sessionKey of Object.keys(sessionFiredGates)) {
58
+ const fired = sessionFiredGates[sessionKey] || {};
59
+ for (const gateId of Object.keys(fired)) {
60
+ if (fired[gateId] && !gatesWithActionStats.has(gateId)) {
61
+ firstBlocksByGate[gateId] = (firstBlocksByGate[gateId] || 0) + 1;
62
+ }
63
+ }
64
+ }
65
+
66
+ // Union of every gate id we know about from either source.
67
+ const gateIds = new Set([
68
+ ...Object.keys(rawByGate),
69
+ ...Object.keys(firstBlocksByGate),
70
+ ...Object.keys(sessionFiredActions).flatMap((sessionKey) => Object.keys(sessionFiredActions[sessionKey] || {})),
71
+ ]);
72
+
73
+ for (const gateId of gateIds) {
74
+ const gateStat = rawByGate[gateId] || {};
75
+ const totalFires = (gateStat.blocked || 0) + (gateStat.warned || 0);
76
+ const firstBlocks = firstBlocksByGate[gateId] || 0;
77
+ // Repeat fires are total fires beyond the first fire per session bucket.
78
+ const repeatBlocks = Math.max(0, totalFires - firstBlocks);
79
+ byGate[gateId] = { firstBlocks, repeatBlocks };
80
+ }
81
+
82
+ return byGate;
83
+ }
84
+
85
+ /**
86
+ * Compute the repeat-attempts-blocked-before-execution metric.
87
+ *
88
+ * Pure read of gates-engine.loadStats(); no disk writes.
89
+ *
90
+ * @returns {{
91
+ * repeatBlocksBeforeExecution: number,
92
+ * recurringBlocks: number,
93
+ * totalBlocked: number,
94
+ * byGate: Object<string,{firstBlocks:number, repeatBlocks:number}>
95
+ * }}
96
+ */
97
+ function computeRepeatMetric() {
98
+ let stats;
99
+ try {
100
+ stats = gatesEngine.loadStats() || {};
101
+ } catch (_) {
102
+ stats = {};
103
+ }
104
+
105
+ const recurringBlocks = Number(stats.recurringBlocks || 0);
106
+ const totalBlocked = Number(stats.blocked || 0);
107
+
108
+ return {
109
+ // Headline: a pre-action block that stopped a tool call the agent had
110
+ // already been blocked on this session.
111
+ repeatBlocksBeforeExecution: recurringBlocks,
112
+ recurringBlocks,
113
+ totalBlocked,
114
+ byGate: computeByGateSplit(stats),
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Add a `repeat` sub-key to a gate-stats object WITHOUT mutating the original.
120
+ *
121
+ * Takes the object returned by gate-stats.calculateStats() or
122
+ * dashboard.computeGateStats() and returns a shallow copy with the repeat
123
+ * metric attached. The caller's file does not need to import any internals.
124
+ *
125
+ * @param {object} gateStatsObject
126
+ * @returns {object} copy of gateStatsObject with `.repeat`
127
+ */
128
+ function mergeRepeatMetricIntoGateStats(gateStatsObject) {
129
+ const base = gateStatsObject && typeof gateStatsObject === 'object' ? gateStatsObject : {};
130
+ return Object.assign({}, base, { repeat: computeRepeatMetric() });
131
+ }
132
+
133
+ module.exports = {
134
+ computeRepeatMetric,
135
+ mergeRepeatMetricIntoGateStats,
136
+ computeByGateSplit,
137
+ };
@@ -4,6 +4,7 @@
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { resolveFeedbackDir: resolveSharedFeedbackDir } = require('./feedback-paths');
7
+ const { requireLearnedModelsEntitlement } = require('./entitlement');
7
8
 
8
9
  const PROJECT_ROOT = path.join(__dirname, '..');
9
10
  const DEFAULT_FEEDBACK_DIR = resolveSharedFeedbackDir();
@@ -268,6 +269,10 @@ function buildPatternSummary(rows) {
268
269
  }
269
270
 
270
271
  function trainRiskModel(rows, options = {}) {
272
+ requireLearnedModelsEntitlement({
273
+ ...(options.entitlement || {}),
274
+ label: 'risk-scorer AdaBoost training',
275
+ });
271
276
  const registry = buildFeatureRegistry(rows, options);
272
277
  const examples = rows.map((row) => ({
273
278
  row,
@@ -419,6 +424,7 @@ function trainAndPersistRiskModel(feedbackDir, options = {}) {
419
424
  }
420
425
 
421
426
  function getRiskSummary(feedbackDir) {
427
+ requireLearnedModelsEntitlement({ label: 'risk-scorer summary' });
422
428
  const resolvedDir = resolveFeedbackDir(feedbackDir);
423
429
  const rows = readJSONL(sequencePathFor(resolvedDir));
424
430
  if (rows.length === 0) return null;
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ const FIXTURE_TOKENS = {
4
+ awsAccessKeyId: '__TG_FIXTURE_AWS_ACCESS_KEY_ID__',
5
+ githubPat: '__TG_FIXTURE_GITHUB_PAT__',
6
+ openAiLegacyKey: '__TG_FIXTURE_OPENAI_LEGACY_KEY__',
7
+ openAiProjectKey: '__TG_FIXTURE_OPENAI_PROJECT_KEY__',
8
+ rsaPrivateKeyHeader: '__TG_FIXTURE_RSA_PRIVATE_KEY_HEADER__',
9
+ ecPrivateKeyHeader: '__TG_FIXTURE_EC_PRIVATE_KEY_HEADER__',
10
+ privateKeyHeader: '__TG_FIXTURE_PRIVATE_KEY_HEADER__',
11
+ };
12
+
13
+ function buildAwsAccessKeyId() {
14
+ return ['AKIA', 'IOSFODNN7EXAMPLE'].join('');
15
+ }
16
+
17
+ function buildGitHubPat() {
18
+ return ['gh', 'p_', 'x'.repeat(36)].join('');
19
+ }
20
+
21
+ function buildOpenAiLegacyKey() {
22
+ return ['sk', '-', 'abcdefghijklmnopqrstuvwxyz01234567890'].join('');
23
+ }
24
+
25
+ function buildOpenAiProjectKey() {
26
+ return ['sk', '-proj-', 'abcdefghijklmnopqrstuvwxyz01234567890'].join('');
27
+ }
28
+
29
+ function buildPemHeader(prefix = '') {
30
+ return ['-----BEGIN ', prefix, 'PRIVATE KEY-----'].join('');
31
+ }
32
+
33
+ function fixtureReplacements() {
34
+ return [
35
+ [FIXTURE_TOKENS.awsAccessKeyId, buildAwsAccessKeyId()],
36
+ [FIXTURE_TOKENS.githubPat, buildGitHubPat()],
37
+ [FIXTURE_TOKENS.openAiLegacyKey, buildOpenAiLegacyKey()],
38
+ [FIXTURE_TOKENS.openAiProjectKey, buildOpenAiProjectKey()],
39
+ [FIXTURE_TOKENS.rsaPrivateKeyHeader, buildPemHeader('RSA ')],
40
+ [FIXTURE_TOKENS.ecPrivateKeyHeader, buildPemHeader('EC ')],
41
+ [FIXTURE_TOKENS.privateKeyHeader, buildPemHeader('')],
42
+ ];
43
+ }
44
+
45
+ function expandFixturePlaceholders(value) {
46
+ let expanded = String(value || '');
47
+ for (const [token, replacement] of fixtureReplacements()) {
48
+ expanded = expanded.split(token).join(replacement);
49
+ }
50
+ return expanded;
51
+ }
52
+
53
+ module.exports = {
54
+ FIXTURE_TOKENS,
55
+ buildAwsAccessKeyId,
56
+ buildGitHubPat,
57
+ buildOpenAiLegacyKey,
58
+ buildOpenAiProjectKey,
59
+ buildPemHeader,
60
+ expandFixturePlaceholders,
61
+ };