thumbgate 1.26.8 → 1.27.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.
Files changed (57) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/agentic-verify.txt +1 -0
  3. package/.well-known/llms.txt +2 -0
  4. package/.well-known/mcp/server-card.json +1 -1
  5. package/README.md +44 -31
  6. package/adapters/claude/.mcp.json +2 -2
  7. package/adapters/gcp/dfcx-webhook-gate.js +295 -0
  8. package/adapters/mcp/server-stdio.js +41 -1
  9. package/adapters/opencode/opencode.json +1 -1
  10. package/bench/thumbgate-bench.json +2 -2
  11. package/bin/cli.js +184 -8
  12. package/bin/dashboard-cli.js +7 -0
  13. package/config/gate-classifier-routing.json +98 -0
  14. package/config/gate-templates.json +60 -0
  15. package/config/mcp-allowlists.json +8 -7
  16. package/config/model-candidates.json +71 -6
  17. package/package.json +28 -12
  18. package/public/about.html +162 -0
  19. package/public/chatgpt-app.html +330 -0
  20. package/public/codex-plugin.html +66 -14
  21. package/public/compare.html +2 -2
  22. package/public/dashboard.html +224 -36
  23. package/public/guide.html +2 -2
  24. package/public/index.html +122 -40
  25. package/public/learn.html +70 -0
  26. package/public/lessons.html +129 -6
  27. package/public/numbers.html +2 -2
  28. package/public/pricing.html +28 -23
  29. package/public/pro.html +3 -3
  30. package/scripts/agent-operations-planner.js +621 -0
  31. package/scripts/agent-reward-model.js +53 -1
  32. package/scripts/ai-component-inventory.js +367 -0
  33. package/scripts/classifier-routing.js +130 -0
  34. package/scripts/cli-schema.js +26 -0
  35. package/scripts/commercial-offer.js +10 -2
  36. package/scripts/dashboard-chat.js +199 -51
  37. package/scripts/feedback-sanitizer.js +105 -0
  38. package/scripts/gates-engine.js +301 -67
  39. package/scripts/hybrid-feedback-context.js +141 -7
  40. package/scripts/memory-scope-readiness.js +159 -0
  41. package/scripts/oss-pr-opportunity-scout.js +35 -5
  42. package/scripts/parallel-workflow-orchestrator.js +293 -0
  43. package/scripts/plausible-domain-config.js +86 -0
  44. package/scripts/plausible-server-events.js +4 -2
  45. package/scripts/proxy-pointer-rag-guardrails.js +42 -1
  46. package/scripts/qa-scenario-planner.js +136 -0
  47. package/scripts/rate-limiter.js +2 -2
  48. package/scripts/repeat-metric.js +28 -12
  49. package/scripts/secret-fixture-tokens.js +61 -0
  50. package/scripts/secret-scanner.js +44 -5
  51. package/scripts/security-scanner.js +80 -0
  52. package/scripts/seo-gsd.js +113 -0
  53. package/scripts/thumbgate-bench.js +16 -1
  54. package/scripts/tool-registry.js +37 -0
  55. package/scripts/workflow-sentinel.js +282 -54
  56. package/src/api/server.js +466 -60
  57. package/.claude-plugin/marketplace.json +0 -85
