thumbgate 1.30.0 → 1.34.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 (92) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +54 -16
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/forge/forge.yaml +3 -3
  6. package/adapters/mcp/server-stdio.js +105 -10
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +168 -31
  10. package/config/evals/generation-quality-golden.json +95 -0
  11. package/config/evals/rag-answer-quality-golden.json +91 -0
  12. package/config/evals/retrieval-hybrid-ablation.json +66 -0
  13. package/config/evals/retrieval-ranking-golden.json +522 -0
  14. package/config/gates/claim-verifiers.example.json +42 -0
  15. package/config/gates/claim-verifiers.json +25 -0
  16. package/config/gates/default.json +217 -50
  17. package/config/mcp-allowlists.json +233 -206
  18. package/config/model-tiers.json +7 -2
  19. package/glama.json +6 -0
  20. package/hooks/hooks.json +1 -1
  21. package/package.json +69 -12
  22. package/public/assets/diagrams/before-after.svg +17 -16
  23. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  24. package/public/assets/diagrams/loop.svg +19 -13
  25. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  26. package/public/compare.html +1 -0
  27. package/public/dashboard.html +126 -28
  28. package/public/evaluations.html +1 -1
  29. package/public/index.html +142 -13
  30. package/public/numbers.html +3 -2
  31. package/public/pricing.html +143 -30
  32. package/scripts/a-plus-evidence-scorecard.js +303 -0
  33. package/scripts/agent-readiness.js +110 -0
  34. package/scripts/async-eval-observability.js +36 -11
  35. package/scripts/audit-trail.js +37 -1
  36. package/scripts/auto-promote-gates.js +149 -34
  37. package/scripts/auto-wire-hooks.js +20 -8
  38. package/scripts/cli-schema.js +14 -0
  39. package/scripts/colbert-style-maxsim.js +236 -0
  40. package/scripts/cross-encoder-reranker.js +356 -126
  41. package/scripts/dashboard-chat.js +350 -17
  42. package/scripts/document-intake.js +283 -7
  43. package/scripts/eval-quality-suite.js +204 -0
  44. package/scripts/feedback-loop.js +115 -7
  45. package/scripts/feedback-paths.js +32 -13
  46. package/scripts/feedback-quality.js +53 -0
  47. package/scripts/feedback-schema.js +3 -0
  48. package/scripts/file-ledger-lock.js +130 -0
  49. package/scripts/filesystem-search.js +17 -7
  50. package/scripts/financial-control-plane.js +1514 -0
  51. package/scripts/gates-engine.js +202 -7
  52. package/scripts/gemini-embedding-policy.js +1 -0
  53. package/scripts/harness-tool-names.js +70 -0
  54. package/scripts/hook-runtime.js +15 -3
  55. package/scripts/hook-stop-anti-claim.js +63 -3
  56. package/scripts/human-escalation.js +353 -41
  57. package/scripts/lesson-db.js +16 -5
  58. package/scripts/lesson-embedding-index.js +67 -20
  59. package/scripts/lesson-embedding-maintenance.js +177 -0
  60. package/scripts/lesson-reranker.js +55 -9
  61. package/scripts/lesson-retrieval.js +305 -29
  62. package/scripts/lesson-search.js +22 -8
  63. package/scripts/llm-client.js +304 -15
  64. package/scripts/model-tier-router.js +593 -0
  65. package/scripts/pragmatic-hybrid-search.js +379 -0
  66. package/scripts/provider-action-normalizer.js +11 -4
  67. package/scripts/rag-document-pipeline.js +461 -0
  68. package/scripts/rag-structured-output.js +441 -0
  69. package/scripts/ragas-style-metrics.js +351 -0
  70. package/scripts/request-envelope.js +178 -0
  71. package/scripts/rerank-pipeline.js +370 -0
  72. package/scripts/rerank-quality-eval.js +155 -0
  73. package/scripts/retrieval-hybrid-ablation.js +120 -0
  74. package/scripts/retrieval-quality-tier.js +118 -0
  75. package/scripts/secret-scanner.js +395 -4
  76. package/scripts/self-distill-agent.js +7 -1
  77. package/scripts/self-healing-check.js +25 -0
  78. package/scripts/skill-packs.js +183 -0
  79. package/scripts/slow-loop.js +72 -0
  80. package/scripts/statusline-links.js +1 -1
  81. package/scripts/statusline.sh +8 -1
  82. package/scripts/telemetry-analytics.js +13 -1
  83. package/scripts/thumbgate-search.js +98 -6
  84. package/scripts/tier-budget-guard.js +186 -0
  85. package/scripts/tool-registry.js +141 -5
  86. package/scripts/universal-claim-evaluator.js +767 -0
  87. package/scripts/vector-store.js +154 -17
  88. package/scripts/verify-marketing-pages-deployed.js +85 -3
  89. package/scripts/workflow-sentinel.js +77 -11
  90. package/server.json +44 -0
  91. package/smithery.yaml +17 -0
  92. package/src/api/server.js +196 -13
