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.
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +54 -16
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +105 -10
- package/adapters/opencode/opencode.json +1 -1
- package/bench/observability-eval-suite.json +2 -2
- package/bin/cli.js +168 -31
- package/config/evals/generation-quality-golden.json +95 -0
- package/config/evals/rag-answer-quality-golden.json +91 -0
- package/config/evals/retrieval-hybrid-ablation.json +66 -0
- package/config/evals/retrieval-ranking-golden.json +522 -0
- package/config/gates/claim-verifiers.example.json +42 -0
- package/config/gates/claim-verifiers.json +25 -0
- package/config/gates/default.json +217 -50
- package/config/mcp-allowlists.json +233 -206
- package/config/model-tiers.json +7 -2
- package/glama.json +6 -0
- package/hooks/hooks.json +1 -1
- package/package.json +69 -12
- package/public/assets/diagrams/before-after.svg +17 -16
- package/public/assets/diagrams/hero-thumbs.svg +68 -0
- package/public/assets/diagrams/loop.svg +19 -13
- package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
- package/public/compare.html +1 -0
- package/public/dashboard.html +126 -28
- package/public/evaluations.html +1 -1
- package/public/index.html +142 -13
- package/public/numbers.html +3 -2
- package/public/pricing.html +143 -30
- package/scripts/a-plus-evidence-scorecard.js +303 -0
- package/scripts/agent-readiness.js +110 -0
- package/scripts/async-eval-observability.js +36 -11
- package/scripts/audit-trail.js +37 -1
- package/scripts/auto-promote-gates.js +149 -34
- package/scripts/auto-wire-hooks.js +20 -8
- package/scripts/cli-schema.js +14 -0
- package/scripts/colbert-style-maxsim.js +236 -0
- package/scripts/cross-encoder-reranker.js +356 -126
- package/scripts/dashboard-chat.js +350 -17
- package/scripts/document-intake.js +283 -7
- package/scripts/eval-quality-suite.js +204 -0
- package/scripts/feedback-loop.js +115 -7
- package/scripts/feedback-paths.js +32 -13
- package/scripts/feedback-quality.js +53 -0
- package/scripts/feedback-schema.js +3 -0
- package/scripts/file-ledger-lock.js +130 -0
- package/scripts/filesystem-search.js +17 -7
- package/scripts/financial-control-plane.js +1514 -0
- package/scripts/gates-engine.js +202 -7
- package/scripts/gemini-embedding-policy.js +1 -0
- package/scripts/harness-tool-names.js +70 -0
- package/scripts/hook-runtime.js +15 -3
- package/scripts/hook-stop-anti-claim.js +63 -3
- package/scripts/human-escalation.js +353 -41
- package/scripts/lesson-db.js +16 -5
- package/scripts/lesson-embedding-index.js +67 -20
- package/scripts/lesson-embedding-maintenance.js +177 -0
- package/scripts/lesson-reranker.js +55 -9
- package/scripts/lesson-retrieval.js +305 -29
- package/scripts/lesson-search.js +22 -8
- package/scripts/llm-client.js +304 -15
- package/scripts/model-tier-router.js +593 -0
- package/scripts/pragmatic-hybrid-search.js +379 -0
- package/scripts/provider-action-normalizer.js +11 -4
- package/scripts/rag-document-pipeline.js +461 -0
- package/scripts/rag-structured-output.js +441 -0
- package/scripts/ragas-style-metrics.js +351 -0
- package/scripts/request-envelope.js +178 -0
- package/scripts/rerank-pipeline.js +370 -0
- package/scripts/rerank-quality-eval.js +155 -0
- package/scripts/retrieval-hybrid-ablation.js +120 -0
- package/scripts/retrieval-quality-tier.js +118 -0
- package/scripts/secret-scanner.js +395 -4
- package/scripts/self-distill-agent.js +7 -1
- package/scripts/self-healing-check.js +25 -0
- package/scripts/skill-packs.js +183 -0
- package/scripts/slow-loop.js +72 -0
- package/scripts/statusline-links.js +1 -1
- package/scripts/statusline.sh +8 -1
- package/scripts/telemetry-analytics.js +13 -1
- package/scripts/thumbgate-search.js +98 -6
- package/scripts/tier-budget-guard.js +186 -0
- package/scripts/tool-registry.js +141 -5
- package/scripts/universal-claim-evaluator.js +767 -0
- package/scripts/vector-store.js +154 -17
- package/scripts/verify-marketing-pages-deployed.js +85 -3
- package/scripts/workflow-sentinel.js +77 -11
- package/server.json +44 -0
- package/smithery.yaml +17 -0
- package/src/api/server.js +196 -13
package/scripts/vector-store.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
398
|
+
const ollamaConfig = getOllamaEmbeddingConfig();
|
|
399
|
+
if (ollamaConfig.enabled) {
|
|
281
400
|
try {
|
|
282
|
-
const vector = await
|
|
401
|
+
const vector = await embedWithOllama(text, options);
|
|
283
402
|
_lastEmbeddingProfile = {
|
|
284
403
|
generatedAt: new Date().toISOString(),
|
|
285
|
-
source: '
|
|
404
|
+
source: 'local-ollama',
|
|
286
405
|
activeProfile: {
|
|
287
|
-
id: '
|
|
288
|
-
model:
|
|
289
|
-
outputDimensionality:
|
|
290
|
-
task: options.task ||
|
|
291
|
-
rationale: '
|
|
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 (
|
|
297
|
-
|
|
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: '
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
||
|
|
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
|
-
'
|
|
1004
|
-
'
|
|
1005
|
-
'
|
|
1006
|
-
'Money-touching actions
|
|
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
|
-
|
|
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,
|