@@ -0,0 +1,293 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { getFeedbackPaths } = require('./feedback-loop');
6
+ const { ensureDir } = require('./fs-utils');
7
+ const { loadOptionalModule } = require('./private-core-boundary');
8
+
9
+ const launcher = loadOptionalModule(path.join(__dirname, 'hosted-job-launcher'), () => ({
10
+ launchManagedJob: () => {
11
+ throw new Error('Managed jobs require ThumbGate-Core.');
12
+ },
13
+ resumeHostedJob: () => {
14
+ throw new Error('Resuming hosted jobs requires ThumbGate-Core.');
15
+ },
16
+ }));
17
+
18
+ const runner = loadOptionalModule(path.join(__dirname, 'async-job-runner'), () => ({
19
+ readJobState: () => null,
20
+ listJobStates: () => [],
21
+ }));
22
+
23
+ const { launchManagedJob, resumeHostedJob } = launcher;
24
+ const { readJobState, listJobStates } = runner;
25
+
26
+ const DEFAULT_CONCURRENCY = 3;
27
+ const POLL_INTERVAL_MS = 200;
28
+
29
+ function nowIso() {
30
+ return new Date().toISOString();
31
+ }
32
+
33
+ /**
34
+ * Dynamically decompose a high-level objective into parallel, specialized subtasks.
35
+ * Supports rule-based fallback and can be extended to use LLM planning.
36
+ */
37
+ function planWorkflow(objective) {
38
+ const obj = (objective || '').toLowerCase().trim();
39
+ const subtasks = [];
40
+
41
+ if (obj.includes('security') || obj.includes('audit') || obj.includes('leak') || obj.includes('secret')) {
42
+ subtasks.push({
43
+ name: 'scan_secrets',
44
+ tags: ['security', 'secret-scanner'],
45
+ stages: [
46
+ {
47
+ name: 'secret_scan',
48
+ command: 'node scripts/secret-scanner.js --json || true',
49
+ }
50
+ ]
51
+ });
52
+ subtasks.push({
53
+ name: 'audit_dependencies',
54
+ tags: ['security', 'dependencies'],
55
+ stages: [
56
+ {
57
+ name: 'npm_audit',
58
+ command: 'npm audit --json || true',
59
+ }
60
+ ]
61
+ });
62
+ subtasks.push({
63
+ name: 'check_permissions',
64
+ tags: ['security', 'credentials'],
65
+ stages: [
66
+ {
67
+ name: 'credential_gate_check',
68
+ command: 'node scripts/single-use-credential-gate.js plan || true',
69
+ }
70
+ ]
71
+ });
72
+ } else if (obj.includes('performance') || obj.includes('benchmark') || obj.includes('bench')) {
73
+ subtasks.push({
74
+ name: 'benchmark_candidates',
75
+ tags: ['performance', 'bench'],
76
+ stages: [
77
+ {
78
+ name: 'run_bench',
79
+ command: 'npx thumbgate bench --json --min-score=90 || true',
80
+ }
81
+ ]
82
+ });
83
+ subtasks.push({
84
+ name: 'check_budget',
85
+ tags: ['performance', 'budget'],
86
+ stages: [
87
+ {
88
+ name: 'budget_status',
89
+ command: 'node scripts/budget-guard.js --status || true',
90
+ }
91
+ ]
92
+ });
93
+ } else {
94
+ // Default general-purpose fallback workflow: code search and check integrity
95
+ subtasks.push({
96
+ name: 'code_search',
97
+ tags: ['exploration'],
98
+ stages: [
99
+ {
100
+ name: 'search_fs',
101
+ command: 'node scripts/filesystem-search.js --query="pretool" --limit=5 || true',
102
+ }
103
+ ]
104
+ });
105
+ subtasks.push({
106
+ name: 'check_integrity',
107
+ tags: ['integrity'],
108
+ stages: [
109
+ {
110
+ name: 'ops_integrity',
111
+ command: 'node scripts/operational-integrity.js --ci || true',
112
+ }
113
+ ]
114
+ });
115
+ }
116
+
117
+ return {
118
+ objective,
119
+ plannedAt: nowIso(),
120
+ subtasks: subtasks.map((task, idx) => ({
121
+ ...task,
122
+ id: `subtask_${Date.now()}_${idx}_${Math.random().toString(36).slice(2, 6)}`,
123
+ autoImprove: false,
124
+ verificationMode: 'none',
125
+ recordFeedback: false,
126
+ })),
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Execute a list of planned subtasks in parallel, respecting a concurrency limit.
132
+ * Polls active jobs until all complete, then consolidates the results.
133
+ */
134
+ async function executeWorkflow(objective, options = {}) {
135
+ const plan = planWorkflow(objective);
136
+ const concurrency = Number(options.concurrency) || DEFAULT_CONCURRENCY;
137
+ const timeoutMs = Number(options.timeoutMs) || 60000; // 60s timeout safety
138
+
139
+ const { FEEDBACK_DIR } = getFeedbackPaths();
140
+ const workflowId = `wf_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
141
+ const workflowDir = path.join(FEEDBACK_DIR, 'workflows', workflowId);
142
+ ensureDir(workflowDir);
143
+
144
+ const activeJobs = new Map();
145
+ const queue = [...plan.subtasks];
146
+ const results = [];
147
+ const start = Date.now();
148
+
149
+ const runNext = () => {
150
+ while (activeJobs.size < concurrency && queue.length > 0) {
151
+ const task = queue.shift();
152
+ const launched = launchManagedJob(task, { cwd: options.cwd });
153
+ activeJobs.set(task.id, {
154
+ jobId: launched.jobId,
155
+ taskName: task.name,
156
+ launchedAt: Date.now(),
157
+ });
158
+ }
159
+ };
160
+
161
+ runNext();
162
+
163
+ // Polling loop
164
+ await new Promise((resolve) => {
165
+ const interval = setInterval(() => {
166
+ let allDone = true;
167
+
168
+ for (const [taskId, info] of activeJobs.entries()) {
169
+ const jobState = readJobState(info.jobId);
170
+ if (!jobState) {
171
+ allDone = false;
172
+ continue;
173
+ }
174
+
175
+ const isTerminal = ['completed', 'failed', 'cancelled'].includes(jobState.status);
176
+ if (isTerminal) {
177
+ results.push({
178
+ taskId,
179
+ taskName: info.taskName,
180
+ jobId: info.jobId,
181
+ status: jobState.status,
182
+ context: jobState.currentContext,
183
+ stageHistory: jobState.stageHistory,
184
+ lastError: jobState.lastError,
185
+ });
186
+ activeJobs.delete(taskId);
187
+ runNext();
188
+ } else {
189
+ allDone = false;
190
+ }
191
+ }
192
+
193
+ const elapsed = Date.now() - start;
194
+ if (allDone && queue.length === 0) {
195
+ clearInterval(interval);
196
+ resolve();
197
+ } else if (elapsed >= timeoutMs) {
198
+ clearInterval(interval);
199
+ // Timeout remaining active tasks
200
+ for (const [taskId, info] of activeJobs.entries()) {
201
+ results.push({
202
+ taskId,
203
+ taskName: info.taskName,
204
+ jobId: info.jobId,
205
+ status: 'timeout',
206
+ lastError: { message: `Subtask timed out after ${timeoutMs}ms`, code: 'TIMEOUT' },
207
+ });
208
+ }
209
+ resolve();
210
+ }
211
+ }, POLL_INTERVAL_MS);
212
+ });
213
+
214
+ const durationMs = Date.now() - start;
215
+
216
+ // Compile final markdown report
217
+ const reportPath = path.join(workflowDir, 'report.md');
218
+ const reportContent = compileWorkflowReport(plan, results, durationMs, workflowId);
219
+ fs.writeFileSync(reportPath, reportContent, 'utf8');
220
+
221
+ // Also save the raw execution results JSON
222
+ const resultsJsonPath = path.join(workflowDir, 'results.json');
223
+ fs.writeFileSync(resultsJsonPath, JSON.stringify({
224
+ workflowId,
225
+ objective,
226
+ durationMs,
227
+ plan,
228
+ results,
229
+ }, null, 2) + '\n', 'utf8');
230
+
231
+ return {
232
+ workflowId,
233
+ objective,
234
+ durationMs,
235
+ reportPath,
236
+ results,
237
+ };
238
+ }
239
+
240
+ function compileWorkflowReport(plan, results, durationMs, workflowId) {
241
+ const timestamp = nowIso();
242
+ const totalSubtasks = plan.subtasks.length;
243
+ const completed = results.filter((r) => r.status === 'completed').length;
244
+ const failed = results.filter((r) => r.status === 'failed' || r.status === 'timeout').length;
245
+
246
+ let report = `# Dynamic Workflow Execution Report: ${workflowId}\n\n`;
247
+ report += `**Objective:** ${plan.objective}\n`;
248
+ report += `**Executed At:** ${timestamp}\n`;
249
+ report += `**Duration:** ${(durationMs / 1000).toFixed(2)}s\n`;
250
+ report += `**Status:** ${completed === totalSubtasks ? '✅ SUCCESS' : '⚠️ COMPLETED WITH FAILURES'}\n\n`;
251
+
252
+ report += `## Summary\n`;
253
+ report += `- Total planned subtasks: ${totalSubtasks}\n`;
254
+ report += `- Completed successfully: ${completed}\n`;
255
+ report += `- Failed/Timed out: ${failed}\n\n`;
256
+
257
+ report += `## Subtask Breakdown\n\n`;
258
+
259
+ for (const res of results) {
260
+ const taskPlan = plan.subtasks.find((t) => t.id === res.taskId) || {};
261
+ const commandUsed = taskPlan.stages && taskPlan.stages[0] ? taskPlan.stages[0].command : 'N/A';
262
+
263
+ report += `### ✦ Subtask: \`${res.taskName}\`\n`;
264
+ report += `- **Job ID:** \`${res.jobId}\`\n`;
265
+ report += `- **Status:** ${res.status === 'completed' ? '✅ COMPLETED' : '❌ ' + res.status.toUpperCase()}\n`;
266
+ report += `- **Command Run:** \`${commandUsed}\`\n`;
267
+
268
+ if (res.lastError) {
269
+ report += `- **Error:** \`${res.lastError.message}\` (Code: \`${res.lastError.code}\`)\n`;
270
+ }
271
+
272
+ if (res.context) {
273
+ report += `\n**Output Context Preview:**\n\`\`\`json\n`;
274
+ try {
275
+ // Try parsing output context as JSON for clean formatting
276
+ const parsed = JSON.parse(res.context);
277
+ report += JSON.stringify(parsed, null, 2);
278
+ } catch {
279
+ report += res.context.slice(0, 1000) + (res.context.length > 1000 ? '\n... (truncated)' : '');
280
+ }
281
+ report += `\n\`\`\`\n`;
282
+ }
283
+ report += `\n---\n\n`;
284
+ }
285
+
286
+ return report;
287
+ }
288
+
289
+ module.exports = {
290
+ planWorkflow,
291
+ executeWorkflow,
292
+ compileWorkflowReport,
293
+ };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ const PRIMARY_PLAUSIBLE_DOMAIN = 'thumbgate.ai';
4
+ const FALLBACK_REGISTERED_PLAUSIBLE_DOMAIN = 'thumbgate-production.up.railway.app';
5
+
6
+ function splitDomains(value) {
7
+ return String(value || '')
8
+ .split(/[\s,]+/)
9
+ .map((domain) => domain.trim().toLowerCase())
10
+ .filter(Boolean);
11
+ }
12
+
13
+ function normalizeDomain(value) {
14
+ const input = String(value || '').trim();
15
+ if (!input) return '';
16
+ try {
17
+ return new URL(input.includes('://') ? input : `https://${input}`).host.toLowerCase();
18
+ } catch {
19
+ return input.replace(/^https?:\/\//i, '').replace(/\/.*$/, '').toLowerCase();
20
+ }
21
+ }
22
+
23
+ function getConfiguredRegisteredDomains(env = process.env) {
24
+ const configured = [
25
+ ...splitDomains(env.PLAUSIBLE_SITE_ID),
26
+ ...splitDomains(env.PLAUSIBLE_SITE_IDS),
27
+ ...splitDomains(env.THUMBGATE_PLAUSIBLE_REGISTERED_DOMAINS),
28
+ ...splitDomains(env.PLAUSIBLE_REGISTERED_DOMAINS),
29
+ ].map(normalizeDomain).filter(Boolean);
30
+
31
+ return [...new Set([
32
+ FALLBACK_REGISTERED_PLAUSIBLE_DOMAIN,
33
+ ...configured,
34
+ ])];
35
+ }
36
+
37
+ function isPlausibleDomainRegistered(domain, env = process.env) {
38
+ const normalized = normalizeDomain(domain);
39
+ if (!normalized) return false;
40
+ return getConfiguredRegisteredDomains(env).includes(normalized);
41
+ }
42
+
43
+ function resolvePlausibleDataDomain({ host = '', env = process.env } = {}) {
44
+ const explicit = normalizeDomain(env.THUMBGATE_PLAUSIBLE_DOMAIN);
45
+ if (explicit) return explicit;
46
+
47
+ const normalizedHost = normalizeDomain(host);
48
+ if (isPlausibleDomainRegistered(normalizedHost, env)) {
49
+ return normalizedHost;
50
+ }
51
+
52
+ return FALLBACK_REGISTERED_PLAUSIBLE_DOMAIN;
53
+ }
54
+
55
+ function analyzePlausibleDomainCoverage({
56
+ emittedDomains = [],
57
+ registeredDomains = [],
58
+ primaryDomain = PRIMARY_PLAUSIBLE_DOMAIN,
59
+ } = {}) {
60
+ const emitted = [...new Set(emittedDomains.map(normalizeDomain).filter(Boolean))];
61
+ const registered = [...new Set(registeredDomains.map(normalizeDomain).filter(Boolean))];
62
+ const registeredSet = new Set(registered);
63
+ const missingEmittedDomains = emitted.filter((domain) => !registeredSet.has(domain));
64
+ const primaryRegistered = registeredSet.has(normalizeDomain(primaryDomain));
65
+
66
+ return {
67
+ ok: missingEmittedDomains.length === 0 && primaryRegistered,
68
+ emittedDomains: emitted,
69
+ registeredDomains: registered,
70
+ missingEmittedDomains,
71
+ primaryDomain: normalizeDomain(primaryDomain),
72
+ primaryRegistered,
73
+ severity: missingEmittedDomains.length > 0 || !primaryRegistered ? 'critical' : 'ok',
74
+ };
75
+ }
76
+
77
+ module.exports = {
78
+ PRIMARY_PLAUSIBLE_DOMAIN,
79
+ FALLBACK_REGISTERED_PLAUSIBLE_DOMAIN,
80
+ splitDomains,
81
+ normalizeDomain,
82
+ getConfiguredRegisteredDomains,
83
+ isPlausibleDomainRegistered,
84
+ resolvePlausibleDataDomain,
85
+ analyzePlausibleDomainCoverage,
86
+ };
@@ -24,8 +24,10 @@
24
24
  */
25
25
 
26
26
  const https = require('node:https');
27
+ const {
28
+ resolvePlausibleDataDomain,
29
+ } = require('./plausible-domain-config');
27
30
 
28
- const DEFAULT_PLAUSIBLE_DOMAIN = 'thumbgate.ai';
29
31
  const PLAUSIBLE_ENDPOINT = 'https://plausible.io/api/event';
30
32
  const REQUEST_TIMEOUT_MS = 2_000;
31
33
 
@@ -40,7 +42,7 @@ function isPlausibleDisabled() {
40
42
  }
41
43
 
42
44
  function getPlausibleDomain() {
43
- return process.env.THUMBGATE_PLAUSIBLE_DOMAIN || DEFAULT_PLAUSIBLE_DOMAIN;
45
+ return resolvePlausibleDataDomain();
44
46
  }
45
47
 
46
48
  /**
@@ -39,9 +39,23 @@ function normalizeOptions(options = {}) {
39
39
  ...splitCsv(options.documents),
40
40
  ...splitCsv(options['document-ids']),
41
41
  ]);
42
+ const sourcePointers = unique([
43
+ ...splitCsv(options['source-pointers']),
44
+ ...splitCsv(options.pointers),
45
+ ...splitCsv(options.sources),
46
+ ]);
42
47
  const candidateImages = Number.isFinite(Number(options['candidate-images']))
43
48
  ? Number(options['candidate-images'])
44
49
  : null;
50
+ const extractedEntities = Number.isFinite(Number(options['extracted-entities']))
51
+ ? Number(options['extracted-entities'])
52
+ : 0;
53
+ const extractedRelations = Number.isFinite(Number(options['extracted-relations']))
54
+ ? Number(options['extracted-relations'])
55
+ : 0;
56
+ const promotionThreshold = Number.isFinite(Number(options['promotion-threshold']))
57
+ ? Number(options['promotion-threshold'])
58
+ : 3;
45
59
 
46
60
  return {
47
61
  ragTool: String(options['rag-tool'] || options.tool || 'proxy-pointer-rag').trim() || 'proxy-pointer-rag',
@@ -49,10 +63,15 @@ function normalizeOptions(options = {}) {
49
63
  sectionIds,
50
64
  imagePointers,
51
65
  documentIds,
66
+ sourcePointers,
52
67
  candidateImages,
68
+ extractedEntities,
69
+ extractedRelations,
70
+ promotionThreshold,
53
71
  crossDocumentPolicy: String(options['cross-doc-policy'] || options['cross-document-policy'] || '').trim().toLowerCase(),
54
72
  visionFilter: normalizeBoolean(options['vision-filter']),
55
73
  visualClaims: normalizeBoolean(options['visual-claims']),
74
+ pointerFirst: normalizeBoolean(options['pointer-first']) || normalizeBoolean(options['proxy-pointer']),
56
75
  };
57
76
  }
58
77
 
@@ -72,6 +91,14 @@ function gateApplicability(template, options) {
72
91
  return false;
73
92
  }
74
93
 
94
+ function hasExtractionSprawl(options) {
95
+ const extractedFacts = options.extractedEntities + options.extractedRelations;
96
+ if (extractedFacts === 0) return false;
97
+ if (options.pointerFirst) return true;
98
+ if (options.sourcePointers.length === 0) return true;
99
+ return extractedFacts > options.sourcePointers.length * Math.max(2, options.promotionThreshold);
100
+ }
101
+
75
102
  function buildSignalSummary(options) {
76
103
  const signals = [];
77
104
  if (options.treePath || options.sectionIds.length > 0) {
@@ -110,6 +137,19 @@ function buildSignalSummary(options) {
110
137
  risk: 'answers that describe image content may need a vision-model sanity check',
111
138
  });
112
139
  }
140
+ if (hasExtractionSprawl(options)) {
141
+ signals.push({
142
+ id: 'entity_relation_sprawl',
143
+ label: 'Entity/relation extraction sprawl',
144
+ values: unique([
145
+ `${options.extractedEntities} extracted entities`,
146
+ `${options.extractedRelations} extracted relations`,
147
+ `${options.sourcePointers.length} source pointers`,
148
+ `promotion threshold ${options.promotionThreshold}`,
149
+ ]),
150
+ risk: 'eager graph extraction can create stale aliases, weak edges, and unauditable memory; keep source pointers first and promote relations only after repeated retrieval value',
151
+ });
152
+ }
113
153
  return signals;
114
154
  }
115
155
 
@@ -139,11 +179,12 @@ function buildProxyPointerRagGuardrailsPlan(rawOptions = {}, templatesPath) {
139
179
  templates: recommendedTemplates,
140
180
  nextActions: [
141
181
  'Preserve document hierarchy, section IDs, and image file paths during ingestion.',
182
+ 'Store source pointers before extracting entities or relations; promote a relation only after repeated retrieval value and source verification.',
142
183
  'Pass section-tree and image-pointer metadata into the agent before it answers with visuals.',
143
184
  'Enable the recommended Document RAG Safety templates as pre-action gates.',
144
185
  'Use a vision filter only for high-impact answers that make claims about visual content.',
145
186
  ],
146
- exampleCommand: 'npx thumbgate proxy-pointer-rag-guardrails --tree-path=.rag/tree.json --image-pointers=paper-1/figures/fig2.png --documents=paper-1 --visual-claims --json',
187
+ exampleCommand: 'npx thumbgate proxy-pointer-rag-guardrails --tree-path=.rag/tree.json --source-pointers=lesson/fb_123,tool/run_456 --extracted-entities=120 --extracted-relations=80 --pointer-first --json',
147
188
  };
148
189
  }
149
190
 
@@ -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
+ };
@@ -6,7 +6,7 @@ const path = require('path');
6
6
  const {
7
7
  PRO_MONTHLY_PAYMENT_LINK,
8
8
  PRO_PRICE_LABEL,
9
- TEAM_PRICE_LABEL,
9
+ ENTERPRISE_PRICE_LABEL,
10
10
  } = require('./commercial-offer');
11
11
 
12
12
  const USAGE_FILE = path.join(process.env.HOME || '/tmp', '.thumbgate', 'usage-limits.json');
@@ -31,7 +31,7 @@ const FREE_TIER_LIMITS = {
31
31
  const FREE_TIER_MAX_GATES = 3; // 3 active prevention rules on free; Pro is unlimited
32
32
  const FREE_TIER_DAILY_BLOCKS = 3; // 3 gate blocks/day on free; after limit, deny → warn + upgrade CTA
33
33
 
34
- const UPGRADE_MESSAGE = `Pro: ${PRO_PRICE_LABEL} — unlimited rules, recall, lesson search, dashboard, and exports: ${PRO_MONTHLY_PAYMENT_LINK}\n Team: ${TEAM_PRICE_LABEL} after workflow qualification.`;
34
+ const UPGRADE_MESSAGE = `Pro: ${PRO_PRICE_LABEL} — unlimited rules, recall, lesson search, dashboard, and exports: ${PRO_MONTHLY_PAYMENT_LINK}\n Enterprise: ${ENTERPRISE_PRICE_LABEL} after workflow qualification.`;
35
35
 
36
36
  const PAYWALL_MESSAGES = {
37
37
  capture_feedback: 'Free tier: 5 captures/day (25 total). Your feedback is stored locally — upgrade to capture unlimited.',