@@ -10,6 +10,7 @@ const {
10
10
  } = require('./local-model-profile');
11
11
  const {
12
12
  prepareEmbeddingText,
13
+ normalizeEmbeddingKind,
13
14
  resolveGeminiEmbeddingConfig,
14
15
  resolveGeminiModelResource,
15
16
  resolveGeminiTaskType,
@@ -80,6 +81,42 @@ function hasLocalTransformerProvider() {
80
81
  }
81
82
  }
82
83
 
84
+ function getOllamaEmbeddingConfig() {
85
+ const model = String(process.env.THUMBGATE_OLLAMA_EMBED_MODEL || '').trim();
86
+ let endpoint = String(
87
+ process.env.THUMBGATE_OLLAMA_ENDPOINT
88
+ || process.env.OLLAMA_HOST
89
+ || 'http://127.0.0.1:11434',
90
+ ).trim();
91
+ if (endpoint && !/^https?:\/\//i.test(endpoint)) endpoint = `http://${endpoint}`;
92
+ while (endpoint.endsWith('/')) endpoint = endpoint.slice(0, -1);
93
+ return {
94
+ enabled: Boolean(model),
95
+ model,
96
+ endpoint,
97
+ timeoutMs: Math.max(
98
+ 250,
99
+ Math.min(30_000, Number(process.env.THUMBGATE_OLLAMA_TIMEOUT_MS) || 10_000),
100
+ ),
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Synchronous capability check used by hot-path routing. This reports only
106
+ * semantic providers, never the deterministic feature-hash degradation.
107
+ */
108
+ function hasSemanticEmbeddingProvider() {
109
+ if (process.env.THUMBGATE_VECTOR_STUB_EMBED === 'true') return true;
110
+ if (getOllamaEmbeddingConfig().enabled) return true;
111
+ if (hasLocalTransformerProvider()) return true;
112
+ try {
113
+ const config = resolveGeminiEmbeddingConfig();
114
+ return config.provider === 'coreai' || Boolean(config.apiKey);
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
83
120
  function fnv1a32(value) {
84
121
  let hash = 0x811c9dc5;
85
122
  const bytes = Buffer.from(String(value), 'utf8');
@@ -98,7 +135,7 @@ function addHashedFeature(vector, feature, weight) {
98
135
  }
99
136
 
100
137
  function embedWithFeatureHash(text) {
101
- const vector = Array(FEATURE_HASH_DIMENSIONS).fill(0);
138
+ const vector = new Array(FEATURE_HASH_DIMENSIONS).fill(0);
102
139
  const tokens = String(text || '').toLowerCase().match(/[\p{L}\p{N}_-]+/gu) || [];
103
140
 
104
141
  for (let index = 0; index < tokens.length; index += 1) {
@@ -175,7 +212,7 @@ async function embedWithGemini(text, options = {}) {
175
212
  }
176
213
 
177
214
  if (typeof fetch !== 'function') {
178
- throw new Error('Gemini embeddings require global fetch. Use Node 18.18+ or the local embedding provider.');
215
+ throw new TypeError('Gemini embeddings require global fetch. Use Node 18.18+ or the local embedding provider.');
179
216
  }
180
217
 
181
218
  const modelResource = resolveGeminiModelResource(config.model);
@@ -249,6 +286,87 @@ async function embedWithCoreAI(text, options = {}) {
249
286
  throw new Error('Core AI local service did not return a valid embedding');
250
287
  }
251
288
 
289
+ async function embedWithOllama(text, options = {}) {
290
+ const config = getOllamaEmbeddingConfig();
291
+ if (!config.enabled) {
292
+ throw new Error('Ollama embeddings require THUMBGATE_OLLAMA_EMBED_MODEL');
293
+ }
294
+ if (typeof fetch !== 'function') {
295
+ throw new TypeError('Ollama embeddings require global fetch. Use Node 18.18+.');
296
+ }
297
+
298
+ // Apply Nomic-style asymmetric prefixes. nomic-embed-text was trained
299
+ // with "search_query:" / "search_document:" role prefixes, which improve
300
+ // query-document matching fidelity on the dense retrieval path.
301
+ const kind = normalizeEmbeddingKind(options.kind);
302
+ let inputText = String(text || '');
303
+ if (kind === 'query') {
304
+ inputText = `search_query: ${inputText}`;
305
+ } else if (kind === 'document') {
306
+ inputText = `search_document: ${inputText}`;
307
+ }
308
+
309
+ let response;
310
+ try {
311
+ response = await fetch(`${config.endpoint}/api/embed`, {
312
+ method: 'POST',
313
+ headers: { 'Content-Type': 'application/json' },
314
+ body: JSON.stringify({
315
+ model: config.model,
316
+ input: inputText,
317
+ truncate: true,
318
+ dimensions: options.outputDimensionality || undefined,
319
+ }),
320
+ signal: AbortSignal.timeout(config.timeoutMs),
321
+ });
322
+ } catch (error) {
323
+ throw new Error(`Ollama embedding service unavailable: ${error.message}`);
324
+ }
325
+
326
+ if (!response.ok) {
327
+ throw new Error(`Ollama embedding request failed: ${response.status} ${response.statusText}`);
328
+ }
329
+ const payload = await response.json();
330
+ const vector = Array.isArray(payload.embeddings) ? payload.embeddings[0] : null;
331
+ if (!Array.isArray(vector) || vector.length === 0) {
332
+ throw new Error('Ollama embedding response did not include vector values');
333
+ }
334
+ return vector.map(Number);
335
+ }
336
+
337
+ async function tryGeminiManagedEmbedding(text, options, geminiConfig) {
338
+ if (!geminiConfig.apiKey && !_geminiEmbedderForTests) {
339
+ return null;
340
+ }
341
+ try {
342
+ const vector = await embedWithGemini(text, options);
343
+ _lastEmbeddingProfile = {
344
+ generatedAt: new Date().toISOString(),
345
+ source: 'managed',
346
+ activeProfile: {
347
+ id: 'gemini',
348
+ model: geminiConfig.model,
349
+ outputDimensionality: geminiConfig.outputDimensionality,
350
+ task: options.task || geminiConfig.defaultTask,
351
+ rationale: geminiConfig.enabled
352
+ ? 'Managed Gemini Embedding 2 path with task-specific query/document prefixes.'
353
+ : 'Managed Gemini Embedding 2 fallback after local providers exhausted.',
354
+ },
355
+ fallbackUsed: !geminiConfig.enabled,
356
+ ...(!geminiConfig.enabled ? { fallbackReason: 'local_providers_exhausted' } : {}),
357
+ };
358
+ return vector;
359
+ } catch (geminiError) {
360
+ if (!geminiConfig.fallbackToLocal) {
361
+ throw geminiError;
362
+ }
363
+ // Do not log raw provider/user-controlled error text (Sonar jssecurity:S5145).
364
+ const code = geminiError && (geminiError.code || geminiError.name || 'Error');
365
+ console.warn(`Gemini embedding fallback: ${code}`);
366
+ return null;
367
+ }
368
+ }
369
+
252
370
  async function embed(text, options = {}) {
253
371
  if (process.env.THUMBGATE_VECTOR_STUB_EMBED === 'true') {
254
372
  // Deterministic 384-dim unit vector: first element = 1.0, rest = 0.0
@@ -277,29 +395,32 @@ async function embed(text, options = {}) {
277
395
  console.warn(`Core AI embedding failed, falling back to local: ${coreaiError.message}`);
278
396
  }
279
397
  }
280
- if (geminiConfig.enabled) {
398
+ const ollamaConfig = getOllamaEmbeddingConfig();
399
+ if (ollamaConfig.enabled) {
281
400
  try {
282
- const vector = await embedWithGemini(text, options);
401
+ const vector = await embedWithOllama(text, options);
283
402
  _lastEmbeddingProfile = {
284
403
  generatedAt: new Date().toISOString(),
285
- source: 'managed',
404
+ source: 'local-ollama',
286
405
  activeProfile: {
287
- id: 'gemini',
288
- model: geminiConfig.model,
289
- outputDimensionality: geminiConfig.outputDimensionality,
290
- task: options.task || geminiConfig.defaultTask,
291
- rationale: 'Managed Gemini Embedding 2 path with task-specific query/document prefixes.',
406
+ id: 'ollama',
407
+ model: ollamaConfig.model,
408
+ outputDimensionality: vector.length,
409
+ task: options.task || 'code retrieval',
410
+ rationale: 'Explicit local Ollama semantic embedding provider.',
411
+ qualityTier: 'production',
292
412
  },
293
413
  fallbackUsed: false,
294
414
  };
295
415
  return vector;
296
- } catch (geminiError) {
297
- if (!geminiConfig.fallbackToLocal) {
298
- throw geminiError;
299
- }
300
- console.warn(`Gemini embedding fallback: ${geminiError.message}`);
416
+ } catch (ollamaError) {
417
+ console.warn(`Ollama embedding failed, falling back: ${ollamaError.message}`);
301
418
  }
302
419
  }
420
+ if (geminiConfig.enabled) {
421
+ const vector = await tryGeminiManagedEmbedding(text, options, geminiConfig);
422
+ if (vector) return vector;
423
+ }
303
424
  if (hasLocalTransformerProvider()) {
304
425
  try {
305
426
  const { pipe, profile } = await getEmbeddingPipeline();
@@ -313,7 +434,17 @@ async function embed(text, options = {}) {
313
434
  }
314
435
  }
315
436
 
437
+ // Gemini managed fallback — only when API key present but Gemini is not the
438
+ // explicitly selected provider. Honors fallbackToLocal in the catch block
439
+ // so that THUMBGATE_GEMINI_EMBED_FALLBACK_LOCAL=false makes Gemini mandatory.
440
+ if (geminiConfig.apiKey && !geminiConfig.enabled) {
441
+ const vector = await tryGeminiManagedEmbedding(text, options, geminiConfig);
442
+ if (vector) return vector;
443
+ }
444
+
316
445
  const vector = embedWithFeatureHash(text);
446
+ // Feature-hash is a last-resort degrade, not production semantic quality.
447
+ // Callers (prove/eval/chat health) must treat quality_tier=degraded.
317
448
  _lastEmbeddingProfile = {
318
449
  generatedAt: new Date().toISOString(),
319
450
  source: 'built-in',
@@ -322,9 +453,11 @@ async function embed(text, options = {}) {
322
453
  model: 'ThumbGate feature hashing',
323
454
  outputDimensionality: FEATURE_HASH_DIMENSIONS,
324
455
  task: options.task || 'code retrieval',
325
- rationale: 'Deterministic zero-dependency local text embedding.',
456
+ rationale: 'DEGRADED: deterministic zero-dependency hash embedding — not semantic. Configure Gemini or local transformers for production retrieval quality.',
457
+ qualityTier: 'degraded',
326
458
  },
327
- fallbackUsed: false,
459
+ fallbackUsed: true,
460
+ fallbackReason: 'no_managed_or_transformer_embedder',
328
461
  };
329
462
  return vector;
330
463
  }
@@ -435,9 +568,13 @@ module.exports = {
435
568
  TABLE_NAME,
436
569
  getEmbeddingConfig,
437
570
  getLastEmbeddingProfile,
571
+ getActiveEmbeddingProfile: getLastEmbeddingProfile,
438
572
  setPipelineLoaderForTests,
439
573
  setLanceLoaderForTests,
440
574
  setGeminiEmbedderForTests,
441
575
  truncateForEmbedding,
442
576
  embedWithFeatureHash,
577
+ embedWithOllama,
578
+ getOllamaEmbeddingConfig,
579
+ hasSemanticEmbeddingProvider,
443
580
  };
@@ -22,6 +22,8 @@
22
22
  * --json machine-readable report on stdout, suitable for piping
23
23
  * into the GitHub Actions PR comment step.
24
24
  * --quiet suppress per-route lines; only print the final summary.
25
+ * --max-attempts=N retry only transient transport/5xx failures.
26
+ * --retry-delay-ms=N bounded delay between transient retries.
25
27
  *
26
28
  * Exit code is 0 when every page passes, 1 if any sentinel is missing
27
29
  * or any route returns non-200.
@@ -32,6 +34,8 @@ const path = require('node:path');
32
34
 
33
35
  const DEFAULT_PROD_URL = 'https://thumbgate-production.up.railway.app';
34
36
  const DEFAULT_TIMEOUT_MS = 12000;
37
+ const DEFAULT_MAX_ATTEMPTS = 2;
38
+ const DEFAULT_RETRY_DELAY_MS = 500;
35
39
  const DEFAULT_MANIFEST_PATH = path.resolve(__dirname, '..', 'config', 'post-deploy-marketing-pages.json');
36
40
 
37
41
  function parseArgs(argv = []) {
@@ -41,6 +45,8 @@ function parseArgs(argv = []) {
41
45
  json: false,
42
46
  quiet: false,
43
47
  timeoutMs: DEFAULT_TIMEOUT_MS,
48
+ maxAttempts: DEFAULT_MAX_ATTEMPTS,
49
+ retryDelayMs: DEFAULT_RETRY_DELAY_MS,
44
50
  };
45
51
  for (const arg of argv) {
46
52
  if (arg === '--json') out.json = true;
@@ -50,6 +56,12 @@ function parseArgs(argv = []) {
50
56
  else if (arg.startsWith('--timeout-ms=')) {
51
57
  const n = Number(arg.slice('--timeout-ms='.length));
52
58
  if (Number.isFinite(n) && n > 0) out.timeoutMs = n;
59
+ } else if (arg.startsWith('--max-attempts=')) {
60
+ const n = Number(arg.slice('--max-attempts='.length));
61
+ if (Number.isInteger(n) && n > 0 && n <= 5) out.maxAttempts = n;
62
+ } else if (arg.startsWith('--retry-delay-ms=')) {
63
+ const n = Number(arg.slice('--retry-delay-ms='.length));
64
+ if (Number.isInteger(n) && n >= 0 && n <= 5000) out.retryDelayMs = n;
53
65
  }
54
66
  }
55
67
  return out;
@@ -80,7 +92,7 @@ function loadManifest(manifestPath) {
80
92
  return parsed;
81
93
  }
82
94
 
83
- async function probePage({ prodUrl, route, sentinel, mustNotContain = [], userAgent, fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS }) {
95
+ async function probePageOnce({ prodUrl, route, sentinel, mustNotContain = [], userAgent, fetchImpl, timeoutMs }) {
84
96
  if (typeof fetchImpl !== 'function') {
85
97
  return { route, ok: false, error: 'fetch_unavailable' };
86
98
  }
@@ -97,7 +109,10 @@ async function probePage({ prodUrl, route, sentinel, mustNotContain = [], userAg
97
109
  Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
98
110
  },
99
111
  });
100
- const body = await res.text().catch(() => '');
112
+ // Keep body-read transport failures visible to the outer retry classifier.
113
+ // Treating a disconnected 2xx stream as an empty body would incorrectly
114
+ // turn an operational failure into a deterministic sentinel mismatch.
115
+ const body = await res.text();
101
116
  const sentinelPresent = body.includes(sentinel);
102
117
  const forbiddenPresent = Array.isArray(mustNotContain)
103
118
  ? mustNotContain.filter((value) => body.includes(value))
@@ -122,7 +137,67 @@ async function probePage({ prodUrl, route, sentinel, mustNotContain = [], userAg
122
137
  }
123
138
  }
124
139
 
125
- async function runVerification({ prodUrl, manifestPath, fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
140
+ function isTransientProbeFailure(result = {}) {
141
+ if (result.ok) return false;
142
+ if (result.error && result.error !== 'fetch_unavailable') return true;
143
+ return result.status === 429 || result.status >= 500;
144
+ }
145
+
146
+ function waitForRetry(delayMs) {
147
+ if (!delayMs) return Promise.resolve();
148
+ return new Promise((resolve) => setTimeout(resolve, delayMs));
149
+ }
150
+
151
+ async function probePage({
152
+ prodUrl,
153
+ route,
154
+ sentinel,
155
+ mustNotContain = [],
156
+ userAgent,
157
+ fetchImpl = globalThis.fetch,
158
+ timeoutMs = DEFAULT_TIMEOUT_MS,
159
+ maxAttempts = DEFAULT_MAX_ATTEMPTS,
160
+ retryDelayMs = DEFAULT_RETRY_DELAY_MS,
161
+ }) {
162
+ const boundedAttempts = Number.isInteger(maxAttempts)
163
+ ? Math.min(Math.max(maxAttempts, 1), 5)
164
+ : DEFAULT_MAX_ATTEMPTS;
165
+ let result;
166
+ for (let attempt = 1; attempt <= boundedAttempts; attempt += 1) {
167
+ // eslint-disable-next-line no-await-in-loop
168
+ result = await probePageOnce({
169
+ prodUrl,
170
+ route,
171
+ sentinel,
172
+ mustNotContain,
173
+ userAgent,
174
+ fetchImpl,
175
+ timeoutMs,
176
+ });
177
+ if (!isTransientProbeFailure(result) || attempt === boundedAttempts) {
178
+ return {
179
+ ...result,
180
+ attempts: attempt,
181
+ recoveredAfterRetry: Boolean(result.ok && attempt > 1),
182
+ };
183
+ }
184
+ // A deploy can pass /health while the first buyer-page request still
185
+ // wakes a cold instance. Retry only transient failures; content contract
186
+ // mismatches remain immediate, deterministic failures.
187
+ // eslint-disable-next-line no-await-in-loop
188
+ await waitForRetry(retryDelayMs);
189
+ }
190
+ return { ...result, attempts: boundedAttempts, recoveredAfterRetry: false };
191
+ }
192
+
193
+ async function runVerification({
194
+ prodUrl,
195
+ manifestPath,
196
+ fetchImpl = globalThis.fetch,
197
+ timeoutMs = DEFAULT_TIMEOUT_MS,
198
+ maxAttempts = DEFAULT_MAX_ATTEMPTS,
199
+ retryDelayMs = DEFAULT_RETRY_DELAY_MS,
200
+ } = {}) {
126
201
  const manifest = loadManifest(manifestPath);
127
202
  const results = [];
128
203
  // Sequential is fine — manifest has <20 entries; parallelism would
@@ -137,6 +212,8 @@ async function runVerification({ prodUrl, manifestPath, fetchImpl = globalThis.f
137
212
  userAgent: entry.userAgent,
138
213
  fetchImpl,
139
214
  timeoutMs,
215
+ maxAttempts,
216
+ retryDelayMs,
140
217
  });
141
218
  results.push({ ...result, sentinel: entry.sentinel, description: entry.description });
142
219
  }
@@ -183,6 +260,8 @@ async function main(argv) {
183
260
  prodUrl: args.prodUrl,
184
261
  manifestPath: args.manifestPath,
185
262
  timeoutMs: args.timeoutMs,
263
+ maxAttempts: args.maxAttempts,
264
+ retryDelayMs: args.retryDelayMs,
186
265
  });
187
266
  } catch (error) {
188
267
  process.stderr.write(`verify-marketing-pages-deployed FAILED: ${error.message}\n`);
@@ -199,9 +278,12 @@ async function main(argv) {
199
278
  module.exports = {
200
279
  DEFAULT_PROD_URL,
201
280
  DEFAULT_TIMEOUT_MS,
281
+ DEFAULT_MAX_ATTEMPTS,
282
+ DEFAULT_RETRY_DELAY_MS,
202
283
  DEFAULT_MANIFEST_PATH,
203
284
  parseArgs,
204
285
  loadManifest,
286
+ isTransientProbeFailure,
205
287
  probePage,
206
288
  runVerification,
207
289
  renderHuman,
@@ -22,6 +22,11 @@ const {
22
22
  buildWorkflowControl,
23
23
  normalizeProviderAction,
24
24
  } = require('./provider-action-normalizer');
25
+ const {
26
+ detectEconomicAction,
27
+ evaluateFinancialControl,
28
+ getFinancialControlRuntimeOptions,
29
+ } = require('./financial-control-plane');
25
30
 
26
31
  const GOVERNANCE_STATE_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'governance-state.json');
27
32
  const DEFAULT_PROTECTED_FILE_GLOBS = [
@@ -40,7 +45,6 @@ const DEFAULT_PROTECTED_FILE_GLOBS = [
40
45
  const EDIT_LIKE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
41
46
  const HIGH_RISK_BASH_PATTERN = /\b(?:git\s+(?:add|commit|push)|gh\s+(?:pr\s+(?:create|merge)|workflow\s+run|release\s+create)|npm\s+publish|yarn\s+publish|pnpm\s+publish|rm\s+-rf)\b/i;
42
47
  const BACKGROUND_AGENT_PATTERN = /\b(?:async(?:-job|-task)?|autonomous|background|cron|dispatch|heartbeat|job runner|job-runner|queue|queued|schedule|scheduled|worker|workflow run)\b/i;
43
- const ECONOMIC_ACTION_PATTERN = /\b(?:billing|charge|credit memo|invoice|payment(?: link|s)?|payout|refund|stripe|subscription(?:s| creation| update| cancel| delete)?|top-?up)\b/i;
44
48
  const CUSTOMER_SYSTEM_PATTERN = /\b(?:crm|customer|email|hubspot|intercom|mailgun|resend|salesforce|support|zendesk)\b/i;
45
49
 
46
50
  const SURFACE_RULES = [
@@ -259,7 +263,7 @@ function classifyActionProfile(toolInput = {}) {
259
263
  const economicAction = Boolean(
260
264
  toolInput.economicAction === true
261
265
  || metadata.economicAction === true
262
- || ECONOMIC_ACTION_PATTERN.test(combined)
266
+ || detectEconomicAction('', { ...toolInput, metadata: { ...metadata, context: combined } })
263
267
  );
264
268
  const customerSystemAction = Boolean(
265
269
  toolInput.customerSystemAction === true
@@ -573,6 +577,7 @@ function scoreRisk({
573
577
  taskScopeViolation,
574
578
  protectedSurface,
575
579
  costControl,
580
+ financialControl,
576
581
  workflowControl,
577
582
  workflowContract,
578
583
  actionProfile,
@@ -692,6 +697,15 @@ function scoreRisk({
692
697
  { mode: costControl.mode, reasons: costControl.reasons }
693
698
  );
694
699
  }
700
+ if (financialControl?.mode === 'block') {
701
+ addDriver(
702
+ drivers,
703
+ 'financial_control',
704
+ 0.65,
705
+ 'Deterministic purchase controls rejected the economic action.',
706
+ { reasonCodes: financialControl.reasonCodes }
707
+ );
708
+ }
695
709
  if (workflowControl && workflowControl.workflow && workflowControl.workflow.pattern !== 'single_action') {
696
710
  const workflow = workflowControl.workflow;
697
711
  if (workflow.pattern === 'agent') {
@@ -814,6 +828,7 @@ function buildEvidence({
814
828
  protectedSurface,
815
829
  normalizedAction,
816
830
  costControl,
831
+ financialControl,
817
832
  workflowControl,
818
833
  workflowContract,
819
834
  actionProfile,
@@ -827,6 +842,13 @@ function buildEvidence({
827
842
  if (costControl && costControl.mode && costControl.mode !== 'allow') {
828
843
  evidence.push(`Cost control ${costControl.mode}: ${costControl.reasons.join(' ')}`);
829
844
  }
845
+ if (financialControl?.economicAction) {
846
+ evidence.push(
847
+ financialControl.mode === 'allow'
848
+ ? `Financial control allow: approved reservation ${financialControl.authorization?.reservationId || 'unknown'}.`
849
+ : `Financial control block: ${financialControl.reasons.join(' ')}`
850
+ );
851
+ }
830
852
  if (workflowControl && workflowControl.workflow && workflowControl.workflow.pattern !== 'single_action') {
831
853
  const workflow = workflowControl.workflow;
832
854
  evidence.push(
@@ -960,6 +982,7 @@ function buildRemediations({
960
982
  learnedPolicy,
961
983
  executionSurface,
962
984
  costControl,
985
+ financialControl,
963
986
  workflowControl,
964
987
  workflowContract,
965
988
  actionProfile,
@@ -1000,10 +1023,10 @@ function buildRemediations({
1000
1023
  }
1001
1024
  if (actionProfile && actionProfile.economicAction) {
1002
1025
  push(
1003
- 'economic_action_approval',
1004
- 'Require operator approval for money movement',
1005
- 'Require an explicit operator checkpoint before refunds, payouts, invoice sends, or subscription changes execute.',
1006
- 'Money-touching actions are costly to reverse and need a clear human owner.'
1026
+ 'financial_requisition_lifecycle',
1027
+ 'Complete the purchase-control lifecycle',
1028
+ 'Create a purchase requisition, obtain independent human approval through the reviewer API, reserve its exact budget, and attach the matching source-message scope before retrying.',
1029
+ 'Money-touching actions require a single-use, auditable authorization instead of an advisory checkpoint.'
1007
1030
  );
1008
1031
  }
1009
1032
  if (actionProfile && actionProfile.customerSystemAction) {
@@ -1064,6 +1087,14 @@ function buildRemediations({
1064
1087
  'High token or cost estimates should be reviewed before the model/tool loop continues.'
1065
1088
  );
1066
1089
  }
1090
+ if (financialControl?.mode === 'block' && financialControl.reasonCodes.includes('zero_spend_budget')) {
1091
+ push(
1092
+ 'honor_zero_spend_budget',
1093
+ 'Honor the zero-spend budget',
1094
+ 'Use a no-cost path. Do not add a card, start a paid trial, buy credits, or upgrade a plan.',
1095
+ 'An explicit $0 budget is a hard prohibition, not an omitted configuration value.'
1096
+ );
1097
+ }
1067
1098
  if (workflowContract?.active && workflowContract.violations.length > 0) {
1068
1099
  const codes = new Set(workflowContract.violations.map((violation) => violation.code));
1069
1100
  if (codes.has('missing_required_evidence')) {
@@ -1145,6 +1176,13 @@ function buildReasoning(report) {
1145
1176
  if (report.costControl && report.costControl.mode !== 'allow') {
1146
1177
  lines.push(`Cost control: ${report.costControl.mode} — ${report.costControl.reasons.join(' ')}`);
1147
1178
  }
1179
+ if (report.financialControl?.economicAction) {
1180
+ lines.push(
1181
+ report.financialControl.mode === 'allow'
1182
+ ? `Financial control: approved reservation ${report.financialControl.authorization?.reservationId || 'unknown'}.`
1183
+ : `Financial control: block — ${report.financialControl.reasons.join(' ')}`
1184
+ );
1185
+ }
1148
1186
  if (report.workflowControl && report.workflowControl.workflow.pattern !== 'single_action') {
1149
1187
  lines.push(
1150
1188
  `Workflow control: ${report.workflowControl.mode} for ${report.workflowControl.workflow.pattern} with inspection ${report.workflowControl.workflow.hasInspectionEvidence ? 'present' : 'missing'}.`
@@ -1255,6 +1293,7 @@ function buildDecisionControl({
1255
1293
  integrity,
1256
1294
  protectedSurface,
1257
1295
  costControl,
1296
+ financialControl,
1258
1297
  workflowControl,
1259
1298
  workflowContract,
1260
1299
  actionProfile,
@@ -1269,6 +1308,7 @@ function buildDecisionControl({
1269
1308
  const hasOperationalBlockers = Boolean(integrity?.blockers?.length);
1270
1309
  const hasCostWarning = costControl?.mode === 'warn';
1271
1310
  const hasCostBlock = costControl?.mode === 'block';
1311
+ const hasFinancialBlock = financialControl?.mode === 'block';
1272
1312
  const hasWorkflowWarning = workflowControl?.mode === 'warn';
1273
1313
  const hasWorkflowBlock = workflowControl?.mode === 'block';
1274
1314
  const hasContractWarning = workflowContract?.mode === 'warn';
@@ -1277,6 +1317,7 @@ function buildDecisionControl({
1277
1317
  || (decision === 'allow' && (reversibility !== 'two_way_door' || hasOperationalBlockers || hasCostWarning || hasWorkflowWarning || hasContractWarning));
1278
1318
  const executionMode = decision === 'deny'
1279
1319
  || hasCostBlock
1320
+ || hasFinancialBlock
1280
1321
  || hasWorkflowBlock
1281
1322
  || hasContractBlock
1282
1323
  ? 'blocked'
@@ -1302,7 +1343,7 @@ function buildDecisionControl({
1302
1343
  decisionOwner,
1303
1344
  reversibility,
1304
1345
  deliberation,
1305
- requiresHumanApproval: (executionMode === 'checkpoint_required' && decisionOwner !== 'agent') || hasCostBlock || hasWorkflowBlock || hasContractBlock,
1346
+ requiresHumanApproval: (executionMode === 'checkpoint_required' && decisionOwner !== 'agent') || hasCostBlock || hasFinancialBlock || hasWorkflowBlock || hasContractBlock,
1306
1347
  recommendedAction: executionMode === 'blocked'
1307
1348
  ? 'halt'
1308
1349
  : executionMode === 'checkpoint_required'
@@ -1379,8 +1420,8 @@ function hasSoftControlWarning({ workflowContract, workflowControl, costControl,
1379
1420
  || (learnedRecall && riskScore >= 0.34);
1380
1421
  }
1381
1422
 
1382
- function chooseDecision({ riskScore, integrity, memoryGuard, learnedPolicy, blastRadius, command, costControl, workflowControl, workflowContract, actionProfile }) {
1383
- if (costControl?.mode === 'block' || workflowControl?.mode === 'block' || workflowContract?.mode === 'block') {
1423
+ function chooseDecision({ riskScore, integrity, memoryGuard, learnedPolicy, blastRadius, command, costControl, financialControl, workflowControl, workflowContract, actionProfile }) {
1424
+ if (financialControl?.mode === 'block' || costControl?.mode === 'block' || workflowControl?.mode === 'block' || workflowContract?.mode === 'block') {
1384
1425
  return 'deny';
1385
1426
  }
1386
1427
 
@@ -1406,7 +1447,7 @@ function chooseDecision({ riskScore, integrity, memoryGuard, learnedPolicy, blas
1406
1447
  return 'deny';
1407
1448
  }
1408
1449
 
1409
- if (actionProfile?.economicAction || (actionProfile?.backgroundAgent && riskScore >= 0.3)) {
1450
+ if ((actionProfile?.economicAction && financialControl?.mode !== 'allow') || (actionProfile?.backgroundAgent && riskScore >= 0.3)) {
1410
1451
  return 'warn';
1411
1452
  }
1412
1453
 
@@ -1448,7 +1489,26 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
1448
1489
  const affectedFiles = Array.isArray(options.affectedFiles)
1449
1490
  ? options.affectedFiles.map((filePath) => normalizePosix(filePath)).filter(Boolean)
1450
1491
  : collectAffectedFiles(normalizedToolName, normalizedToolInput, repoRoot);
1451
- const actionProfile = classifyActionProfile(normalizedToolInput);
1492
+ let actionProfile = classifyActionProfile(normalizedToolInput);
1493
+ const financialControl = evaluateFinancialControl({
1494
+ toolName: normalizedToolName,
1495
+ toolInput: normalizedToolInput,
1496
+ actionProfile,
1497
+ costControl,
1498
+ budget: options.budget || toolInput.budget || {},
1499
+ financialControl: options.financialControl || toolInput.financialControl,
1500
+ }, getFinancialControlRuntimeOptions({
1501
+ feedbackDir: options.feedbackDir
1502
+ || process.env.THUMBGATE_FEEDBACK_DIR
1503
+ || (repoRoot ? path.join(repoRoot, '.thumbgate') : null),
1504
+ }));
1505
+ // The financial detector has additional fail-closed classifiers for opaque
1506
+ // browser/computer mutations. Reflect its verdict in the shared action
1507
+ // profile so downstream learning, risk scoring, and reporting cannot treat
1508
+ // a blocked financial action as non-economic.
1509
+ if (financialControl.economicAction && !actionProfile.economicAction) {
1510
+ actionProfile = { ...actionProfile, economicAction: true };
1511
+ }
1452
1512
  const highRiskAction = isHighRiskAction(normalizedToolName, normalizedToolInput, affectedFiles);
1453
1513
  const baseBranch = options.baseBranch
1454
1514
  || (governanceState.branchGovernance && governanceState.branchGovernance.baseBranch)
@@ -1540,6 +1600,7 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
1540
1600
  taskScopeViolation,
1541
1601
  protectedSurface: protectedSurfaceForRisk,
1542
1602
  costControl,
1603
+ financialControl,
1543
1604
  workflowControl,
1544
1605
  workflowContract,
1545
1606
  actionProfile,
@@ -1567,6 +1628,7 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
1567
1628
  },
1568
1629
  command: normalizedToolInput.command || '',
1569
1630
  costControl,
1631
+ financialControl,
1570
1632
  workflowControl,
1571
1633
  workflowContract,
1572
1634
  actionProfile,
@@ -1580,6 +1642,7 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
1580
1642
  protectedSurface: protectedSurfaceForRisk,
1581
1643
  normalizedAction,
1582
1644
  costControl,
1645
+ financialControl,
1583
1646
  workflowControl,
1584
1647
  workflowContract,
1585
1648
  actionProfile,
@@ -1593,6 +1656,7 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
1593
1656
  learnedPolicy,
1594
1657
  executionSurface,
1595
1658
  costControl,
1659
+ financialControl,
1596
1660
  workflowControl,
1597
1661
  workflowContract,
1598
1662
  actionProfile,
@@ -1607,6 +1671,7 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
1607
1671
  toolName: normalizedToolName,
1608
1672
  normalizedAction,
1609
1673
  costControl,
1674
+ financialControl,
1610
1675
  workflowControl,
1611
1676
  workflowContract,
1612
1677
  decision,
@@ -1643,6 +1708,7 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
1643
1708
  integrity,
1644
1709
  protectedSurface: protectedSurfaceForRisk,
1645
1710
  costControl,
1711
+ financialControl,
1646
1712
  workflowControl,
1647
1713
  workflowContract,
1648
1714
  actionProfile,