thumbgate 1.29.2 → 1.31.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 (111) 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 +143 -14
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +154 -36
  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/default.json +217 -50
  15. package/config/mcp-allowlists.json +215 -185
  16. package/config/model-tiers.json +7 -2
  17. package/config/post-deploy-marketing-pages.json +26 -1
  18. package/glama.json +6 -0
  19. package/package.json +94 -11
  20. package/public/architecture.html +130 -0
  21. package/public/assets/diagrams/agent-integration.png +0 -0
  22. package/public/assets/diagrams/before-after.svg +22 -0
  23. package/public/assets/diagrams/decision.svg +36 -0
  24. package/public/assets/diagrams/feedback-pipeline.png +0 -0
  25. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  26. package/public/assets/diagrams/loop.svg +40 -0
  27. package/public/assets/diagrams/plugin-topology.png +0 -0
  28. package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
  29. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  30. package/public/assets/diagrams/stack.svg +18 -0
  31. package/public/assets/diagrams/thumbgate-architecture.png +0 -0
  32. package/public/case-studies.html +151 -0
  33. package/public/compare.html +1 -0
  34. package/public/dashboard.html +126 -28
  35. package/public/eval-scorecard.html +195 -0
  36. package/public/eval-scorecard.json +18 -0
  37. package/public/evaluations.html +168 -0
  38. package/public/index.html +143 -13
  39. package/public/numbers.html +3 -2
  40. package/public/pricing.html +143 -30
  41. package/public/whitepaper.html +189 -0
  42. package/scripts/a-plus-evidence-scorecard.js +303 -0
  43. package/scripts/activation-quickstart.js +1 -0
  44. package/scripts/agent-outcome-monitor.js +71 -1
  45. package/scripts/async-eval-observability.js +36 -11
  46. package/scripts/audit-trail.js +37 -1
  47. package/scripts/auto-promote-gates.js +149 -34
  48. package/scripts/billing.js +3 -1
  49. package/scripts/claude-feedback-sync.js +3 -2
  50. package/scripts/cli-feedback.js +13 -7
  51. package/scripts/colbert-style-maxsim.js +236 -0
  52. package/scripts/cross-encoder-reranker.js +359 -126
  53. package/scripts/dashboard-chat.js +350 -17
  54. package/scripts/document-intake.js +283 -7
  55. package/scripts/eval-quality-suite.js +204 -0
  56. package/scripts/feedback-aggregate.js +5 -2
  57. package/scripts/feedback-loop.js +359 -189
  58. package/scripts/feedback-paths.js +32 -13
  59. package/scripts/feedback-quality.js +53 -0
  60. package/scripts/filesystem-search.js +17 -7
  61. package/scripts/gates-engine.js +98 -4
  62. package/scripts/generate-case-study-outreach.js +253 -0
  63. package/scripts/generate-eval-scorecard.js +276 -0
  64. package/scripts/growth-campaigns.js +183 -0
  65. package/scripts/harness-tool-names.js +70 -0
  66. package/scripts/hook-runtime.js +10 -3
  67. package/scripts/jsonl-watcher.js +1 -0
  68. package/scripts/lesson-db.js +16 -5
  69. package/scripts/lesson-embedding-index.js +67 -20
  70. package/scripts/lesson-embedding-maintenance.js +177 -0
  71. package/scripts/lesson-inference.js +23 -4
  72. package/scripts/lesson-reranker.js +55 -9
  73. package/scripts/lesson-retrieval.js +375 -32
  74. package/scripts/lesson-search.js +48 -11
  75. package/scripts/llm-client.js +304 -15
  76. package/scripts/mcp-config.js +26 -5
  77. package/scripts/mcp-oauth.js +37 -2
  78. package/scripts/model-eval.js +308 -0
  79. package/scripts/model-tier-router.js +593 -0
  80. package/scripts/parallel-workflow-orchestrator.js +86 -22
  81. package/scripts/pragmatic-hybrid-search.js +379 -0
  82. package/scripts/published-cli.js +11 -1
  83. package/scripts/rag-document-pipeline.js +461 -0
  84. package/scripts/rag-structured-output.js +441 -0
  85. package/scripts/ragas-style-metrics.js +351 -0
  86. package/scripts/refresh-proof-pack.js +261 -0
  87. package/scripts/request-envelope.js +178 -0
  88. package/scripts/rerank-pipeline.js +370 -0
  89. package/scripts/rerank-quality-eval.js +155 -0
  90. package/scripts/retrieval-hybrid-ablation.js +120 -0
  91. package/scripts/retrieval-quality-tier.js +118 -0
  92. package/scripts/risk-scorer.js +144 -15
  93. package/scripts/secret-scanner.js +395 -4
  94. package/scripts/self-distill-agent.js +7 -1
  95. package/scripts/self-healing-check.js +25 -0
  96. package/scripts/skill-packs.js +183 -0
  97. package/scripts/slow-loop.js +72 -0
  98. package/scripts/statusline-links.js +1 -1
  99. package/scripts/statusline-local-stats.js +1 -1
  100. package/scripts/statusline.sh +8 -1
  101. package/scripts/telemetry-analytics.js +13 -1
  102. package/scripts/thumbgate-bench.js +13 -0
  103. package/scripts/thumbgate-search.js +98 -6
  104. package/scripts/tier-budget-guard.js +186 -0
  105. package/scripts/tool-kpi-tracker.js +124 -0
  106. package/scripts/tool-registry.js +95 -1
  107. package/scripts/vector-store.js +108 -4
  108. package/scripts/verify-marketing-pages-deployed.js +85 -3
  109. package/server.json +44 -0
  110. package/smithery.yaml +17 -0
  111. package/src/api/server.js +424 -99
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Fail-closed A+ readiness scorecard.
6
+ *
7
+ * A passing unit test or a checked-in module is repository evidence, not live
8
+ * production or commercial proof. This scorecard keeps those surfaces separate
9
+ * and awards A+/10 only when every check in every area is verified.
10
+ */
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+
15
+ const ROOT = path.join(__dirname, '..');
16
+ const SCORECARD_VERSION = '2026-08-01.1';
17
+
18
+ function read(root, relativePath) {
19
+ try {
20
+ return fs.readFileSync(path.join(root, relativePath), 'utf8');
21
+ } catch {
22
+ return '';
23
+ }
24
+ }
25
+
26
+ function hasAll(text, needles) {
27
+ return needles.every((needle) => text.includes(needle));
28
+ }
29
+
30
+ function safeEval(fn) {
31
+ try {
32
+ return fn() === true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ function collectRepositoryEvidence(root = ROOT) {
39
+ const landing = read(root, 'public/index.html');
40
+ const feedback = read(root, 'scripts/feedback-loop.js');
41
+ const promotion = read(root, 'scripts/auto-promote-gates.js');
42
+ const buyerPaths = read(root, 'scripts/buyer-paths.js');
43
+ const hook = read(root, 'scripts/hook-pre-tool-use.js');
44
+ const gatesEngine = read(root, 'scripts/gates-engine.js');
45
+ const gateEvasionMatrix = read(root, 'tests/gate-evasion-matrix.test.js');
46
+ const retrieval = read(root, 'scripts/lesson-retrieval.js');
47
+ const crossEncoder = read(root, 'scripts/cross-encoder-reranker.js');
48
+ const productionArchitecture = read(root, 'docs/RAG_PRODUCTION_ARCHITECTURE.md');
49
+ const packageJson = read(root, 'package.json');
50
+
51
+ let qualitySuitePassed = false;
52
+ let rerankGoldenPassed = false;
53
+ if (root === ROOT) {
54
+ qualitySuitePassed = safeEval(() => require('./eval-quality-suite').runSuite().report.passed);
55
+ rerankGoldenPassed = safeEval(() => require('./rerank-quality-eval').evaluate().pass);
56
+ }
57
+
58
+ return {
59
+ landingVisualLoop: hasAll(landing, [
60
+ 'hero-thumbs',
61
+ 'before-after.svg',
62
+ 'self-improving-thumbs-loop.svg',
63
+ 'Is it really self-improving?',
64
+ ]),
65
+ landingBuyerRoutes: hasAll(landing, ['/checkout/pro', '/go/diagnostic-pay'])
66
+ && hasAll(buyerPaths, ['/go/pro', '/go/sprint', '/diagnostic']),
67
+ feedbackRewardReachable: feedback.includes('scoreFeedbackReward('),
68
+ feedbackPromotionReachable: promotion.includes('promote') && hook.includes('retrieveWithRerankingSync'),
69
+ preventionChangePromoted: feedback.includes('whatToChange')
70
+ && feedback.includes('promotion'),
71
+ architectureNamesHonest: hasAll(productionArchitecture, [
72
+ 'LLM-as-a-judge output is diagnostic',
73
+ 'A heuristic score is never',
74
+ 'does not get to override a hard gate',
75
+ ]),
76
+ deterministicMultiQuery: retrieval.includes('buildQueryVariants'),
77
+ hydeExplicitAndBounded: hasAll(retrieval, ['hydeGenerator', 'hydeApplied', 'hydeProvider']),
78
+ rerankProductionWired: crossEncoder.includes("require('./rerank-pipeline')")
79
+ && crossEncoder.includes('rerankPipelineSync(query, candidates'),
80
+ rerankProvenance: hasAll(crossEncoder, [
81
+ 'pairwiseHeuristicScore',
82
+ 'crossEncoderScore',
83
+ 'reranker',
84
+ ]),
85
+ rerankGoldenPassed,
86
+ qualitySuitePassed,
87
+ requestEnvelope: fs.existsSync(path.join(root, 'scripts/request-envelope.js')),
88
+ hardBudgets: fs.existsSync(path.join(root, 'scripts/tier-budget-guard.js')),
89
+ degradedRetrievalFlags: fs.existsSync(path.join(root, 'scripts/retrieval-quality-tier.js')),
90
+ structuredOutputValidation: fs.existsSync(path.join(root, 'scripts/rag-structured-output.js')),
91
+ tenantAclBeforeRetrieval: hasAll(productionArchitecture, [
92
+ 'Filtering must happen before',
93
+ 'Missing/mismatched tenant or principal',
94
+ ]),
95
+ commandPositionHardening: hasAll(gatesEngine, [
96
+ 'LITERAL_COMMAND_SUBSTITUTION_HEADS',
97
+ 'canonicalizeLiteralCommandSubstitutionHead',
98
+ ]) && gateEvasionMatrix.includes('literal command substitution'),
99
+ rawFrameworkDecisionDefended: hasAll(productionArchitecture, [
100
+ '## Framework decision',
101
+ 'LangChain',
102
+ 'LangGraph',
103
+ 'LlamaIndex',
104
+ '## One complete RAG request',
105
+ ]),
106
+ scorecardInMainTest: packageJson.includes('test:a-plus-evidence'),
107
+ };
108
+ }
109
+
110
+ function check(id, label, passed, evidenceClass, remediation) {
111
+ return {
112
+ id,
113
+ label,
114
+ passed: passed === true,
115
+ evidenceClass,
116
+ remediation: passed === true ? null : remediation,
117
+ };
118
+ }
119
+
120
+ function shaMatches(live = {}) {
121
+ const candidate = String(live.candidateBuildSha || '').trim();
122
+ const deployed = String(live.deployedBuildSha || '').trim();
123
+ return candidate.length >= 7 && deployed.length >= 7 && candidate === deployed;
124
+ }
125
+
126
+ function evaluateReadiness({ repo = {}, live = {} } = {}) {
127
+ const production = live.production || {};
128
+ const retrieval = live.retrieval || {};
129
+ const security = live.security || {};
130
+ const commercial = live.commercial || {};
131
+
132
+ const areas = [
133
+ {
134
+ id: 'landing_conversion',
135
+ label: 'Landing page and conversion',
136
+ checks: [
137
+ check('visual_loop', 'Thumb visuals and simple learning diagrams ship', repo.landingVisualLoop, 'repository', 'Ship the visual thumbs-to-gate loop.'),
138
+ check('buyer_routes', 'First-party buyer routes are present', repo.landingBuyerRoutes, 'repository', 'Restore diagnostic, Pro, and sprint buyer routes.'),
139
+ check('live_landing', 'Candidate landing page is verified live', production.landingVerified === true && shaMatches(production), 'production', 'Verify the exact candidate SHA on the live landing page.'),
140
+ ],
141
+ },
142
+ {
143
+ id: 'self_improvement',
144
+ label: 'Self-improving control loop',
145
+ checks: [
146
+ check('reward_reachable', 'Feedback reward scoring is invoked by capture', repo.feedbackRewardReachable, 'repository', 'Wire reward scoring into the capture path.'),
147
+ check('promotion_reachable', 'Reviewed failures reach promotion and pre-action retrieval', repo.feedbackPromotionReachable, 'repository', 'Connect feedback promotion to the pre-action hook.'),
148
+ check('specific_change', 'Specific what-to-change guidance reaches prevention rules', repo.preventionChangePromoted, 'repository', 'Promote specific corrective instructions, not vague signals.'),
149
+ check('live_feedback', 'Fresh production feedback closes the loop', production.feedbackLoopVerified === true, 'production', 'Capture one real reviewed outcome and prove its next-action effect.'),
150
+ ],
151
+ },
152
+ {
153
+ id: 'architecture_honesty',
154
+ label: 'Judge, routing, and architecture honesty',
155
+ checks: [
156
+ check('honest_names', 'Judge, heuristic, neural, and enforcement stages are distinct', repo.architectureNamesHonest, 'repository', 'Document stage placement and prevent misleading model-level MoE claims.'),
157
+ check('route_trace', 'Live traces identify the provider and routed model', production.providerTraceVerified === true, 'production', 'Attach a secret-safe live route trace.'),
158
+ ],
159
+ },
160
+ {
161
+ id: 'query_transformation',
162
+ label: 'Query transformation, multi-query, and HyDE',
163
+ checks: [
164
+ check('multi_query', 'Bounded deterministic multi-query is implemented', repo.deterministicMultiQuery, 'repository', 'Implement bounded, inspectable query variants.'),
165
+ check('hyde_contract', 'HyDE is explicit, bounded, and provenance-bearing', repo.hydeExplicitAndBounded, 'repository', 'Add an explicit caller-supplied HyDE contract and fallback.'),
166
+ check('hyde_holdout', 'HyDE or multi-query improves a provider holdout', retrieval.queryTransformationHoldoutPassed === true, 'provider-holdout', 'Measure lift on a non-fixture provider holdout.'),
167
+ ],
168
+ },
169
+ {
170
+ id: 'reranking',
171
+ label: 'Reranking cascade',
172
+ checks: [
173
+ check('production_wiring', 'BM25F, local MaxSim, and pairwise fusion run in PreToolUse', repo.rerankProductionWired, 'repository', 'Wire the documented cascade into the production caller.'),
174
+ check('provenance', 'Heuristic and neural scores cannot masquerade as each other', repo.rerankProvenance, 'repository', 'Emit per-stage provenance and explicit fallbacks.'),
175
+ check('golden', 'Deterministic rerank golden floors pass', repo.rerankGoldenPassed, 'deterministic-eval', 'Fix rerank golden regressions.'),
176
+ check('neural_holdout', 'True neural pair/late-interaction holdout passes', retrieval.neuralRerankHoldoutPassed === true, 'provider-holdout', 'Run a pretrained pair scorer or token embedder on an external holdout.'),
177
+ check('llm_failures', 'LLM rerank failure modes pass live-provider tests', retrieval.llmRerankFailureModesPassed === true, 'provider-holdout', 'Test malformed, partial, injected, timed-out, and unavailable LLM reranks.'),
178
+ ],
179
+ },
180
+ {
181
+ id: 'evaluation',
182
+ label: 'Retrieval and answer evaluation',
183
+ checks: [
184
+ check('offline_suite', 'Recall, precision, MRR, nDCG, and answer proxy floors pass', repo.qualitySuitePassed, 'deterministic-eval', 'Fix the unified deterministic quality suite.'),
185
+ check('external_cases', 'External labeled holdout has at least 100 cases', Number(retrieval.externalHoldoutCases) >= 100, 'provider-holdout', 'Label and freeze at least 100 non-fixture cases.'),
186
+ check('judge_calibration', 'LLM judge is calibrated against human labels', retrieval.judgeCalibrationPassed === true, 'provider-holdout', 'Measure judge agreement and calibration against human-reviewed labels.'),
187
+ ],
188
+ },
189
+ {
190
+ id: 'production_controls',
191
+ label: 'Latency, cost, and observability',
192
+ checks: [
193
+ check('request_envelope', 'Request trace, token, cost, and retrieval envelope exists', repo.requestEnvelope, 'repository', 'Add a request envelope.'),
194
+ check('hard_budgets', 'Per-request and daily tier budgets fail closed', repo.hardBudgets, 'repository', 'Add hard cost and tier budgets.'),
195
+ check('degraded_flags', 'Stale or stub retrieval is labeled degraded', repo.degradedRetrievalFlags, 'repository', 'Expose retrieval quality tiers.'),
196
+ check('live_slo', 'Production p95 and cost SLOs pass under load', production.loadTestPassed === true && Number(production.p95LatencyMs) > 0, 'production', 'Run a production-like load test and attach p95/cost evidence.'),
197
+ check('cache_batch', 'Live cache and batching savings are measured', production.cacheAndBatchingMeasured === true, 'production', 'Measure cache hit rate and batching cost/latency lift.'),
198
+ ],
199
+ },
200
+ {
201
+ id: 'failure_security',
202
+ label: 'Failure modes, validation, ACL, and tenancy',
203
+ checks: [
204
+ check('structured', 'Structured answers and citations are validated', repo.structuredOutputValidation, 'repository', 'Validate output shape and citation relationships.'),
205
+ check('acl_order', 'Tenant/document ACL runs before retrieval and hydration', repo.tenantAclBeforeRetrieval, 'repository', 'Enforce authorization before ranking.'),
206
+ check('command_evasion', 'Literal command-substitution evasions are canonicalized and tested', repo.commandPositionHardening, 'repository', 'Ratchet deterministic command-position substitutions in the evasion matrix.'),
207
+ check('penetration_test', 'Tenant isolation has external penetration evidence', security.tenantPenTestPassed === true, 'security-review', 'Run a professional tenant-isolation penetration test.'),
208
+ check('incident_drill', 'Hallucination, stale-index, miss, and leak drills pass', security.failureDrillPassed === true, 'production', 'Run and retain production-like failure drills.'),
209
+ ],
210
+ },
211
+ {
212
+ id: 'framework_pipeline',
213
+ label: 'Framework decision and end-to-end RAG defense',
214
+ checks: [
215
+ check('decision', 'Raw versus LangChain/LangGraph/LlamaIndex tradeoffs are defended', repo.rawFrameworkDecisionDefended, 'repository', 'Document the complete pipeline and framework decision.'),
216
+ check('ratchet', 'The evidence scorecard runs in the main test chain', repo.scorecardInMainTest, 'repository', 'Wire this scorecard into the test chain.'),
217
+ ],
218
+ },
219
+ {
220
+ id: 'commercial_validation',
221
+ label: 'Value, willingness to pay, and captured money',
222
+ checks: [
223
+ check('buyer_conversations', 'At least 10 target-buyer value conversations are evidenced', Number(commercial.buyerConversations) >= 10, 'commercial', 'Complete and retain 10 target-buyer value conversations.'),
224
+ check('payment_asks', 'At least 3 exact-price payment asks are evidenced', Number(commercial.paymentAsks) >= 3, 'commercial', 'Make three exact-price payment asks to qualified buyers.'),
225
+ check('external_payment', 'At least one non-owner external payment is reconciled', Number(commercial.externalPayments) >= 1, 'provider', 'Capture and reconcile one real external payment.'),
226
+ check('provider_truth', 'Provider catalog and product attribution are verified', commercial.providerRevenueVerified === true, 'provider', 'Attach exact provider catalog and product-attributed revenue evidence.'),
227
+ ],
228
+ },
229
+ ];
230
+
231
+ for (const area of areas) {
232
+ const passed = area.checks.filter((row) => row.passed).length;
233
+ area.score = Number((10 * passed / area.checks.length).toFixed(1));
234
+ area.grade = area.score === 10 ? 'A+' : area.score >= 9 ? 'A' : area.score >= 8 ? 'B' : area.score >= 7 ? 'C' : area.score >= 6 ? 'D' : 'F';
235
+ area.status = area.score === 10 ? 'verified' : passed === 0 ? 'blocked' : 'partial';
236
+ }
237
+
238
+ const score = Number((areas.reduce((sum, area) => sum + area.score, 0) / areas.length).toFixed(1));
239
+ const atTarget = areas.every((area) => area.score === 10);
240
+ return {
241
+ scorecardVersion: SCORECARD_VERSION,
242
+ generatedAt: new Date().toISOString(),
243
+ target: { score: 10, grade: 'A+' },
244
+ atTarget,
245
+ score,
246
+ grade: atTarget ? 'A+' : score >= 9 ? 'A' : score >= 8 ? 'B' : score >= 7 ? 'C' : score >= 6 ? 'D' : 'F',
247
+ areas,
248
+ blockers: areas.flatMap((area) => area.checks
249
+ .filter((row) => !row.passed)
250
+ .map((row) => ({ area: area.id, check: row.id, evidenceClass: row.evidenceClass, remediation: row.remediation }))),
251
+ };
252
+ }
253
+
254
+ function formatMarkdown(report) {
255
+ const lines = [
256
+ '# ThumbGate A+ evidence scorecard',
257
+ '',
258
+ `Overall: **${report.score}/10 (${report.grade})**`,
259
+ `Target verified: **${report.atTarget ? 'YES' : 'NO'}**`,
260
+ '',
261
+ '| Area | Score | Grade | Status |',
262
+ '|---|---:|:---:|---|',
263
+ ...report.areas.map((area) => `| ${area.label} | ${area.score}/10 | ${area.grade} | ${area.status} |`),
264
+ '',
265
+ '## Remaining evidence blockers',
266
+ '',
267
+ ...(report.blockers.length
268
+ ? report.blockers.map((row) => `- **${row.area}/${row.check}** (${row.evidenceClass}): ${row.remediation}`)
269
+ : ['- None. Every repository, production, provider, security, and commercial check is verified.']),
270
+ '',
271
+ ];
272
+ return lines.join('\n');
273
+ }
274
+
275
+ function loadLiveEvidence(argv) {
276
+ const index = argv.indexOf('--evidence');
277
+ if (index === -1 || !argv[index + 1]) return {};
278
+ return JSON.parse(fs.readFileSync(path.resolve(argv[index + 1]), 'utf8'));
279
+ }
280
+
281
+ function main() {
282
+ const live = loadLiveEvidence(process.argv.slice(2));
283
+ const repo = collectRepositoryEvidence();
284
+ const report = evaluateReadiness({ repo, live });
285
+ process.stdout.write(process.argv.includes('--json')
286
+ ? `${JSON.stringify(report, null, 2)}\n`
287
+ : `${formatMarkdown(report)}\n`);
288
+ if (process.argv.includes('--require-a-plus') && !report.atTarget) process.exitCode = 1;
289
+ }
290
+
291
+ function isCliEntrypoint(argv = process.argv) {
292
+ return Boolean(argv[1]) && path.resolve(argv[1]) === path.resolve(__filename);
293
+ }
294
+
295
+ if (isCliEntrypoint()) main();
296
+
297
+ module.exports = {
298
+ SCORECARD_VERSION,
299
+ collectRepositoryEvidence,
300
+ evaluateReadiness,
301
+ formatMarkdown,
302
+ isCliEntrypoint,
303
+ };
@@ -93,6 +93,7 @@ async function runActivationFlow({ ask, out, isTTY, deps = {} }) {
93
93
  whatToChange: `Block this action: ${mistake}`,
94
94
  tags: 'quickstart,activation,first-rule',
95
95
  gateAction: 'block',
96
+ reviewOrigin: 'human',
96
97
  });
97
98
  } catch {
98
99
  // Capture failure should not abort the activation aha.
@@ -5,11 +5,13 @@ const fs = require('node:fs');
5
5
  const os = require('node:os');
6
6
  const path = require('node:path');
7
7
  const { calculateTaskOutcomeMetrics, readTaskOutcomes } = require('./task-outcomes');
8
+ const { computeToolKpis } = require('./tool-kpi-tracker');
8
9
 
9
10
  const DEFAULT_THRESHOLDS = path.join(__dirname, '..', 'config', 'agent-outcome-monitor-thresholds.json');
10
11
  const DEFAULT_HOSTED_ORIGIN = 'https://thumbgate-production.up.railway.app';
11
12
  const DEFAULT_MONITOR_PATH = '/v1/task-outcomes/monitor';
12
13
  const DEFAULT_SCHEDULE_ID = 'thumbgate-agent-outcome-monitor';
14
+ const DEFAULT_MINIMUM_TOOL_CALLS = 20;
13
15
 
14
16
  function monitorTaskOutcomes(outcomes = [], options = {}) {
15
17
  const metrics = calculateTaskOutcomeMetrics(outcomes);
@@ -72,6 +74,71 @@ function monitorTaskOutcomes(outcomes = [], options = {}) {
72
74
  };
73
75
  }
74
76
 
77
+ function monitorProductionSignals(outcomes = [], toolKpis = {}, options = {}) {
78
+ const taskReport = monitorTaskOutcomes(outcomes, options);
79
+ const requestedMinimumToolCalls = Number(options.minimumToolCalls);
80
+ const minimumToolCalls = Number.isFinite(requestedMinimumToolCalls) && requestedMinimumToolCalls >= 1
81
+ ? Math.floor(requestedMinimumToolCalls)
82
+ : DEFAULT_MINIMUM_TOOL_CALLS;
83
+ const measuredToolCalls = Number(toolKpis.totalCalls);
84
+ const totalToolCalls = Number.isFinite(measuredToolCalls) && measuredToolCalls >= 0
85
+ ? Math.floor(measuredToolCalls)
86
+ : 0;
87
+ const toolAlerts = [];
88
+
89
+ if (totalToolCalls < minimumToolCalls) {
90
+ toolAlerts.push({
91
+ id: 'minimum-tool-calls',
92
+ severity: 'block',
93
+ message: `Need ${minimumToolCalls} observed tool calls; observed ${totalToolCalls}.`,
94
+ });
95
+ }
96
+ for (const tool of (toolKpis.tools || [])) {
97
+ if (tool.requestCount < 3) continue;
98
+ if (tool.successRate < 90) {
99
+ toolAlerts.push({
100
+ id: `tool-success-${tool.toolName}`,
101
+ severity: 'block',
102
+ actual: tool.successRate,
103
+ expected: 'gte 90',
104
+ message: `${tool.toolName} success rate ${tool.successRate}% is below 90%.`,
105
+ });
106
+ }
107
+ if (tool.p95 > 500) {
108
+ toolAlerts.push({
109
+ id: `tool-latency-${tool.toolName}`,
110
+ severity: 'warn',
111
+ actual: tool.p95,
112
+ expected: 'lte 500',
113
+ message: `${tool.toolName} p95 latency ${tool.p95}ms is above 500ms.`,
114
+ });
115
+ }
116
+ }
117
+
118
+ const alerts = [...taskReport.alerts, ...toolAlerts];
119
+ let verdict = taskReport.verdict;
120
+ if (taskReport.verdict !== 'blocked' && toolAlerts.some((alert) => alert.id === 'minimum-tool-calls')) {
121
+ verdict = 'insufficient_evidence';
122
+ } else if (toolAlerts.some((alert) => alert.severity === 'block')) {
123
+ verdict = 'blocked';
124
+ } else if (verdict === 'healthy' && toolAlerts.length > 0) {
125
+ verdict = 'watch';
126
+ }
127
+
128
+ return {
129
+ ...taskReport,
130
+ verdict,
131
+ alerts,
132
+ observability: {
133
+ minimumToolCalls,
134
+ totalToolCalls,
135
+ evidenceStatus: totalToolCalls >= minimumToolCalls ? 'measured' : 'insufficient_evidence',
136
+ tools: toolKpis.tools || [],
137
+ servers: toolKpis.servers || [],
138
+ },
139
+ };
140
+ }
141
+
75
142
  function flattenMetricValues(metrics) {
76
143
  return {
77
144
  workingRate: metrics.task.workingRate,
@@ -228,8 +295,9 @@ async function main(argv = process.argv.slice(2)) {
228
295
 
229
296
  const report = options.hosted
230
297
  ? await fetchHostedMonitor(options)
231
- : monitorTaskOutcomes(
298
+ : monitorProductionSignals(
232
299
  readTaskOutcomes({ inputPath: options.inputPath }),
300
+ computeToolKpis(),
233
301
  { thresholdsPath: options.thresholdsPath },
234
302
  );
235
303
  if (options.outputPath) {
@@ -255,7 +323,9 @@ module.exports = {
255
323
  flattenMetricValues,
256
324
  installAgentOutcomeMonitorSchedule,
257
325
  main,
326
+ monitorProductionSignals,
258
327
  monitorTaskOutcomes,
259
328
  parseArgs,
260
329
  passesRule,
330
+ DEFAULT_MINIMUM_TOOL_CALLS,
261
331
  };
@@ -3,11 +3,19 @@
3
3
 
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
+ const {
7
+ claimSupportScore,
8
+ evaluateAnswerQuality,
9
+ queryCoverage,
10
+ splitAnswerClaims,
11
+ } = require('./rag-structured-output');
6
12
 
7
13
  const DEFAULT_THRESHOLDS = {
8
14
  faithfulness: 0.72,
9
15
  answerRelevance: 0.45,
10
16
  contextPrecision: 0.5,
17
+ groundedness: 0.75,
18
+ citationPrecision: 1,
11
19
  };
12
20
 
13
21
  function tokenize(value) {
@@ -30,10 +38,7 @@ function overlapScore(left, right) {
30
38
  }
31
39
 
32
40
  function splitClaims(response) {
33
- return String(response || '')
34
- .split(/(?:[.!?]\s+|\n+)/)
35
- .map((claim) => claim.trim())
36
- .filter((claim) => claim.length > 0);
41
+ return splitAnswerClaims(response);
37
42
  }
38
43
 
39
44
  function normalizeContexts(contexts) {
@@ -44,11 +49,10 @@ function normalizeContexts(contexts) {
44
49
 
45
50
  function scoreFaithfulness(response, contexts) {
46
51
  const claims = splitClaims(response);
47
- const contextText = normalizeContexts(contexts).join('\n');
52
+ const contextItems = normalizeContexts(contexts);
48
53
  if (claims.length === 0) return { score: 0, supportedClaims: 0, totalClaims: 0 };
49
54
  const supportedClaims = claims.filter((claim) => {
50
- const normalized = claim.toLowerCase();
51
- return contextText.toLowerCase().includes(normalized) || overlapScore(claim, contextText) >= 0.58;
55
+ return contextItems.some((context) => claimSupportScore(claim, context) >= 0.4);
52
56
  }).length;
53
57
  return {
54
58
  score: Number((supportedClaims / claims.length).toFixed(4)),
@@ -58,7 +62,7 @@ function scoreFaithfulness(response, contexts) {
58
62
  }
59
63
 
60
64
  function scoreAnswerRelevance(question, response) {
61
- const score = overlapScore(question, response);
65
+ const score = queryCoverage(question, response);
62
66
  return {
63
67
  score: Number(score.toFixed(4)),
64
68
  matchedQuestionTerms: unique(tokenize(question).filter((token) => tokenize(response).includes(token))),
@@ -97,14 +101,29 @@ function evaluateGeneration(testCase, options = {}) {
97
101
  contexts,
98
102
  testCase.reference || testCase.groundTruth || ''
99
103
  );
104
+ const answerQuality = evaluateAnswerQuality({
105
+ query: testCase.question || testCase.user_input,
106
+ answer: testCase.response || testCase.answer,
107
+ referenceAnswer: testCase.reference || testCase.groundTruth || '',
108
+ citations: testCase.citations,
109
+ contexts: contexts.map((text, index) => ({ id: `context-${index + 1}`, text })),
110
+ }, {
111
+ minFaithfulness: thresholds.faithfulness,
112
+ minGroundedness: thresholds.groundedness,
113
+ minAnswerRelevance: thresholds.answerRelevance,
114
+ });
100
115
  const scores = {
101
116
  faithfulness: faithfulness.score,
102
117
  answerRelevance: answerRelevance.score,
103
118
  contextPrecision: contextPrecision.score,
119
+ groundedness: answerQuality.metrics.groundedness,
120
+ citationPrecision: answerQuality.metrics.citationPrecision,
104
121
  };
105
122
  const passed = scores.faithfulness >= thresholds.faithfulness
106
123
  && scores.answerRelevance >= thresholds.answerRelevance
107
- && scores.contextPrecision >= thresholds.contextPrecision;
124
+ && scores.contextPrecision >= thresholds.contextPrecision
125
+ && scores.groundedness >= thresholds.groundedness
126
+ && scores.citationPrecision >= thresholds.citationPrecision;
108
127
 
109
128
  return {
110
129
  id: String(testCase.id || testCase.traceId || 'case'),
@@ -116,6 +135,7 @@ function evaluateGeneration(testCase, options = {}) {
116
135
  faithfulness,
117
136
  answerRelevance,
118
137
  contextPrecision,
138
+ answerQuality,
119
139
  },
120
140
  };
121
141
  }
@@ -155,6 +175,8 @@ function buildEvalReport(cases, options = {}) {
155
175
  faithfulness: average(results.map((result) => result.scores.faithfulness)),
156
176
  answerRelevance: average(results.map((result) => result.scores.answerRelevance)),
157
177
  contextPrecision: average(results.map((result) => result.scores.contextPrecision)),
178
+ groundedness: average(results.map((result) => result.scores.groundedness)),
179
+ citationPrecision: average(results.map((result) => result.scores.citationPrecision)),
158
180
  };
159
181
 
160
182
  return {
@@ -166,7 +188,7 @@ function buildEvalReport(cases, options = {}) {
166
188
  passRate: results.length === 0 ? 0 : Number(((passed / results.length) * 100).toFixed(2)),
167
189
  aggregate,
168
190
  passedThreshold: failed === 0,
169
- metrics: ['faithfulness', 'answerRelevance', 'contextPrecision'],
191
+ metrics: ['faithfulness', 'answerRelevance', 'contextPrecision', 'groundedness', 'citationPrecision'],
170
192
  sinks: {
171
193
  ci: true,
172
194
  langsmithCompatible: true,
@@ -228,7 +250,10 @@ module.exports = {
228
250
  scoreFaithfulness,
229
251
  };
230
252
 
231
- if (require.main === module) {
253
+ const invokedDirectly = Boolean(process.argv[1])
254
+ && path.resolve(process.argv[1]) === __filename;
255
+
256
+ if (invokedDirectly) {
232
257
  main().catch((err) => {
233
258
  console.error(err.stack || err.message);
234
259
  process.exitCode = 1;
@@ -61,7 +61,18 @@ function recordAuditEvent(params = {}) {
61
61
  source: params.source || 'gates-engine',
62
62
  };
63
63
 
64
- fs.appendFileSync(logPath, JSON.stringify(record) + '\n');
64
+ // Safe stringify: never let circular/toxic tool inputs crash the gate path
65
+ // (Antithesis-style invariant: evaluation + audit must not throw).
66
+ let line;
67
+ try {
68
+ line = JSON.stringify(record);
69
+ } catch (err) {
70
+ line = JSON.stringify({
71
+ ...record,
72
+ toolInput: { _unserializable: true, reason: String(err.message || err).slice(0, 120) },
73
+ });
74
+ }
75
+ fs.appendFileSync(logPath, `${line}\n`);
65
76
  try {
66
77
  const { trainAndPersistInterventionPolicy } = require('./intervention-policy');
67
78
  trainAndPersistInterventionPolicy(path.dirname(logPath));
@@ -73,10 +84,13 @@ function recordAuditEvent(params = {}) {
73
84
 
74
85
  /**
75
86
  * Strip secrets and large payloads from tool input before audit storage.
87
+ * Drop circular references so JSON.stringify never throws mid-gate evaluation.
76
88
  */
77
89
  function sanitizeToolInput(toolInput) {
90
+ if (!toolInput || typeof toolInput !== 'object') return {};
78
91
  const safe = {};
79
92
  const MAX_VALUE_LEN = 200;
93
+ const seen = new WeakSet();
80
94
 
81
95
  for (const [key, value] of Object.entries(toolInput)) {
82
96
  if (typeof value === 'string') {
@@ -88,6 +102,17 @@ function sanitizeToolInput(toolInput) {
88
102
  ? value.slice(0, MAX_VALUE_LEN) + '...'
89
103
  : value;
90
104
  }
105
+ } else if (value && typeof value === 'object') {
106
+ if (seen.has(value)) {
107
+ safe[key] = '[Circular]';
108
+ continue;
109
+ }
110
+ seen.add(value);
111
+ try {
112
+ safe[key] = JSON.parse(JSON.stringify(value, getCircularReplacer()));
113
+ } catch {
114
+ safe[key] = `[unserializable:${typeof value}]`;
115
+ }
91
116
  } else {
92
117
  safe[key] = value;
93
118
  }
@@ -95,6 +120,17 @@ function sanitizeToolInput(toolInput) {
95
120
  return safe;
96
121
  }
97
122
 
123
+ function getCircularReplacer() {
124
+ const seen = new WeakSet();
125
+ return function circularReplacer(_key, value) {
126
+ if (value && typeof value === 'object') {
127
+ if (seen.has(value)) return '[Circular]';
128
+ seen.add(value);
129
+ }
130
+ return value;
131
+ };
132
+ }
133
+
98
134
  // ---------------------------------------------------------------------------
99
135
  // Auto-feedback from audit events
100
136
  // ---------------------------------------------------------------------------