thumbgate 1.28.4 → 1.29.2

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 (87) hide show
  1. package/.claude/commands/dashboard.md +11 -1
  2. package/.claude/commands/thumbgate-dashboard.md +23 -8
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.well-known/llms.txt +18 -10
  5. package/.well-known/mcp/server-card.json +1 -1
  6. package/README.md +66 -3
  7. package/adapters/claude/.mcp.json +2 -2
  8. package/adapters/forge/forge.yaml +3 -3
  9. package/adapters/mcp/server-stdio.js +88 -2
  10. package/adapters/opencode/opencode.json +1 -1
  11. package/bin/cli.js +8 -8
  12. package/bin/postinstall.js +4 -13
  13. package/commands/dashboard.md +11 -1
  14. package/commands/thumbgate-dashboard.md +23 -8
  15. package/config/agent-outcome-monitor-thresholds.json +63 -0
  16. package/config/evals/agent-outcomes-baseline.json +17 -0
  17. package/config/evals/agent-outcomes-golden.json +412 -0
  18. package/config/evals/prompt-eval-baseline.json +23 -0
  19. package/config/github-about.json +5 -4
  20. package/config/post-deploy-marketing-pages.json +6 -6
  21. package/config/schemas/task-outcome-receipt.schema.json +296 -0
  22. package/docs/integrations/grafana/README.md +109 -0
  23. package/docs/integrations/grafana/thumbgate-revenue-evidence-dashboard.json +1930 -0
  24. package/openapi/openapi.yaml +475 -5
  25. package/package.json +75 -22
  26. package/public/agent-manager.html +10 -11
  27. package/public/agents-cost-savings.html +2 -2
  28. package/public/assets/brand/thumbgate-logo-transparent.svg +6 -11
  29. package/public/assets/brand/thumbgate-mark-inline-v3.svg +11 -10
  30. package/public/assets/brand/thumbgate-mark.svg +10 -11
  31. package/public/blog/inside-your-boundary.html +114 -0
  32. package/public/blog/process-over-outcome-gates.html +119 -0
  33. package/public/blog.html +296 -402
  34. package/public/brand/thumbgate-mark.svg +5 -9
  35. package/public/codex-enterprise.html +2 -2
  36. package/public/compare.html +12 -3
  37. package/public/diagnostic.html +79 -29
  38. package/public/guide.html +4 -4
  39. package/public/index.html +1090 -2098
  40. package/public/install.html +3 -3
  41. package/public/js/buyer-intent.js +33 -18
  42. package/public/numbers.html +2 -2
  43. package/public/pricing.html +268 -408
  44. package/public/pro.html +4 -4
  45. package/scripts/agent-outcome-eval.js +130 -0
  46. package/scripts/agent-outcome-monitor.js +261 -0
  47. package/scripts/agent-reasoning-traces.js +8 -9
  48. package/scripts/async-job-runner.js +107 -13
  49. package/scripts/billing.js +456 -126
  50. package/scripts/buyer-paths.js +102 -0
  51. package/scripts/cli-feedback.js +2 -2
  52. package/scripts/commercial-offer.js +18 -10
  53. package/scripts/durability/step.js +121 -12
  54. package/scripts/external-customer-audit.js +881 -0
  55. package/scripts/feedback-loop.js +26 -0
  56. package/scripts/gates-engine.js +554 -19
  57. package/scripts/grafana-revenue-evidence.js +856 -0
  58. package/scripts/human-escalation.js +265 -0
  59. package/scripts/hybrid-feedback-context.js +93 -50
  60. package/scripts/jsonl-window.js +89 -0
  61. package/scripts/judge-reward-function.js +30 -18
  62. package/scripts/lesson-embedding-index.js +3 -7
  63. package/scripts/meta-agent-loop.js +20 -2
  64. package/scripts/observability-env.js +139 -0
  65. package/scripts/observability-setup.js +55 -0
  66. package/scripts/plausible-domain-config.js +4 -0
  67. package/scripts/prompt-eval.js +81 -4
  68. package/scripts/provider-live-evidence.js +1290 -0
  69. package/scripts/provider-payment-reconciler.js +442 -0
  70. package/scripts/provider-revenue-evidence.js +249 -0
  71. package/scripts/rate-limiter.js +1 -5
  72. package/scripts/revenue-action-eligibility.js +414 -0
  73. package/scripts/revenue-evidence-remediation.js +694 -0
  74. package/scripts/revenue-offer-system.js +709 -0
  75. package/scripts/sales-pipeline.js +1117 -0
  76. package/scripts/schedule-manager.js +249 -0
  77. package/scripts/seo-gsd.js +8 -4
  78. package/scripts/stripe-credentials.js +37 -0
  79. package/scripts/stripe-revenue-catalog-audit.js +363 -0
  80. package/scripts/stripe-revenue-catalog.js +164 -0
  81. package/scripts/task-outcomes.js +425 -0
  82. package/scripts/telemetry-analytics.js +23 -3
  83. package/scripts/tool-contract-validator.js +287 -59
  84. package/scripts/tool-registry.js +143 -0
  85. package/scripts/vector-store.js +83 -7
  86. package/scripts/workflow-intake-queue.js +483 -0
  87. package/src/api/server.js +647 -118
@@ -0,0 +1,881 @@
1
+ #!/usr/bin/env node
2
+ /** Reconcile exact Stripe product activity while excluding reviewed owner/test identities. */
3
+
4
+ 'use strict';
5
+
6
+ const crypto = require('node:crypto');
7
+ const path = require('node:path');
8
+ const {
9
+ DEFAULT_SECRET_PATHS,
10
+ readSecretFile,
11
+ resolveStripeSecretKey,
12
+ } = require('./stripe-credentials');
13
+ const { eventOccursInWindow, formatLocalDate, resolveAnalyticsWindow } = require('./analytics-window');
14
+ const { digestBuyerEmail } = require('./provider-revenue-evidence');
15
+ const {
16
+ STRIPE_REVENUE_CATALOG_VERSION,
17
+ DEFAULT_STRIPE_REVENUE_CATALOG,
18
+ validateStripeRevenueCatalog,
19
+ matchStripeRevenueCatalogPrice,
20
+ } = require('./stripe-revenue-catalog');
21
+ const compareText = (a, b) => a.localeCompare(b);
22
+
23
+ function parseArgs(argv = []) {
24
+ return {
25
+ json: argv.includes('--json'),
26
+ strict: argv.includes('--strict'),
27
+ };
28
+ }
29
+
30
+ function parseOwnerEmails(env = process.env) {
31
+ const raw = env.THUMBGATE_OWNER_EMAILS;
32
+ if (raw?.trim()) {
33
+ return raw.split(',').map((e) => e.trim().toLowerCase()).filter(Boolean);
34
+ }
35
+ return [
36
+ 'iganapolsky@gmail.com',
37
+ 'igor.ganapolsky@gmail.com',
38
+ ];
39
+ }
40
+
41
+ function isOwnerEmail(email, ownerEmails) {
42
+ if (!email) return false;
43
+ return ownerEmails.includes(String(email).toLowerCase().trim());
44
+ }
45
+
46
+ function classifyEmail(email, ownerEmails) {
47
+ if (!String(email || '').trim()) return 'unknown';
48
+ return isOwnerEmail(email, ownerEmails) ? 'owner' : 'external';
49
+ }
50
+
51
+ function dollars(cents) {
52
+ return Number(cents || 0) / 100;
53
+ }
54
+
55
+ function loadStripe(requireFn = require) {
56
+ return requireFn('stripe');
57
+ }
58
+
59
+ // Stripe's cursor pagination has no hard stop — we iterate until `has_more`
60
+ // is false. The optional `max` is a runaway-guard only; default 100,000
61
+ // (well above any sane lifetime list for this product) so the audit never
62
+ // silently truncates real data the way an earlier 1000-cap did. Tests can
63
+ // pass a small max to exercise truncation behavior explicitly.
64
+ async function listAllPaged(listFn, params = {}, max = 100000) {
65
+ const out = [];
66
+ let startingAfter;
67
+ while (out.length < max) {
68
+ const page = await listFn({ ...params, limit: 100, ...(startingAfter ? { starting_after: startingAfter } : {}) });
69
+ if (!page?.data) break;
70
+ if (page.data.length === 0) break;
71
+ for (const row of page.data) {
72
+ out.push(row);
73
+ if (out.length >= max) return out;
74
+ }
75
+ if (!page.has_more) break;
76
+ startingAfter = page.data[page.data.length - 1].id;
77
+ }
78
+ return out;
79
+ }
80
+
81
+ function emailOfCustomer(obj) {
82
+ if (!obj || typeof obj === 'string') return null;
83
+ if (obj.email) return obj.email;
84
+ if (obj.customer && typeof obj.customer === 'object') return obj.customer.email || null;
85
+ return null;
86
+ }
87
+
88
+ function chargeEmail(charge) {
89
+ return emailOfCustomer(charge) || charge.billing_details?.email || null;
90
+ }
91
+
92
+ function sessionEmail(session) {
93
+ return session.customer_email || session.customer_details?.email || null;
94
+ }
95
+
96
+ function paymentIntentId(value) {
97
+ const paymentIntent = value?.payment_intent;
98
+ return typeof paymentIntent === 'string' ? paymentIntent : paymentIntent?.id || null;
99
+ }
100
+
101
+ function productReference(value) {
102
+ if (!value) return { known: false, id: null, name: null };
103
+ if (typeof value === 'string') return { known: false, id: value, name: null };
104
+ return {
105
+ known: Boolean(String(value.name || '').trim()),
106
+ id: value.id || null,
107
+ name: String(value.name || '').trim() || null,
108
+ };
109
+ }
110
+
111
+ function isThumbGateProductName(value) {
112
+ return String(value || '').toLowerCase().includes('thumbgate');
113
+ }
114
+
115
+ function subscriptionPriceReferences(subscription = {}) {
116
+ const items = subscription.items?.data || [];
117
+ if (items.length) return items.map((item) => item.price || {}).filter(Boolean);
118
+ return [subscription.plan || subscription.price].filter(Boolean);
119
+ }
120
+
121
+ function partitionCharges(rows, ownerEmails) {
122
+ const result = { all: [], owner: [], external: [], unknown: [] };
123
+ for (const ch of rows) {
124
+ const bucket = classifyEmail(chargeEmail(ch), ownerEmails);
125
+ result.all.push(ch);
126
+ result[bucket].push(ch);
127
+ }
128
+ return result;
129
+ }
130
+
131
+ function summarizeCharges(rows) {
132
+ const gross = rows.reduce((s, c) => s + (c.amount || 0), 0);
133
+ const refunded = rows.reduce((s, c) => s + (c.amount_refunded || 0), 0);
134
+ const netByEmail = new Map();
135
+ for (const charge of rows) {
136
+ const email = String(chargeEmail(charge) || '').toLowerCase().trim();
137
+ if (!email) continue;
138
+ const net = Number(charge.amount || 0) - Number(charge.amount_refunded || 0);
139
+ netByEmail.set(email, (netByEmail.get(email) || 0) + net);
140
+ }
141
+ return {
142
+ chargeCount: rows.length,
143
+ netPositiveChargeCount: rows.filter((charge) => (
144
+ Number(charge.amount || 0) - Number(charge.amount_refunded || 0) > 0
145
+ )).length,
146
+ identifiedCustomerCount: netByEmail.size,
147
+ uniqueCustomerCount: [...netByEmail.values()].filter((net) => net > 0).length,
148
+ grossCents: gross,
149
+ refundedCents: refunded,
150
+ netCents: gross - refunded,
151
+ gross: dollars(gross),
152
+ net: dollars(gross - refunded),
153
+ };
154
+ }
155
+
156
+ function monthlyRecurringCents(price = {}) {
157
+ const amount = Number(price.unit_amount ?? price.amount ?? 0);
158
+ const intervalCount = Math.max(1, Number(price.recurring?.interval_count ?? price.interval_count ?? 1));
159
+ const interval = price.recurring?.interval ?? price.interval;
160
+ if (interval === 'year') return amount / (12 * intervalCount);
161
+ if (interval === 'week') return amount * 52 / (12 * intervalCount);
162
+ if (interval === 'day') return amount * 365 / (12 * intervalCount);
163
+ if (interval === 'month') return amount / intervalCount;
164
+ return amount;
165
+ }
166
+
167
+ function subscriptionMRRCents(subscription = {}) {
168
+ const items = subscription.items?.data || [];
169
+ if (items.length) {
170
+ return items.reduce((sum, item) => (
171
+ sum + monthlyRecurringCents(item.price || {}) * Math.max(1, Number(item.quantity || 1))
172
+ ), 0);
173
+ }
174
+ return monthlyRecurringCents(subscription.plan || subscription.price || {});
175
+ }
176
+
177
+ function stripeCreatedIso(value = {}) {
178
+ const createdSeconds = Number(value.created);
179
+ if (!Number.isFinite(createdSeconds) || createdSeconds <= 0) return null;
180
+ return new Date(createdSeconds * 1000).toISOString();
181
+ }
182
+
183
+ function sha256(value) {
184
+ return `sha256:${crypto.createHash('sha256').update(String(value || '')).digest('hex')}`;
185
+ }
186
+
187
+ function buildStripeIndividualPaymentEvidence(charges = [], contexts = new Map(), {
188
+ now,
189
+ timeZone = 'UTC',
190
+ } = {}) {
191
+ const nowDate = new Date(now || new Date().toISOString());
192
+ if (Number.isNaN(nowDate.getTime())) {
193
+ return {
194
+ verified: false,
195
+ gap: 'Stripe individual-payment evidence requires a valid audit timestamp.',
196
+ payments: [],
197
+ states: [],
198
+ };
199
+ }
200
+ const ids = new Set();
201
+ const states = [];
202
+ for (const charge of charges) {
203
+ const id = String(charge?.id || '').trim();
204
+ const createdAt = new Date(String(stripeCreatedIso(charge) || ''));
205
+ const grossCents = Number(charge?.amount);
206
+ const refundedCents = Number(charge?.amount_refunded || 0);
207
+ const currency = String(charge?.currency || 'usd').trim().toLowerCase();
208
+ const payerEmail = String(chargeEmail(charge) || '').trim().toLowerCase();
209
+ const context = contexts.get(id) || {};
210
+ const sessionIds = [...(context.sessionIds || [])].sort(compareText);
211
+ const productIds = [...(context.productIds || [])].sort(compareText);
212
+ const priceIds = [...(context.priceIds || [])].sort(compareText);
213
+ const offerIds = [...(context.offerIds || [])].sort(compareText);
214
+ const buyerEmailDigest = digestBuyerEmail(payerEmail);
215
+ if (!id || ids.has(id) || Number.isNaN(createdAt.getTime()) ||
216
+ createdAt.getTime() > nowDate.getTime() + 5 * 60 * 1000 ||
217
+ !Number.isSafeInteger(grossCents) || grossCents <= 0 ||
218
+ !Number.isSafeInteger(refundedCents) || refundedCents < 0 || refundedCents > grossCents ||
219
+ currency !== 'usd' || !buyerEmailDigest || sessionIds.length === 0 || productIds.length === 0 ||
220
+ priceIds.length === 0 || offerIds.length === 0) {
221
+ return {
222
+ verified: false,
223
+ gap: `Stripe individual-payment candidate ${id || 'unknown'} is malformed or lacks exact product, payer, or checkout evidence.`,
224
+ payments: [],
225
+ states: [],
226
+ };
227
+ }
228
+ ids.add(id);
229
+ const netCents = grossCents - refundedCents;
230
+ const status = netCents === 0
231
+ ? 'refunded'
232
+ : refundedCents > 0 ? 'partially_refunded' : 'completed';
233
+ const customerId = sha256(`stripe-payer:${payerEmail}`);
234
+ const invoiceId = String(charge?.invoice?.id || charge?.invoice || '').trim().slice(0, 127) || null;
235
+ const evidenceRecord = {
236
+ provider: 'stripe',
237
+ id,
238
+ createdAt: createdAt.toISOString(),
239
+ status,
240
+ grossCents,
241
+ refundedCents,
242
+ netCents,
243
+ currency,
244
+ customerId,
245
+ buyerEmailDigest,
246
+ paymentIntentId: String(charge?.payment_intent?.id || charge?.payment_intent || '').trim() || null,
247
+ invoiceId,
248
+ sessionIds,
249
+ productIds,
250
+ priceIds,
251
+ offerIds,
252
+ catalogVersion: STRIPE_REVENUE_CATALOG_VERSION,
253
+ };
254
+ const localDate = formatLocalDate(createdAt, timeZone);
255
+ const state = {
256
+ provider: 'stripe',
257
+ id,
258
+ createdAt: createdAt.toISOString(),
259
+ localDate,
260
+ timeZone,
261
+ isToday: localDate === formatLocalDate(nowDate, timeZone),
262
+ status,
263
+ grossCents,
264
+ refundedCents,
265
+ netCents,
266
+ currency,
267
+ customerId,
268
+ buyerEmailDigest,
269
+ customerClassification: 'external',
270
+ ownerTest: false,
271
+ productAttribution: { verified: true, product: 'thumbgate' },
272
+ catalogVersion: STRIPE_REVENUE_CATALOG_VERSION,
273
+ offerIds,
274
+ evidenceVerified: true,
275
+ evidenceSource: 'provider_api_live:stripe-checkout-product-reconciliation',
276
+ evidenceDigest: sha256(JSON.stringify(evidenceRecord)),
277
+ ...(invoiceId ? { invoiceId } : {}),
278
+ };
279
+ states.push(state);
280
+ }
281
+ return {
282
+ verified: true,
283
+ gap: null,
284
+ payments: states.filter((payment) => payment.netCents > 0),
285
+ states,
286
+ };
287
+ }
288
+
289
+ function localDateRange(startLocalDate, endLocalDate) {
290
+ const dates = [];
291
+ const end = Date.parse(`${endLocalDate}T00:00:00.000Z`);
292
+ for (let cursor = Date.parse(`${startLocalDate}T00:00:00.000Z`); cursor <= end; cursor += 86400000) {
293
+ dates.push(new Date(cursor).toISOString().slice(0, 10));
294
+ }
295
+ return dates;
296
+ }
297
+
298
+ function summarizeAttributedRevenueWindows(charges = [], { now, timeZone = 'UTC' } = {}) {
299
+ const today = resolveAnalyticsWindow({ window: 'today', now, timeZone });
300
+ const trailing30Days = resolveAnalyticsWindow({ window: '30d', now, timeZone });
301
+ let missingCreatedAtCount = 0;
302
+ let todayGrossRevenueCents = 0;
303
+ let todayNetRevenueCents = 0;
304
+ let trailing30DayGrossRevenueCents = 0;
305
+ let trailing30DayNetRevenueCents = 0;
306
+ const dailyGrossRevenueCents = Object.fromEntries(
307
+ localDateRange(trailing30Days.startLocalDate, trailing30Days.endLocalDate)
308
+ .map((localDate) => [localDate, 0])
309
+ );
310
+ const dailyNetRevenueCents = Object.fromEntries(
311
+ Object.keys(dailyGrossRevenueCents).map((localDate) => [localDate, 0])
312
+ );
313
+
314
+ for (const charge of charges) {
315
+ const createdAt = stripeCreatedIso(charge);
316
+ if (!createdAt) {
317
+ missingCreatedAtCount += 1;
318
+ continue;
319
+ }
320
+ const grossCents = Number(charge.amount || 0);
321
+ const netCents = grossCents - Number(charge.amount_refunded || 0);
322
+ if (eventOccursInWindow(createdAt, today)) {
323
+ todayGrossRevenueCents += grossCents;
324
+ todayNetRevenueCents += netCents;
325
+ }
326
+ if (eventOccursInWindow(createdAt, trailing30Days)) {
327
+ trailing30DayGrossRevenueCents += grossCents;
328
+ trailing30DayNetRevenueCents += netCents;
329
+ const localDate = formatLocalDate(new Date(createdAt), trailing30Days.timeZone);
330
+ dailyGrossRevenueCents[localDate] += grossCents;
331
+ dailyNetRevenueCents[localDate] += netCents;
332
+ }
333
+ }
334
+
335
+ return {
336
+ verified: missingCreatedAtCount === 0,
337
+ gap: missingCreatedAtCount === 0
338
+ ? null
339
+ : `${missingCreatedAtCount} attributed charge(s) lacked Stripe created timestamps, so time-window revenue is incomplete.`,
340
+ basis: 'Stripe charge created time with current charge-level refund totals; this is charge-cohort revenue, not balance-transaction cash-flow timing.',
341
+ timeZone: today.timeZone,
342
+ asOf: today.now,
343
+ todayLocalDate: today.endLocalDate,
344
+ trailing30DayStartLocalDate: trailing30Days.startLocalDate,
345
+ missingCreatedAtCount,
346
+ todayGrossRevenueCents,
347
+ todayNetRevenueCents,
348
+ trailing30DayGrossRevenueCents,
349
+ trailing30DayNetRevenueCents,
350
+ dailyGrossRevenueCents,
351
+ dailyNetRevenueCents,
352
+ };
353
+ }
354
+
355
+ function subsMRR(rows) {
356
+ return rows.reduce((sum, subscription) => sum + subscriptionMRRCents(subscription), 0);
357
+ }
358
+
359
+ async function buildProductAttribution({
360
+ stripe,
361
+ sessions,
362
+ successfulCharges,
363
+ externalSubs,
364
+ ownerEmails,
365
+ accountExternalNetCents,
366
+ now,
367
+ timeZone = 'UTC',
368
+ productCatalog = DEFAULT_STRIPE_REVENUE_CATALOG,
369
+ }) {
370
+ const catalogValidation = validateStripeRevenueCatalog(productCatalog);
371
+ if (!catalogValidation.ok) {
372
+ return {
373
+ verified: false,
374
+ scope: 'stripe_revenue_catalog_invalid',
375
+ gap: catalogValidation.gap,
376
+ thumbgate: {
377
+ uniquePayingCustomerCount: 0,
378
+ netRevenueCents: 0,
379
+ activeSubscriptionCount: 0,
380
+ mrrCents: 0,
381
+ revenueWindows: summarizeAttributedRevenueWindows([], { now, timeZone }),
382
+ individualPaymentEvidenceVerified: false,
383
+ individualPaymentEvidenceGap: catalogValidation.gap,
384
+ individualPayments: [],
385
+ individualPaymentStates: [],
386
+ },
387
+ paidExternalSessionCount: 0,
388
+ zeroAmountPaidStatusSessionCount: 0,
389
+ auditedPaidExternalSessionCount: 0,
390
+ identityConflictCount: 0,
391
+ unresolvedSessionCount: 0,
392
+ unresolvedReasons: {
393
+ lineItemFetchFailed: 0,
394
+ productIdentityUnavailable: 0,
395
+ catalogTermsMismatch: 0,
396
+ paymentIntentMissing: 0,
397
+ chargeNotFound: 0,
398
+ payerIdentityConflict: 0,
399
+ mixedProductSession: 0,
400
+ },
401
+ unresolvedSubscriptionCount: 0,
402
+ mixedProductSubscriptionCount: 0,
403
+ catalogTermsMismatchCount: 0,
404
+ unmatchedExternalNetCents: accountExternalNetCents,
405
+ catalogVersion: STRIPE_REVENUE_CATALOG_VERSION,
406
+ };
407
+ }
408
+ const zeroAmountPaidStatusSessions = sessions.filter((session) => (
409
+ session.payment_status === 'paid' && Number(session.amount_total || 0) <= 0
410
+ ));
411
+ const paidExternalSessions = sessions.filter((session) => (
412
+ session.payment_status === 'paid' &&
413
+ Number(session.amount_total || 0) > 0 &&
414
+ classifyEmail(sessionEmail(session), ownerEmails) === 'external'
415
+ ));
416
+ const lineItemsApi = stripe?.checkout?.sessions?.listLineItems;
417
+ if (paidExternalSessions.length && typeof lineItemsApi !== 'function') {
418
+ return {
419
+ verified: false,
420
+ scope: 'stripe_account_wide',
421
+ gap: 'Paid known non-owner sessions exist, but Stripe line-item access is unavailable for ThumbGate product attribution.',
422
+ thumbgate: {
423
+ uniquePayingCustomerCount: 0,
424
+ netRevenueCents: 0,
425
+ activeSubscriptionCount: 0,
426
+ mrrCents: 0,
427
+ revenueWindows: summarizeAttributedRevenueWindows([], { now, timeZone }),
428
+ },
429
+ paidExternalSessionCount: paidExternalSessions.length,
430
+ zeroAmountPaidStatusSessionCount: zeroAmountPaidStatusSessions.length,
431
+ auditedPaidExternalSessionCount: 0,
432
+ identityConflictCount: 0,
433
+ unresolvedSessionCount: paidExternalSessions.length,
434
+ unmatchedExternalNetCents: accountExternalNetCents,
435
+ catalogVersion: STRIPE_REVENUE_CATALOG_VERSION,
436
+ };
437
+ }
438
+
439
+ const successfulChargesByPaymentIntent = new Map();
440
+ for (const charge of successfulCharges) {
441
+ const id = paymentIntentId(charge);
442
+ if (!id) continue;
443
+ const matches = successfulChargesByPaymentIntent.get(id) || [];
444
+ matches.push(charge);
445
+ successfulChargesByPaymentIntent.set(id, matches);
446
+ }
447
+
448
+ const attributedPayingEmails = new Set();
449
+ const reconciledExternalChargeIds = new Set();
450
+ const attributedThumbGateCharges = new Map();
451
+ const attributedThumbGateContexts = new Map();
452
+ let auditedPaidExternalSessionCount = 0;
453
+ let identityConflictCount = 0;
454
+ let unresolvedSessionCount = 0;
455
+ let lineItemErrorCount = 0;
456
+ let catalogTermsMismatchCount = 0;
457
+ const unresolvedReasons = {
458
+ lineItemFetchFailed: 0,
459
+ productIdentityUnavailable: 0,
460
+ catalogTermsMismatch: 0,
461
+ paymentIntentMissing: 0,
462
+ chargeNotFound: 0,
463
+ payerIdentityConflict: 0,
464
+ mixedProductSession: 0,
465
+ };
466
+
467
+ for (const session of paidExternalSessions) {
468
+ let lineItems;
469
+ try {
470
+ lineItems = await listAllPaged(
471
+ (params) => lineItemsApi.call(stripe.checkout.sessions, session.id, params),
472
+ { expand: ['data.price.product'] },
473
+ 1000
474
+ );
475
+ auditedPaidExternalSessionCount += 1;
476
+ } catch {
477
+ lineItemErrorCount += 1;
478
+ unresolvedSessionCount += 1;
479
+ unresolvedReasons.lineItemFetchFailed += 1;
480
+ continue;
481
+ }
482
+
483
+ const catalogMatches = lineItems.map((item) => matchStripeRevenueCatalogPrice(item.price, productCatalog));
484
+ if (catalogMatches.some((match) => match.reason === 'catalog_terms_mismatch')) {
485
+ unresolvedSessionCount += 1;
486
+ catalogTermsMismatchCount += 1;
487
+ unresolvedReasons.catalogTermsMismatch += 1;
488
+ continue;
489
+ }
490
+ const productsKnown = catalogMatches.length > 0 && catalogMatches.every((match) => match.complete);
491
+ const allThumbGate = productsKnown && catalogMatches.every((match) => match.matched);
492
+ const intentId = paymentIntentId(session);
493
+ const matchingCharges = intentId ? (successfulChargesByPaymentIntent.get(intentId) || []) : [];
494
+ const checkoutEmail = String(sessionEmail(session) || '').trim().toLowerCase();
495
+ const externalCharges = matchingCharges.filter((charge) => (
496
+ classifyEmail(chargeEmail(charge), ownerEmails) === 'external' &&
497
+ String(chargeEmail(charge) || '').trim().toLowerCase() === checkoutEmail
498
+ ));
499
+ const conflictingCharges = matchingCharges.filter((charge) => (
500
+ classifyEmail(chargeEmail(charge), ownerEmails) !== 'external' ||
501
+ String(chargeEmail(charge) || '').trim().toLowerCase() !== checkoutEmail
502
+ ));
503
+
504
+ if (!productsKnown) {
505
+ unresolvedSessionCount += 1;
506
+ unresolvedReasons.productIdentityUnavailable += 1;
507
+ continue;
508
+ }
509
+ if (!intentId) {
510
+ unresolvedSessionCount += 1;
511
+ unresolvedReasons.paymentIntentMissing += 1;
512
+ continue;
513
+ }
514
+ if (matchingCharges.length === 0) {
515
+ unresolvedSessionCount += 1;
516
+ unresolvedReasons.chargeNotFound += 1;
517
+ continue;
518
+ }
519
+ if (conflictingCharges.length || externalCharges.length === 0) {
520
+ identityConflictCount += 1;
521
+ unresolvedReasons.payerIdentityConflict += 1;
522
+ continue;
523
+ }
524
+
525
+ const anyThumbGate = catalogMatches.some((match) => match.matched);
526
+ if (anyThumbGate && !allThumbGate) {
527
+ unresolvedSessionCount += 1;
528
+ unresolvedReasons.mixedProductSession += 1;
529
+ continue;
530
+ }
531
+
532
+ for (const charge of externalCharges) reconciledExternalChargeIds.add(charge.id);
533
+ if (!allThumbGate) continue;
534
+ for (const charge of externalCharges) {
535
+ attributedThumbGateCharges.set(charge.id, charge);
536
+ const context = attributedThumbGateContexts.get(charge.id) || {
537
+ sessionIds: new Set(),
538
+ productIds: new Set(),
539
+ priceIds: new Set(),
540
+ offerIds: new Set(),
541
+ };
542
+ context.sessionIds.add(String(session.id));
543
+ for (const match of catalogMatches) {
544
+ if (match.observed?.productId) context.productIds.add(String(match.observed.productId));
545
+ if (match.observed?.priceId) context.priceIds.add(String(match.observed.priceId));
546
+ if (match.offerId) context.offerIds.add(String(match.offerId));
547
+ }
548
+ attributedThumbGateContexts.set(charge.id, context);
549
+ }
550
+ const netCents = externalCharges.reduce((sum, charge) => (
551
+ sum + Number(charge.amount || 0) - Number(charge.amount_refunded || 0)
552
+ ), 0);
553
+ if (netCents <= 0) continue;
554
+ attributedPayingEmails.add(String(sessionEmail(session)).toLowerCase().trim());
555
+ }
556
+
557
+ const attributedCharges = [...attributedThumbGateCharges.values()];
558
+ const individualPaymentEvidence = buildStripeIndividualPaymentEvidence(
559
+ attributedCharges,
560
+ attributedThumbGateContexts,
561
+ { now, timeZone }
562
+ );
563
+ const thumbgateNetRevenueCents = attributedCharges.reduce((sum, charge) => (
564
+ sum + Number(charge.amount || 0) - Number(charge.amount_refunded || 0)
565
+ ), 0);
566
+ const revenueWindows = summarizeAttributedRevenueWindows(attributedCharges, { now, timeZone });
567
+
568
+ const reconciledExternalNetCents = successfulCharges
569
+ .filter((charge) => reconciledExternalChargeIds.has(charge.id))
570
+ .reduce((sum, charge) => sum + Number(charge.amount || 0) - Number(charge.amount_refunded || 0), 0);
571
+ const unmatchedExternalNetCents = Math.max(0, accountExternalNetCents - reconciledExternalNetCents);
572
+
573
+ let activeSubscriptionCount = 0;
574
+ let subscriptionMrrCents = 0;
575
+ let unresolvedSubscriptionCount = 0;
576
+ let mixedProductSubscriptionCount = 0;
577
+ for (const subscription of externalSubs) {
578
+ const refs = subscriptionPriceReferences(subscription);
579
+ const catalogMatches = refs.map((price) => matchStripeRevenueCatalogPrice(price, productCatalog));
580
+ if (catalogMatches.some((match) => match.reason === 'catalog_terms_mismatch')) {
581
+ unresolvedSubscriptionCount += 1;
582
+ catalogTermsMismatchCount += 1;
583
+ continue;
584
+ }
585
+ if (!catalogMatches.length || catalogMatches.some((match) => !match.complete)) {
586
+ unresolvedSubscriptionCount += 1;
587
+ continue;
588
+ }
589
+ const thumbGateRefs = catalogMatches.filter((match) => match.matched);
590
+ if (thumbGateRefs.length > 0 && thumbGateRefs.length !== catalogMatches.length) {
591
+ unresolvedSubscriptionCount += 1;
592
+ mixedProductSubscriptionCount += 1;
593
+ continue;
594
+ }
595
+ if (thumbGateRefs.length === 0) continue;
596
+ activeSubscriptionCount += 1;
597
+ subscriptionMrrCents += subscriptionMRRCents(subscription);
598
+ const email = emailOfCustomer(subscription);
599
+ if (email) attributedPayingEmails.add(String(email).toLowerCase().trim());
600
+ }
601
+
602
+ const verified = lineItemErrorCount === 0 &&
603
+ unresolvedSessionCount === 0 &&
604
+ identityConflictCount === 0 &&
605
+ unresolvedSubscriptionCount === 0 &&
606
+ unmatchedExternalNetCents === 0;
607
+ return {
608
+ verified,
609
+ scope: 'reconciled_paid_checkout_and_active_subscription',
610
+ gap: verified
611
+ ? null
612
+ : 'ThumbGate revenue remains unverified where product, payment-intent, payer identity, refund net, or active-subscription evidence does not reconcile.',
613
+ thumbgate: {
614
+ uniquePayingCustomerCount: attributedPayingEmails.size,
615
+ netRevenueCents: thumbgateNetRevenueCents,
616
+ activeSubscriptionCount,
617
+ mrrCents: subscriptionMrrCents,
618
+ revenueWindows,
619
+ individualPaymentEvidenceVerified: individualPaymentEvidence.verified,
620
+ individualPaymentEvidenceGap: individualPaymentEvidence.gap,
621
+ individualPayments: individualPaymentEvidence.payments,
622
+ individualPaymentStates: individualPaymentEvidence.states,
623
+ },
624
+ paidExternalSessionCount: paidExternalSessions.length,
625
+ zeroAmountPaidStatusSessionCount: zeroAmountPaidStatusSessions.length,
626
+ auditedPaidExternalSessionCount,
627
+ identityConflictCount,
628
+ unresolvedSessionCount,
629
+ unresolvedReasons,
630
+ unresolvedSubscriptionCount,
631
+ mixedProductSubscriptionCount,
632
+ catalogTermsMismatchCount,
633
+ unmatchedExternalNetCents,
634
+ catalogVersion: STRIPE_REVENUE_CATALOG_VERSION,
635
+ };
636
+ }
637
+
638
+ async function runAudit({
639
+ stripeClient = null,
640
+ stripeFactory = null,
641
+ secretKey = undefined,
642
+ env = process.env,
643
+ secretPaths = DEFAULT_SECRET_PATHS,
644
+ ownerEmails = parseOwnerEmails(),
645
+ now = undefined,
646
+ timeZone = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
647
+ productCatalog = DEFAULT_STRIPE_REVENUE_CATALOG,
648
+ } = {}) {
649
+ let credentialSource = stripeClient ? 'injected_client' : null;
650
+ if (!stripeClient && secretKey === undefined) {
651
+ const resolved = resolveStripeSecretKey({ env, secretPaths });
652
+ secretKey = resolved.secretKey;
653
+ credentialSource = resolved.source;
654
+ } else if (!stripeClient && secretKey) {
655
+ credentialSource = 'injected_secret';
656
+ }
657
+ if (!secretKey && !stripeClient) {
658
+ return {
659
+ configured: false,
660
+ gap: 'No Stripe credential found in STRIPE_SECRET_KEY or managed local key files',
661
+ ownerEmails,
662
+ };
663
+ }
664
+ let stripe = stripeClient;
665
+ if (!stripe) {
666
+ try {
667
+ const factory = stripeFactory || loadStripe();
668
+ stripe = factory(secretKey);
669
+ } catch (error) {
670
+ return { configured: false, gap: `Stripe SDK unavailable: ${error.message}`, ownerEmails };
671
+ }
672
+ }
673
+ // Explicit null guard for static analyzers: at this point the branches
674
+ // above either assigned `stripe` or returned. Make the precondition
675
+ // unambiguous so Sonar's reliability check stays at A.
676
+ if (!stripe?.charges?.list || !stripe?.subscriptions?.list || !stripe?.checkout?.sessions?.list) {
677
+ return { configured: false, gap: 'Stripe client does not expose the expected list endpoints', ownerEmails };
678
+ }
679
+
680
+ const [charges, subscriptions, sessions] = await Promise.all([
681
+ listAllPaged((p) => stripe.charges.list(p), { expand: ['data.customer'] }),
682
+ listAllPaged((p) => stripe.subscriptions.list(p), { status: 'all', expand: ['data.customer'] }),
683
+ listAllPaged((p) => stripe.checkout.sessions.list(p), {}),
684
+ ]);
685
+
686
+ const successfulCharges = charges.filter((c) => c.status === 'succeeded' || c.paid === true);
687
+ const partitioned = partitionCharges(successfulCharges, ownerEmails);
688
+
689
+ const subs = subscriptions.filter((s) => s.status === 'active' || s.status === 'trialing');
690
+ const activeSubs = subs.filter((s) => s.status === 'active');
691
+ const trialingSubs = subs.filter((s) => s.status === 'trialing');
692
+ const externalSubs = activeSubs.filter((s) => classifyEmail(emailOfCustomer(s), ownerEmails) === 'external');
693
+ const ownerSubs = activeSubs.filter((s) => classifyEmail(emailOfCustomer(s), ownerEmails) === 'owner');
694
+ const unknownSubs = activeSubs.filter((s) => classifyEmail(emailOfCustomer(s), ownerEmails) === 'unknown');
695
+ const externalTrials = trialingSubs.filter((s) => classifyEmail(emailOfCustomer(s), ownerEmails) === 'external');
696
+ const ownerTrials = trialingSubs.filter((s) => classifyEmail(emailOfCustomer(s), ownerEmails) === 'owner');
697
+ const unknownTrials = trialingSubs.filter((s) => classifyEmail(emailOfCustomer(s), ownerEmails) === 'unknown');
698
+
699
+ const externalSessions = sessions.filter((s) => classifyEmail(sessionEmail(s), ownerEmails) === 'external');
700
+ const ownerSessions = sessions.filter((s) => classifyEmail(sessionEmail(s), ownerEmails) === 'owner');
701
+ const unknownSessions = sessions.filter((s) => classifyEmail(sessionEmail(s), ownerEmails) === 'unknown');
702
+ const completedSessions = sessions.filter((s) => s.status === 'complete');
703
+ const externalCompletedSessions = externalSessions.filter((s) => s.status === 'complete');
704
+ const paidSessions = sessions.filter((s) => s.payment_status === 'paid');
705
+ const externalPaidSessions = externalSessions.filter((s) => s.payment_status === 'paid');
706
+ const ownerPaidSessions = ownerSessions.filter((s) => s.payment_status === 'paid');
707
+ const unknownPaidSessions = unknownSessions.filter((s) => s.payment_status === 'paid');
708
+ const monetaryPaidSessions = paidSessions.filter((s) => Number(s.amount_total || 0) > 0);
709
+ const monetaryExternalPaidSessions = externalPaidSessions.filter((s) => Number(s.amount_total || 0) > 0);
710
+ const zeroAmountPaidStatusSessions = paidSessions.filter((s) => Number(s.amount_total || 0) <= 0);
711
+ const allSessions = sessions.length;
712
+ const externalSessionsCount = externalSessions.length;
713
+ const accountExternalSummary = summarizeCharges(partitioned.external);
714
+ const productAttribution = await buildProductAttribution({
715
+ stripe,
716
+ sessions,
717
+ successfulCharges,
718
+ externalSubs,
719
+ ownerEmails,
720
+ accountExternalNetCents: accountExternalSummary.netCents,
721
+ now,
722
+ timeZone,
723
+ productCatalog,
724
+ });
725
+
726
+ return {
727
+ configured: true,
728
+ credentialSource,
729
+ ownerEmails,
730
+ generatedAt: new Date().toISOString(),
731
+ classificationNote: 'External means a known email not present in THUMBGATE_OWNER_EMAILS. Missing email stays unknown and never counts as external proof.',
732
+ productAttribution,
733
+ charges: {
734
+ all: summarizeCharges(partitioned.all),
735
+ owner: summarizeCharges(partitioned.owner),
736
+ external: accountExternalSummary,
737
+ unknown: summarizeCharges(partitioned.unknown),
738
+ },
739
+ subscriptions: {
740
+ activeOrTrialing: subs.length,
741
+ activeExternal: externalSubs.length,
742
+ activeOwner: ownerSubs.length,
743
+ activeUnknown: unknownSubs.length,
744
+ trialingExternal: externalTrials.length,
745
+ trialingOwner: ownerTrials.length,
746
+ trialingUnknown: unknownTrials.length,
747
+ mrrAllCents: subsMRR(activeSubs),
748
+ mrrExternalCents: subsMRR(externalSubs),
749
+ mrrAll: dollars(subsMRR(activeSubs)),
750
+ mrrExternal: dollars(subsMRR(externalSubs)),
751
+ },
752
+ checkout: {
753
+ totalSessions: allSessions,
754
+ externalSessions: externalSessionsCount,
755
+ ownerSessions: ownerSessions.length,
756
+ unknownSessions: unknownSessions.length,
757
+ statusCompleteAll: completedSessions.length,
758
+ statusCompleteExternal: externalCompletedSessions.length,
759
+ completedAll: completedSessions.length,
760
+ completedExternal: externalCompletedSessions.length,
761
+ paidAll: paidSessions.length,
762
+ paidExternal: externalPaidSessions.length,
763
+ paidOwner: ownerPaidSessions.length,
764
+ paidUnknown: unknownPaidSessions.length,
765
+ monetaryPaidAll: monetaryPaidSessions.length,
766
+ monetaryPaidExternal: monetaryExternalPaidSessions.length,
767
+ zeroAmountPaidStatusAll: zeroAmountPaidStatusSessions.length,
768
+ completionRateAll: allSessions ? completedSessions.length / allSessions : 0,
769
+ // External rate uses the external denominator — dividing external
770
+ // completions by total sessions would systematically undercount the
771
+ // real-customer conversion (Codex P2 finding).
772
+ completionRateExternal: externalSessionsCount ? externalCompletedSessions.length / externalSessionsCount : 0,
773
+ paidRateExternal: externalSessionsCount ? externalPaidSessions.length / externalSessionsCount : 0,
774
+ },
775
+ };
776
+ }
777
+
778
+ function renderMarkdown(report) {
779
+ const lines = [];
780
+ lines.push('# External Customer Audit');
781
+ lines.push('');
782
+ lines.push(`Generated: ${report.generatedAt || new Date().toISOString()}`);
783
+ if (!report.configured) {
784
+ lines.push(`Status: NOT CONFIGURED — ${report.gap}`);
785
+ return lines.join('\n') + '\n';
786
+ }
787
+ lines.push(`Owner emails filtered: ${report.ownerEmails.join(', ')}`);
788
+ lines.push('');
789
+ lines.push('## Charges (lifetime)');
790
+ lines.push('');
791
+ lines.push('| Bucket | Succeeded charges | Net-positive identities | Gross | Refunded | Net |');
792
+ lines.push('| --- | ---: | ---: | ---: | ---: | ---: |');
793
+ for (const k of ['all', 'owner', 'external', 'unknown']) {
794
+ const s = report.charges[k];
795
+ lines.push(`| ${k} | ${s.chargeCount} | ${s.uniqueCustomerCount} | $${s.gross.toFixed(2)} | $${dollars(s.refundedCents).toFixed(2)} | $${s.net.toFixed(2)} |`);
796
+ }
797
+ lines.push('');
798
+ lines.push('## Active subscriptions');
799
+ lines.push('');
800
+ lines.push(`- All active or trialing: ${report.subscriptions.activeOrTrialing} (MRR $${report.subscriptions.mrrAll.toFixed(2)})`);
801
+ lines.push(`- **Known non-owner**: ${report.subscriptions.activeExternal} (MRR $${report.subscriptions.mrrExternal.toFixed(2)})`);
802
+ lines.push(`- Owner: ${report.subscriptions.activeOwner}`);
803
+ lines.push(`- Unknown identity: ${report.subscriptions.activeUnknown}`);
804
+ lines.push(`- Trialing, not counted as active revenue: ${report.subscriptions.trialingExternal + report.subscriptions.trialingOwner + report.subscriptions.trialingUnknown}`);
805
+ lines.push('');
806
+ lines.push('## Checkout sessions');
807
+ lines.push('');
808
+ lines.push(`- Total sessions ever created: ${report.checkout.totalSessions}`);
809
+ lines.push(`- Status complete (all; not payment proof): ${report.checkout.statusCompleteAll} (${(report.checkout.completionRateAll * 100).toFixed(2)}%)`);
810
+ lines.push(`- Status complete (known non-owner; not payment proof): ${report.checkout.statusCompleteExternal} (${(report.checkout.completionRateExternal * 100).toFixed(2)}%)`);
811
+ lines.push(`- Stripe payment_status=paid (known non-owner): ${report.checkout.paidExternal} (${(report.checkout.paidRateExternal * 100).toFixed(2)}%)`);
812
+ lines.push(`- **Positive-amount paid-status sessions (known non-owner)**: ${report.checkout.monetaryPaidExternal}`);
813
+ lines.push(`- Zero-amount paid-status sessions (all identities): ${report.checkout.zeroAmountPaidStatusAll}`);
814
+ lines.push(`- Unknown identity sessions: ${report.checkout.unknownSessions}`);
815
+ lines.push('');
816
+ lines.push('## Account-wide identity evidence');
817
+ lines.push('');
818
+ const realCustomers = report.charges.external.uniqueCustomerCount;
819
+ const realRevenue = report.charges.external.net;
820
+ const realSubs = report.subscriptions.activeExternal;
821
+ const realMrr = report.subscriptions.mrrExternal;
822
+ lines.push(`- **Known non-owner paying identities lifetime: ${realCustomers}**`);
823
+ lines.push(`- **Known non-owner net revenue lifetime: $${realRevenue.toFixed(2)}**`);
824
+ lines.push(`- **Known non-owner active subscriptions: ${realSubs}** (MRR $${realMrr.toFixed(2)})`);
825
+ lines.push(`- Identity caveat: ${report.classificationNote}`);
826
+ lines.push(`- ThumbGate-attributed paying identities: ${report.productAttribution.thumbgate.uniquePayingCustomerCount}`);
827
+ lines.push(`- ThumbGate-attributed net revenue: $${dollars(report.productAttribution.thumbgate.netRevenueCents).toFixed(2)}`);
828
+ const revenueWindows = report.productAttribution.thumbgate.revenueWindows;
829
+ lines.push(`- ThumbGate-attributed gross today (${revenueWindows.timeZone}): $${dollars(revenueWindows.todayGrossRevenueCents).toFixed(2)}`);
830
+ lines.push(`- ThumbGate-attributed gross trailing 30 days: $${dollars(revenueWindows.trailing30DayGrossRevenueCents).toFixed(2)}`);
831
+ lines.push(`- Time-window attribution verified: ${revenueWindows.verified}`);
832
+ lines.push(`- Time-window basis: ${revenueWindows.basis}`);
833
+ if (revenueWindows.gap) lines.push(`- Time-window caveat: ${revenueWindows.gap}`);
834
+ lines.push(`- ThumbGate-attributed active subscriptions: ${report.productAttribution.thumbgate.activeSubscriptionCount} (MRR $${dollars(report.productAttribution.thumbgate.mrrCents).toFixed(2)})`);
835
+ lines.push(`- Product attribution verified: ${report.productAttribution.verified}`);
836
+ if (report.productAttribution.gap) lines.push(`- Product-attribution caveat: ${report.productAttribution.gap}`);
837
+ return lines.join('\n') + '\n';
838
+ }
839
+
840
+ async function main(argv = process.argv.slice(2)) {
841
+ const args = parseArgs(argv);
842
+ const report = await runAudit();
843
+ if (args.json) {
844
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
845
+ } else {
846
+ process.stdout.write(renderMarkdown(report));
847
+ }
848
+ if (args.strict && report.configured) {
849
+ const real = report.charges.external.uniqueCustomerCount;
850
+ if (real === 0) process.exit(2);
851
+ }
852
+ }
853
+
854
+ if (path.resolve(process.argv[1] || '') === path.resolve(__filename)) {
855
+ main().catch((error) => {
856
+ process.stderr.write(`external-customer-audit FAILED: ${error.message}\n`);
857
+ process.exit(1);
858
+ });
859
+ }
860
+
861
+ module.exports = {
862
+ DEFAULT_SECRET_PATHS,
863
+ parseArgs,
864
+ parseOwnerEmails,
865
+ isOwnerEmail,
866
+ classifyEmail,
867
+ readSecretFile,
868
+ resolveStripeSecretKey,
869
+ listAllPaged,
870
+ monthlyRecurringCents,
871
+ subscriptionMRRCents,
872
+ stripeCreatedIso,
873
+ localDateRange,
874
+ summarizeAttributedRevenueWindows,
875
+ productReference,
876
+ isThumbGateProductName,
877
+ buildProductAttribution,
878
+ buildStripeIndividualPaymentEvidence,
879
+ runAudit,
880
+ renderMarkdown,
881
+ };