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.
- package/.claude/commands/dashboard.md +11 -1
- package/.claude/commands/thumbgate-dashboard.md +23 -8
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/llms.txt +18 -10
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +66 -3
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +88 -2
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +8 -8
- package/bin/postinstall.js +4 -13
- package/commands/dashboard.md +11 -1
- package/commands/thumbgate-dashboard.md +23 -8
- package/config/agent-outcome-monitor-thresholds.json +63 -0
- package/config/evals/agent-outcomes-baseline.json +17 -0
- package/config/evals/agent-outcomes-golden.json +412 -0
- package/config/evals/prompt-eval-baseline.json +23 -0
- package/config/github-about.json +5 -4
- package/config/post-deploy-marketing-pages.json +6 -6
- package/config/schemas/task-outcome-receipt.schema.json +296 -0
- package/docs/integrations/grafana/README.md +109 -0
- package/docs/integrations/grafana/thumbgate-revenue-evidence-dashboard.json +1930 -0
- package/openapi/openapi.yaml +475 -5
- package/package.json +75 -22
- package/public/agent-manager.html +10 -11
- package/public/agents-cost-savings.html +2 -2
- package/public/assets/brand/thumbgate-logo-transparent.svg +6 -11
- package/public/assets/brand/thumbgate-mark-inline-v3.svg +11 -10
- package/public/assets/brand/thumbgate-mark.svg +10 -11
- package/public/blog/inside-your-boundary.html +114 -0
- package/public/blog/process-over-outcome-gates.html +119 -0
- package/public/blog.html +296 -402
- package/public/brand/thumbgate-mark.svg +5 -9
- package/public/codex-enterprise.html +2 -2
- package/public/compare.html +12 -3
- package/public/diagnostic.html +79 -29
- package/public/guide.html +4 -4
- package/public/index.html +1090 -2098
- package/public/install.html +3 -3
- package/public/js/buyer-intent.js +33 -18
- package/public/numbers.html +2 -2
- package/public/pricing.html +268 -408
- package/public/pro.html +4 -4
- package/scripts/agent-outcome-eval.js +130 -0
- package/scripts/agent-outcome-monitor.js +261 -0
- package/scripts/agent-reasoning-traces.js +8 -9
- package/scripts/async-job-runner.js +107 -13
- package/scripts/billing.js +456 -126
- package/scripts/buyer-paths.js +102 -0
- package/scripts/cli-feedback.js +2 -2
- package/scripts/commercial-offer.js +18 -10
- package/scripts/durability/step.js +121 -12
- package/scripts/external-customer-audit.js +881 -0
- package/scripts/feedback-loop.js +26 -0
- package/scripts/gates-engine.js +554 -19
- package/scripts/grafana-revenue-evidence.js +856 -0
- package/scripts/human-escalation.js +265 -0
- package/scripts/hybrid-feedback-context.js +93 -50
- package/scripts/jsonl-window.js +89 -0
- package/scripts/judge-reward-function.js +30 -18
- package/scripts/lesson-embedding-index.js +3 -7
- package/scripts/meta-agent-loop.js +20 -2
- package/scripts/observability-env.js +139 -0
- package/scripts/observability-setup.js +55 -0
- package/scripts/plausible-domain-config.js +4 -0
- package/scripts/prompt-eval.js +81 -4
- package/scripts/provider-live-evidence.js +1290 -0
- package/scripts/provider-payment-reconciler.js +442 -0
- package/scripts/provider-revenue-evidence.js +249 -0
- package/scripts/rate-limiter.js +1 -5
- package/scripts/revenue-action-eligibility.js +414 -0
- package/scripts/revenue-evidence-remediation.js +694 -0
- package/scripts/revenue-offer-system.js +709 -0
- package/scripts/sales-pipeline.js +1117 -0
- package/scripts/schedule-manager.js +249 -0
- package/scripts/seo-gsd.js +8 -4
- package/scripts/stripe-credentials.js +37 -0
- package/scripts/stripe-revenue-catalog-audit.js +363 -0
- package/scripts/stripe-revenue-catalog.js +164 -0
- package/scripts/task-outcomes.js +425 -0
- package/scripts/telemetry-analytics.js +23 -3
- package/scripts/tool-contract-validator.js +287 -59
- package/scripts/tool-registry.js +143 -0
- package/scripts/vector-store.js +83 -7
- package/scripts/workflow-intake-queue.js +483 -0
- package/src/api/server.js +647 -118
|
@@ -0,0 +1,1117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const { getFeedbackPaths } = require('./feedback-paths');
|
|
9
|
+
const { appendJsonl, ensureParentDir, readJsonl } = require('./fs-utils');
|
|
10
|
+
|
|
11
|
+
const SALES_PIPELINE_FILE = 'sales-pipeline.jsonl';
|
|
12
|
+
const SALES_PIPELINE_PATH_ENV = 'THUMBGATE_SALES_PIPELINE_PATH';
|
|
13
|
+
const SALES_STAGE_FLOW = [
|
|
14
|
+
'targeted',
|
|
15
|
+
'contacted',
|
|
16
|
+
'replied',
|
|
17
|
+
'call_booked',
|
|
18
|
+
'checkout_started',
|
|
19
|
+
'sprint_intake',
|
|
20
|
+
'paid',
|
|
21
|
+
'lost',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const SALES_STAGE_TRANSITIONS = {
|
|
25
|
+
targeted: ['contacted', 'lost'],
|
|
26
|
+
contacted: ['replied', 'lost'],
|
|
27
|
+
replied: ['call_booked', 'checkout_started', 'sprint_intake', 'lost'],
|
|
28
|
+
call_booked: ['checkout_started', 'sprint_intake', 'paid', 'lost'],
|
|
29
|
+
checkout_started: ['paid', 'lost'],
|
|
30
|
+
sprint_intake: ['paid', 'lost'],
|
|
31
|
+
paid: ['lost'],
|
|
32
|
+
lost: [],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const SALES_EVIDENCE_KINDS = Object.freeze([
|
|
36
|
+
'platform_send_receipt',
|
|
37
|
+
'buyer_reply',
|
|
38
|
+
'booking_confirmation',
|
|
39
|
+
'provider_checkout_session',
|
|
40
|
+
'buyer_checkout_confirmation',
|
|
41
|
+
'intake_submission',
|
|
42
|
+
'workflow_materials_received',
|
|
43
|
+
'provider_payment',
|
|
44
|
+
'provider_refund',
|
|
45
|
+
'buyer_declined',
|
|
46
|
+
'operator_disqualified',
|
|
47
|
+
'stale_closed',
|
|
48
|
+
'operator_note',
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const SALES_STAGE_EVIDENCE_KINDS = Object.freeze({
|
|
52
|
+
targeted: [],
|
|
53
|
+
contacted: ['platform_send_receipt'],
|
|
54
|
+
replied: ['buyer_reply'],
|
|
55
|
+
call_booked: ['booking_confirmation'],
|
|
56
|
+
checkout_started: ['provider_checkout_session', 'buyer_checkout_confirmation'],
|
|
57
|
+
sprint_intake: ['intake_submission', 'workflow_materials_received'],
|
|
58
|
+
paid: ['provider_payment'],
|
|
59
|
+
lost: ['buyer_declined', 'operator_disqualified', 'stale_closed', 'provider_refund'],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const VERIFIED_PAYMENT_PROVIDERS = Object.freeze(['paypal', 'stripe']);
|
|
63
|
+
const VERIFIED_PAYMENT_SOURCE_PATTERN = /^provider_api_live:.+/;
|
|
64
|
+
const VERIFIED_PAYMENT_DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
65
|
+
|
|
66
|
+
const LEGACY_TIMESTAMP_FALLBACK = '1970-01-01T00:00:00.000Z';
|
|
67
|
+
|
|
68
|
+
function normalizeText(value, maxLength = 1000) {
|
|
69
|
+
if (value === undefined || value === null) return null;
|
|
70
|
+
const text = String(value).trim();
|
|
71
|
+
if (!text) return null;
|
|
72
|
+
return text.slice(0, maxLength);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeUrl(value) {
|
|
76
|
+
const text = normalizeText(value, 1000);
|
|
77
|
+
if (!text) return null;
|
|
78
|
+
try {
|
|
79
|
+
return new URL(text).toString();
|
|
80
|
+
} catch {
|
|
81
|
+
return text;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeSalesStage(value, fallback = null) {
|
|
86
|
+
const normalized = normalizeText(value, 80);
|
|
87
|
+
if (!normalized) return fallback;
|
|
88
|
+
return SALES_STAGE_FLOW.includes(normalized) ? normalized : fallback;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeInteger(value, fallback = 0) {
|
|
92
|
+
const parsed = Number.parseInt(String(value || '').trim(), 10);
|
|
93
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeSalesEvidence(value = {}) {
|
|
97
|
+
const evidence = value && typeof value === 'object' ? value : {};
|
|
98
|
+
const normalized = {
|
|
99
|
+
kind: normalizeText(evidence.kind, 80),
|
|
100
|
+
provider: normalizeText(evidence.provider, 80)?.toLowerCase() || null,
|
|
101
|
+
source: normalizeText(evidence.source, 160),
|
|
102
|
+
reference: normalizeText(evidence.reference, 1000),
|
|
103
|
+
verified: evidence.verified === true,
|
|
104
|
+
digest: normalizeText(evidence.digest, 160)?.toLowerCase() || null,
|
|
105
|
+
};
|
|
106
|
+
const invoiceId = normalizeText(evidence.invoiceId, 127);
|
|
107
|
+
if (invoiceId) normalized.invoiceId = invoiceId;
|
|
108
|
+
const offerId = normalizeText(evidence.offerId, 120);
|
|
109
|
+
if (offerId) normalized.offerId = offerId;
|
|
110
|
+
const buyerDigest = normalizeText(evidence.buyerDigest, 80)?.toLowerCase() || null;
|
|
111
|
+
if (buyerDigest) normalized.buyerDigest = buyerDigest;
|
|
112
|
+
return normalized;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildSalesEvidence(payload = {}) {
|
|
116
|
+
return normalizeSalesEvidence({
|
|
117
|
+
kind: payload.evidenceKind || payload.evidence?.kind,
|
|
118
|
+
provider: payload.evidenceProvider || payload.evidence?.provider,
|
|
119
|
+
source: payload.evidenceSource || payload.evidence?.source,
|
|
120
|
+
reference: payload.evidenceRef || payload.evidenceReference
|
|
121
|
+
|| payload.evidence?.reference,
|
|
122
|
+
verified: payload.evidenceVerified === true || payload.evidence?.verified === true,
|
|
123
|
+
digest: payload.evidenceDigest || payload.evidence?.digest,
|
|
124
|
+
invoiceId: payload.evidenceInvoiceId || payload.evidence?.invoiceId,
|
|
125
|
+
offerId: payload.evidenceOfferId || payload.evidence?.offerId,
|
|
126
|
+
buyerDigest: payload.evidenceBuyerDigest || payload.evidence?.buyerDigest,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isVerifiedProviderFinancialEvidence(evidence = {}) {
|
|
131
|
+
return ['provider_payment', 'provider_refund'].includes(evidence.kind)
|
|
132
|
+
&& VERIFIED_PAYMENT_PROVIDERS.includes(evidence.provider)
|
|
133
|
+
&& evidence.verified === true
|
|
134
|
+
&& VERIFIED_PAYMENT_SOURCE_PATTERN.test(evidence.source || '')
|
|
135
|
+
&& VERIFIED_PAYMENT_DIGEST_PATTERN.test(evidence.digest || '')
|
|
136
|
+
&& Boolean(evidence.offerId)
|
|
137
|
+
&& VERIFIED_PAYMENT_DIGEST_PATTERN.test(evidence.buyerDigest || '');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function evidenceSupportsStage(stage, evidence = {}) {
|
|
141
|
+
if (stage === 'targeted') return true;
|
|
142
|
+
const allowed = SALES_STAGE_EVIDENCE_KINDS[stage] || [];
|
|
143
|
+
const containsPlaceholder = [evidence.source, evidence.reference]
|
|
144
|
+
.some((value) => /^REPLACE_WITH_/i.test(String(value || '').trim()));
|
|
145
|
+
const structurallySupported = allowed.includes(evidence.kind)
|
|
146
|
+
&& Boolean(evidence.source)
|
|
147
|
+
&& Boolean(evidence.reference)
|
|
148
|
+
&& !containsPlaceholder;
|
|
149
|
+
if (!structurallySupported) return false;
|
|
150
|
+
if (stage === 'paid') return evidence.kind === 'provider_payment' && isVerifiedProviderFinancialEvidence(evidence);
|
|
151
|
+
if (evidence.kind === 'provider_refund') return stage === 'lost' && isVerifiedProviderFinancialEvidence(evidence);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function validateKnownEvidence(evidence = {}) {
|
|
156
|
+
if (!evidence.kind || !SALES_EVIDENCE_KINDS.includes(evidence.kind)) {
|
|
157
|
+
throw new Error(`evidenceKind must be one of: ${SALES_EVIDENCE_KINDS.join(', ')}`);
|
|
158
|
+
}
|
|
159
|
+
if (!evidence.source) throw new Error('evidenceSource is required.');
|
|
160
|
+
if (!evidence.reference) throw new Error('evidenceRef is required.');
|
|
161
|
+
if (/^REPLACE_WITH_/i.test(evidence.source) || /^REPLACE_WITH_/i.test(evidence.reference)) {
|
|
162
|
+
throw new Error('Replace evidence placeholders with an actual provider or buyer receipt before advancing.');
|
|
163
|
+
}
|
|
164
|
+
if (['provider_payment', 'provider_refund'].includes(evidence.kind)) {
|
|
165
|
+
if (!isVerifiedProviderFinancialEvidence(evidence)) {
|
|
166
|
+
throw new Error('Provider payment/refund evidence must come from provider-payment reconciliation with a supported provider, live provider API source, verified=true, and sha256 evidence digest.');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return evidence;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function validateStageEvidence(stage, payload = {}) {
|
|
173
|
+
if (stage === 'targeted') return normalizeSalesEvidence();
|
|
174
|
+
const evidence = validateKnownEvidence(buildSalesEvidence(payload));
|
|
175
|
+
const allowed = SALES_STAGE_EVIDENCE_KINDS[stage] || [];
|
|
176
|
+
if (!allowed.includes(evidence.kind)) {
|
|
177
|
+
throw new Error(`stage ${stage} requires evidenceKind: ${allowed.join(' or ')}`);
|
|
178
|
+
}
|
|
179
|
+
if (stage === 'paid' && normalizeInteger(payload.amountCents, 0) <= 0) {
|
|
180
|
+
throw new Error('stage paid requires amountCents greater than 0.');
|
|
181
|
+
}
|
|
182
|
+
return evidence;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function slugify(value, fallback = 'lead') {
|
|
186
|
+
const normalized = normalizeText(value, 320);
|
|
187
|
+
if (!normalized) return fallback;
|
|
188
|
+
let slug = '';
|
|
189
|
+
let pendingSeparator = false;
|
|
190
|
+
for (const char of normalized.toLowerCase()) {
|
|
191
|
+
const code = char.codePointAt(0);
|
|
192
|
+
const alphaNumeric = (code >= 97 && code <= 122) || (code >= 48 && code <= 57);
|
|
193
|
+
if (alphaNumeric) {
|
|
194
|
+
if (pendingSeparator && slug) slug += '_';
|
|
195
|
+
slug += char;
|
|
196
|
+
pendingSeparator = false;
|
|
197
|
+
} else {
|
|
198
|
+
pendingSeparator = true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return slug || fallback;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function shortHash(value) {
|
|
205
|
+
return crypto.createHash('sha256').update(String(value || '')).digest('hex').slice(0, 10);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function buildSalesLeadId(entry = {}) {
|
|
209
|
+
const explicit = normalizeText(entry.leadId, 160);
|
|
210
|
+
if (explicit) return explicit;
|
|
211
|
+
|
|
212
|
+
const source = normalizeText(entry.source, 80) || 'manual';
|
|
213
|
+
const username = normalizeText(entry.contact?.username, 160)
|
|
214
|
+
|| normalizeText(entry.username, 160);
|
|
215
|
+
const repoName = normalizeText(entry.account?.repoName, 200)
|
|
216
|
+
|| normalizeText(entry.repoName, 200);
|
|
217
|
+
const accountName = normalizeText(entry.account?.name, 200)
|
|
218
|
+
|| normalizeText(entry.company, 200);
|
|
219
|
+
const stableKey = [source, username, repoName || accountName].filter(Boolean).join(':');
|
|
220
|
+
|
|
221
|
+
if (stableKey) {
|
|
222
|
+
return slugify(stableKey, `lead_${shortHash(JSON.stringify(entry))}`);
|
|
223
|
+
}
|
|
224
|
+
return `lead_${shortHash(JSON.stringify(entry))}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function buildHistoryEntry({
|
|
228
|
+
fromStage = null,
|
|
229
|
+
toStage,
|
|
230
|
+
at = null,
|
|
231
|
+
actor = null,
|
|
232
|
+
channel = null,
|
|
233
|
+
note = null,
|
|
234
|
+
url = null,
|
|
235
|
+
timestamp = null,
|
|
236
|
+
evidence = null,
|
|
237
|
+
} = {}) {
|
|
238
|
+
const resolvedTimestamp = timestamp || at || new Date().toISOString();
|
|
239
|
+
return {
|
|
240
|
+
fromStage: normalizeSalesStage(fromStage, null),
|
|
241
|
+
toStage: normalizeSalesStage(toStage, 'targeted'),
|
|
242
|
+
at: normalizeText(resolvedTimestamp, 64) || new Date().toISOString(),
|
|
243
|
+
actor: normalizeText(actor, 160),
|
|
244
|
+
channel: normalizeText(channel, 80),
|
|
245
|
+
note: normalizeText(note, 2000),
|
|
246
|
+
url: normalizeUrl(url),
|
|
247
|
+
evidence: normalizeSalesEvidence(evidence || {}),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function normalizeLeadHistory(entry, stage, updatedAt) {
|
|
252
|
+
const hasHistory = Array.isArray(entry.history) ? entry.history.length > 0 : false;
|
|
253
|
+
return hasHistory
|
|
254
|
+
? entry.history.map((item) => buildHistoryEntry(item))
|
|
255
|
+
: [buildHistoryEntry({
|
|
256
|
+
toStage: stage,
|
|
257
|
+
actor: entry.actor || 'sales-pipeline',
|
|
258
|
+
channel: entry.channel || entry.source || 'manual',
|
|
259
|
+
note: entry.note || 'Lead entered pipeline.',
|
|
260
|
+
timestamp: updatedAt,
|
|
261
|
+
evidence: entry.evidence,
|
|
262
|
+
})];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function normalizeLeadContact(entry = {}) {
|
|
266
|
+
const contact = entry.contact || {};
|
|
267
|
+
return {
|
|
268
|
+
username: normalizeText(contact.username, 160),
|
|
269
|
+
name: normalizeText(contact.name, 160),
|
|
270
|
+
email: normalizeText(contact.email, 320),
|
|
271
|
+
url: normalizeUrl(contact.url),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function normalizeLeadAccount(entry = {}) {
|
|
276
|
+
const account = entry.account || {};
|
|
277
|
+
return {
|
|
278
|
+
name: normalizeText(account.name, 200),
|
|
279
|
+
repoName: normalizeText(account.repoName, 200),
|
|
280
|
+
repoUrl: normalizeUrl(account.repoUrl),
|
|
281
|
+
description: normalizeText(account.description, 1000),
|
|
282
|
+
stars: normalizeInteger(account.stars, 0),
|
|
283
|
+
updatedAt: normalizeText(account.updatedAt, 64),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function normalizeLeadQualification(entry = {}) {
|
|
288
|
+
const qualification = entry.qualification || {};
|
|
289
|
+
return {
|
|
290
|
+
painHypothesis: normalizeText(qualification.painHypothesis, 1200),
|
|
291
|
+
concreteOffer: normalizeText(qualification.concreteOffer, 400)
|
|
292
|
+
|| 'I will harden one AI-agent workflow for you.',
|
|
293
|
+
proofTiming: normalizeText(qualification.proofTiming, 240)
|
|
294
|
+
|| 'Use proof pack only after the buyer confirms pain.',
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function normalizeLeadOutbound(entry = {}) {
|
|
299
|
+
const outbound = entry.outbound || {};
|
|
300
|
+
return {
|
|
301
|
+
draft: normalizeText(outbound.draft, 2000),
|
|
302
|
+
followUpDraft: normalizeText(outbound.followUpDraft, 2000),
|
|
303
|
+
cta: normalizeUrl(outbound.cta),
|
|
304
|
+
lastSentAt: normalizeText(outbound.lastSentAt, 64),
|
|
305
|
+
lastSentUrl: normalizeUrl(outbound.lastSentUrl),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function normalizeLeadRevenue(entry = {}) {
|
|
310
|
+
const revenue = entry.revenue || {};
|
|
311
|
+
return {
|
|
312
|
+
amountCents: Math.max(0, normalizeInteger(revenue.amountCents, 0)),
|
|
313
|
+
currency: normalizeText(revenue.currency, 16) || 'usd',
|
|
314
|
+
paidAt: normalizeText(revenue.paidAt, 64),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function normalizeLeadAttribution(entry = {}) {
|
|
319
|
+
const attribution = entry.attribution || {};
|
|
320
|
+
return {
|
|
321
|
+
sourceReport: normalizeText(attribution.sourceReport, 1000),
|
|
322
|
+
campaign: normalizeText(attribution.campaign, 160),
|
|
323
|
+
utmSource: normalizeText(attribution.utmSource, 120),
|
|
324
|
+
utmMedium: normalizeText(attribution.utmMedium, 120),
|
|
325
|
+
utmCampaign: normalizeText(attribution.utmCampaign, 160),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function sanitizeSalesLead(entry = {}) {
|
|
330
|
+
const history = Array.isArray(entry.history) ? entry.history : [];
|
|
331
|
+
const firstHistoryAt = normalizeText(history[0]?.at || history[0]?.timestamp, 64);
|
|
332
|
+
const lastHistoryAt = normalizeText(history.at(-1)?.at || history.at(-1)?.timestamp, 64);
|
|
333
|
+
const createdAt = normalizeText(entry.createdAt, 64)
|
|
334
|
+
|| firstHistoryAt
|
|
335
|
+
|| normalizeText(entry.outbound?.lastSentAt || entry.revenue?.paidAt, 64)
|
|
336
|
+
|| LEGACY_TIMESTAMP_FALLBACK;
|
|
337
|
+
const updatedAt = normalizeText(entry.updatedAt, 64)
|
|
338
|
+
|| lastHistoryAt
|
|
339
|
+
|| normalizeText(entry.revenue?.paidAt || entry.outbound?.lastSentAt, 64)
|
|
340
|
+
|| createdAt;
|
|
341
|
+
const stage = normalizeSalesStage(entry.stage, 'targeted');
|
|
342
|
+
const source = normalizeText(entry.source, 80) || 'manual';
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
leadId: buildSalesLeadId(entry),
|
|
346
|
+
createdAt,
|
|
347
|
+
updatedAt,
|
|
348
|
+
stage,
|
|
349
|
+
source,
|
|
350
|
+
channel: normalizeText(entry.channel, 80) || source,
|
|
351
|
+
offer: normalizeText(entry.offer, 120) || 'workflow_hardening_sprint',
|
|
352
|
+
contact: normalizeLeadContact(entry),
|
|
353
|
+
account: normalizeLeadAccount(entry),
|
|
354
|
+
qualification: normalizeLeadQualification(entry),
|
|
355
|
+
outbound: normalizeLeadOutbound(entry),
|
|
356
|
+
revenue: normalizeLeadRevenue(entry),
|
|
357
|
+
attribution: normalizeLeadAttribution(entry),
|
|
358
|
+
history: normalizeLeadHistory(entry, stage, updatedAt),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function findLinkedGitCommonRoot({ cwd = process.cwd() } = {}) {
|
|
363
|
+
let currentDir;
|
|
364
|
+
try {
|
|
365
|
+
currentDir = path.resolve(cwd);
|
|
366
|
+
} catch {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
while (true) {
|
|
371
|
+
const dotGitPath = path.join(currentDir, '.git');
|
|
372
|
+
try {
|
|
373
|
+
const stat = fs.statSync(dotGitPath);
|
|
374
|
+
if (stat.isDirectory()) return null;
|
|
375
|
+
if (stat.isFile()) {
|
|
376
|
+
const match = /^gitdir:\s*(.+)$/im.exec(fs.readFileSync(dotGitPath, 'utf8'));
|
|
377
|
+
if (!match) return null;
|
|
378
|
+
const gitDir = path.resolve(currentDir, match[1].trim());
|
|
379
|
+
const commonDirFile = path.join(gitDir, 'commondir');
|
|
380
|
+
const commonDir = fs.existsSync(commonDirFile)
|
|
381
|
+
? path.resolve(gitDir, fs.readFileSync(commonDirFile, 'utf8').trim())
|
|
382
|
+
: gitDir;
|
|
383
|
+
if (path.basename(commonDir) !== '.git' || !fs.existsSync(commonDir)) return null;
|
|
384
|
+
const relativeGitDir = path.relative(path.join(commonDir, 'worktrees'), gitDir);
|
|
385
|
+
if (
|
|
386
|
+
!relativeGitDir
|
|
387
|
+
|| relativeGitDir.startsWith('..')
|
|
388
|
+
|| path.isAbsolute(relativeGitDir)
|
|
389
|
+
) return null;
|
|
390
|
+
return path.dirname(commonDir);
|
|
391
|
+
}
|
|
392
|
+
} catch {
|
|
393
|
+
// Keep walking until a repository boundary is found.
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const parentDir = path.dirname(currentDir);
|
|
397
|
+
if (parentDir === currentDir) return null;
|
|
398
|
+
currentDir = parentDir;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function getSalesPipelinePath({
|
|
403
|
+
statePath = null,
|
|
404
|
+
feedbackDir = null,
|
|
405
|
+
cwd = process.cwd(),
|
|
406
|
+
env = process.env,
|
|
407
|
+
} = {}) {
|
|
408
|
+
if (statePath) return path.resolve(statePath);
|
|
409
|
+
if (feedbackDir) return path.join(path.resolve(feedbackDir), SALES_PIPELINE_FILE);
|
|
410
|
+
if (env[SALES_PIPELINE_PATH_ENV]) return path.resolve(env[SALES_PIPELINE_PATH_ENV]);
|
|
411
|
+
|
|
412
|
+
// Explicit runtime storage always wins. In particular, hosted Railway
|
|
413
|
+
// deployments must keep using their mounted feedback volume.
|
|
414
|
+
if (
|
|
415
|
+
env.THUMBGATE_FEEDBACK_DIR
|
|
416
|
+
|| env.RAILWAY_VOLUME_MOUNT_PATH
|
|
417
|
+
|| env.THUMBGATE_PROJECT_DIR
|
|
418
|
+
|| env.CLAUDE_PROJECT_DIR
|
|
419
|
+
) {
|
|
420
|
+
return path.join(getFeedbackPaths({ cwd, env }).FEEDBACK_DIR, SALES_PIPELINE_FILE);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// A linked Git worktree is another view of the same commercial system, not
|
|
424
|
+
// a new business. Share its pipeline with the primary checkout so a release
|
|
425
|
+
// or repair worktree cannot silently report zero active buyers.
|
|
426
|
+
const commonRoot = findLinkedGitCommonRoot({ cwd });
|
|
427
|
+
if (commonRoot) return path.join(commonRoot, '.thumbgate', SALES_PIPELINE_FILE);
|
|
428
|
+
|
|
429
|
+
const baseDir = getFeedbackPaths({ cwd, env }).FEEDBACK_DIR;
|
|
430
|
+
return path.join(baseDir, SALES_PIPELINE_FILE);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function appendSalesLeadSnapshot(lead = {}, options = {}) {
|
|
434
|
+
const sanitized = sanitizeSalesLead(lead);
|
|
435
|
+
appendJsonl(getSalesPipelinePath(options), sanitized);
|
|
436
|
+
return sanitized;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function loadSalesLeadSnapshots(options = {}) {
|
|
440
|
+
return readJsonl(getSalesPipelinePath(options))
|
|
441
|
+
.map((entry) => {
|
|
442
|
+
try {
|
|
443
|
+
return sanitizeSalesLead(entry);
|
|
444
|
+
} catch {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
.filter(Boolean);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function loadSalesLeads(options = {}) {
|
|
452
|
+
const latestByLeadId = new Map();
|
|
453
|
+
for (const snapshot of loadSalesLeadSnapshots(options)) {
|
|
454
|
+
const existing = latestByLeadId.get(snapshot.leadId);
|
|
455
|
+
if (!existing || String(snapshot.updatedAt || '') >= String(existing.updatedAt || '')) {
|
|
456
|
+
latestByLeadId.set(snapshot.leadId, snapshot);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return Array.from(latestByLeadId.values())
|
|
460
|
+
.sort((a, b) => String(a.updatedAt || '').localeCompare(String(b.updatedAt || '')));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function buildLeadFromRevenueTarget(target = {}, { sourcePath = null } = {}) {
|
|
464
|
+
const username = normalizeText(target.username, 160);
|
|
465
|
+
const source = normalizeText(target.source, 80) || 'github';
|
|
466
|
+
const channel = normalizeText(target.channel, 80) || source;
|
|
467
|
+
const repoName = normalizeText(target.repoName, 200);
|
|
468
|
+
const repoUrl = normalizeUrl(target.repoUrl);
|
|
469
|
+
const contactUrl = normalizeUrl(target.contactUrl) || (username && source === 'github'
|
|
470
|
+
? `https://github.com/${username}`
|
|
471
|
+
: username && source === 'reddit'
|
|
472
|
+
? `https://www.reddit.com/user/${username}/`
|
|
473
|
+
: null);
|
|
474
|
+
return sanitizeSalesLead({
|
|
475
|
+
source,
|
|
476
|
+
channel,
|
|
477
|
+
stage: 'targeted',
|
|
478
|
+
offer: normalizeText(target.offer, 120) || 'workflow_hardening_sprint',
|
|
479
|
+
contact: {
|
|
480
|
+
username,
|
|
481
|
+
url: contactUrl,
|
|
482
|
+
},
|
|
483
|
+
account: {
|
|
484
|
+
name: normalizeText(target.accountName, 200) || username,
|
|
485
|
+
repoName,
|
|
486
|
+
repoUrl,
|
|
487
|
+
description: target.description,
|
|
488
|
+
stars: target.stars,
|
|
489
|
+
updatedAt: target.updatedAt,
|
|
490
|
+
},
|
|
491
|
+
qualification: {
|
|
492
|
+
painHypothesis: target.motionReason || target.description,
|
|
493
|
+
concreteOffer: 'I will harden one AI-agent workflow for you.',
|
|
494
|
+
proofTiming: target.proofPackTrigger || 'Use proof pack only after the buyer confirms pain.',
|
|
495
|
+
},
|
|
496
|
+
outbound: {
|
|
497
|
+
draft: target.firstTouchDraft || target.message,
|
|
498
|
+
followUpDraft: target.painConfirmedFollowUpDraft,
|
|
499
|
+
cta: target.cta,
|
|
500
|
+
},
|
|
501
|
+
attribution: {
|
|
502
|
+
sourceReport: sourcePath,
|
|
503
|
+
campaign: normalizeText(target.offer, 160) || 'workflow_hardening_sprint_outbound',
|
|
504
|
+
utmSource: source,
|
|
505
|
+
utmMedium: channel === 'reddit_dm' ? 'warm_outbound' : 'direct_outbound',
|
|
506
|
+
utmCampaign: normalizeText(target.offer, 160) || 'workflow_hardening_sprint',
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function importRevenueLoopReport(report = {}, options = {}) {
|
|
512
|
+
const existing = new Map(loadSalesLeads(options).map((lead) => [lead.leadId, lead]));
|
|
513
|
+
const targets = Array.isArray(report.targets) ? report.targets : [];
|
|
514
|
+
const imported = [];
|
|
515
|
+
const skipped = [];
|
|
516
|
+
|
|
517
|
+
for (const target of targets) {
|
|
518
|
+
const candidate = buildLeadFromRevenueTarget(target, { sourcePath: options.sourcePath || null });
|
|
519
|
+
if (existing.has(candidate.leadId)) {
|
|
520
|
+
skipped.push(candidate.leadId);
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
imported.push(appendSalesLeadSnapshot(candidate, options));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
imported,
|
|
528
|
+
skipped,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function addSalesLead(payload = {}, options = {}) {
|
|
533
|
+
const initialStage = normalizeSalesStage(payload.stage, 'targeted');
|
|
534
|
+
const initialEvidence = validateStageEvidence(initialStage, payload);
|
|
535
|
+
const initialAt = normalizeText(payload.timestamp, 64) || new Date().toISOString();
|
|
536
|
+
const lead = sanitizeSalesLead({
|
|
537
|
+
leadId: payload.leadId,
|
|
538
|
+
createdAt: initialAt,
|
|
539
|
+
updatedAt: initialAt,
|
|
540
|
+
source: payload.source || 'manual',
|
|
541
|
+
channel: payload.channel || payload.source || 'manual',
|
|
542
|
+
stage: initialStage,
|
|
543
|
+
offer: payload.offer || 'workflow_hardening_sprint',
|
|
544
|
+
contact: {
|
|
545
|
+
username: payload.username,
|
|
546
|
+
name: payload.name,
|
|
547
|
+
email: payload.email,
|
|
548
|
+
url: payload.contactUrl,
|
|
549
|
+
},
|
|
550
|
+
account: {
|
|
551
|
+
name: payload.account,
|
|
552
|
+
repoName: payload.repo,
|
|
553
|
+
repoUrl: payload.repoUrl,
|
|
554
|
+
description: payload.description,
|
|
555
|
+
stars: payload.stars,
|
|
556
|
+
},
|
|
557
|
+
qualification: {
|
|
558
|
+
painHypothesis: payload.pain || payload.description,
|
|
559
|
+
concreteOffer: payload.concreteOffer || 'I will harden one AI-agent workflow for you.',
|
|
560
|
+
proofTiming: payload.proofTiming || 'Use proof pack only after the buyer confirms pain.',
|
|
561
|
+
},
|
|
562
|
+
outbound: {
|
|
563
|
+
draft: payload.draft,
|
|
564
|
+
cta: payload.cta,
|
|
565
|
+
lastSentAt: initialEvidence.kind === 'platform_send_receipt' ? initialAt : null,
|
|
566
|
+
lastSentUrl: initialEvidence.kind === 'platform_send_receipt'
|
|
567
|
+
? normalizeUrl(payload.url) || initialEvidence.reference
|
|
568
|
+
: null,
|
|
569
|
+
},
|
|
570
|
+
revenue: {
|
|
571
|
+
amountCents: initialStage === 'paid' ? payload.amountCents : 0,
|
|
572
|
+
currency: payload.currency,
|
|
573
|
+
paidAt: initialStage === 'paid' ? initialAt : null,
|
|
574
|
+
},
|
|
575
|
+
attribution: {
|
|
576
|
+
campaign: payload.campaign || 'workflow_hardening_sprint_outbound',
|
|
577
|
+
utmSource: payload.utmSource || payload.source || 'manual',
|
|
578
|
+
utmMedium: payload.utmMedium || 'direct_outbound',
|
|
579
|
+
utmCampaign: payload.utmCampaign || 'workflow_hardening_sprint',
|
|
580
|
+
},
|
|
581
|
+
history: [buildHistoryEntry({
|
|
582
|
+
toStage: initialStage,
|
|
583
|
+
actor: payload.actor || 'sales-pipeline',
|
|
584
|
+
channel: payload.channel || payload.source || 'manual',
|
|
585
|
+
note: payload.note || 'Lead entered pipeline.',
|
|
586
|
+
url: payload.url,
|
|
587
|
+
timestamp: initialAt,
|
|
588
|
+
evidence: initialEvidence,
|
|
589
|
+
})],
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
const existing = loadSalesLeads(options).find((entry) => entry.leadId === lead.leadId);
|
|
593
|
+
if (existing && !payload.force) {
|
|
594
|
+
throw new Error(`Sales lead already exists: ${lead.leadId}`);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return appendSalesLeadSnapshot(lead, options);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function readRevenueLoopReport(sourcePath) {
|
|
601
|
+
const resolved = path.resolve(sourcePath || '');
|
|
602
|
+
const parsed = JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
|
603
|
+
return {
|
|
604
|
+
report: parsed,
|
|
605
|
+
sourcePath: resolved,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function validateStageTransition(currentStage, nextStage, { force = false } = {}) {
|
|
610
|
+
if (force || currentStage === nextStage) return;
|
|
611
|
+
const allowed = SALES_STAGE_TRANSITIONS[currentStage] || [];
|
|
612
|
+
if (!allowed.includes(nextStage)) {
|
|
613
|
+
throw new Error(`Invalid sales pipeline transition: ${currentStage} -> ${nextStage}`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function advanceSalesLead(payload = {}, options = {}) {
|
|
618
|
+
const leadId = normalizeText(payload.leadId || payload.lead, 160);
|
|
619
|
+
const nextStage = normalizeSalesStage(payload.stage, null);
|
|
620
|
+
if (!leadId) throw new Error('leadId is required.');
|
|
621
|
+
if (!nextStage) throw new Error(`stage must be one of: ${SALES_STAGE_FLOW.join(', ')}`);
|
|
622
|
+
|
|
623
|
+
const currentLead = loadSalesLeads(options).find((lead) => lead.leadId === leadId);
|
|
624
|
+
if (!currentLead) throw new Error(`Unknown sales lead: ${leadId}`);
|
|
625
|
+
validateStageTransition(currentLead.stage, nextStage, { force: Boolean(payload.force) });
|
|
626
|
+
const eventAt = normalizeText(payload.timestamp, 64) || new Date().toISOString();
|
|
627
|
+
const updatedAt = new Date().toISOString();
|
|
628
|
+
|
|
629
|
+
if (currentLead.stage === nextStage) {
|
|
630
|
+
const hasEventEvidence = Boolean(payload.evidenceKind || payload.evidence?.kind);
|
|
631
|
+
if (hasEventEvidence) {
|
|
632
|
+
const eventEvidence = validateKnownEvidence(buildSalesEvidence(payload));
|
|
633
|
+
const isSendReceipt = eventEvidence.kind === 'platform_send_receipt';
|
|
634
|
+
const isPaymentEvidence = eventEvidence.kind === 'provider_payment';
|
|
635
|
+
const evidenceAmount = normalizeInteger(payload.amountCents, currentLead.revenue.amountCents || 0);
|
|
636
|
+
if (currentLead.stage === 'paid' && isPaymentEvidence && evidenceAmount <= 0) {
|
|
637
|
+
throw new Error('stage paid requires amountCents greater than 0.');
|
|
638
|
+
}
|
|
639
|
+
const updatedLead = appendSalesLeadSnapshot({
|
|
640
|
+
...currentLead,
|
|
641
|
+
updatedAt,
|
|
642
|
+
outbound: {
|
|
643
|
+
...currentLead.outbound,
|
|
644
|
+
lastSentAt: isSendReceipt ? eventAt : currentLead.outbound.lastSentAt,
|
|
645
|
+
lastSentUrl: isSendReceipt
|
|
646
|
+
? normalizeUrl(payload.url) || eventEvidence.reference || currentLead.outbound.lastSentUrl
|
|
647
|
+
: currentLead.outbound.lastSentUrl,
|
|
648
|
+
},
|
|
649
|
+
revenue: {
|
|
650
|
+
...currentLead.revenue,
|
|
651
|
+
amountCents: isPaymentEvidence ? evidenceAmount : currentLead.revenue.amountCents,
|
|
652
|
+
currency: isPaymentEvidence
|
|
653
|
+
? normalizeText(payload.currency, 16) || currentLead.revenue.currency
|
|
654
|
+
: currentLead.revenue.currency,
|
|
655
|
+
paidAt: isPaymentEvidence ? (currentLead.revenue.paidAt || eventAt) : currentLead.revenue.paidAt,
|
|
656
|
+
},
|
|
657
|
+
history: currentLead.history.concat(buildHistoryEntry({
|
|
658
|
+
fromStage: currentLead.stage,
|
|
659
|
+
toStage: nextStage,
|
|
660
|
+
actor: payload.actor || 'operator',
|
|
661
|
+
channel: payload.channel || currentLead.channel,
|
|
662
|
+
note: payload.note || 'Recorded same-stage sales evidence.',
|
|
663
|
+
url: payload.url,
|
|
664
|
+
timestamp: eventAt,
|
|
665
|
+
evidence: eventEvidence,
|
|
666
|
+
})),
|
|
667
|
+
}, options);
|
|
668
|
+
return {
|
|
669
|
+
lead: updatedLead,
|
|
670
|
+
unchanged: false,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
if (payload.note || payload.url || payload.evidenceSource || payload.evidenceRef) {
|
|
674
|
+
throw new Error('same-stage updates require evidenceKind, evidenceSource, and evidenceRef.');
|
|
675
|
+
}
|
|
676
|
+
return {
|
|
677
|
+
lead: currentLead,
|
|
678
|
+
unchanged: true,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const stageEvidence = validateStageEvidence(nextStage, payload);
|
|
683
|
+
const revenueAmount = normalizeInteger(payload.amountCents, currentLead.revenue.amountCents || 0);
|
|
684
|
+
const isFullRefund = nextStage === 'lost' && stageEvidence.kind === 'provider_refund';
|
|
685
|
+
const updatedLead = appendSalesLeadSnapshot({
|
|
686
|
+
...currentLead,
|
|
687
|
+
updatedAt,
|
|
688
|
+
stage: nextStage,
|
|
689
|
+
outbound: {
|
|
690
|
+
...currentLead.outbound,
|
|
691
|
+
lastSentAt: nextStage === 'contacted' ? eventAt : currentLead.outbound.lastSentAt,
|
|
692
|
+
lastSentUrl: nextStage === 'contacted'
|
|
693
|
+
? normalizeUrl(payload.url) || currentLead.outbound.lastSentUrl
|
|
694
|
+
: currentLead.outbound.lastSentUrl,
|
|
695
|
+
},
|
|
696
|
+
revenue: {
|
|
697
|
+
...currentLead.revenue,
|
|
698
|
+
amountCents: nextStage === 'paid' ? revenueAmount : (isFullRefund ? 0 : currentLead.revenue.amountCents),
|
|
699
|
+
currency: normalizeText(payload.currency, 16) || currentLead.revenue.currency,
|
|
700
|
+
paidAt: nextStage === 'paid' ? eventAt : currentLead.revenue.paidAt,
|
|
701
|
+
},
|
|
702
|
+
history: currentLead.history.concat(buildHistoryEntry({
|
|
703
|
+
fromStage: currentLead.stage,
|
|
704
|
+
toStage: nextStage,
|
|
705
|
+
actor: payload.actor || 'operator',
|
|
706
|
+
channel: payload.channel || currentLead.channel,
|
|
707
|
+
note: payload.note,
|
|
708
|
+
url: payload.url,
|
|
709
|
+
timestamp: eventAt,
|
|
710
|
+
evidence: stageEvidence,
|
|
711
|
+
})),
|
|
712
|
+
}, options);
|
|
713
|
+
|
|
714
|
+
return {
|
|
715
|
+
lead: updatedLead,
|
|
716
|
+
unchanged: false,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function evaluateLeadEvidenceAtStage(lead = {}, requestedStage = lead.stage) {
|
|
721
|
+
const stage = normalizeSalesStage(requestedStage, 'targeted');
|
|
722
|
+
if (stage === 'targeted') {
|
|
723
|
+
return {
|
|
724
|
+
stage,
|
|
725
|
+
verified: true,
|
|
726
|
+
evidence: null,
|
|
727
|
+
reason: null,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const history = Array.isArray(lead.history) ? lead.history : [];
|
|
732
|
+
const supportingEvent = history
|
|
733
|
+
.slice()
|
|
734
|
+
.reverse()
|
|
735
|
+
.find((event) => event.toStage === stage && evidenceSupportsStage(stage, event.evidence));
|
|
736
|
+
if (!supportingEvent) {
|
|
737
|
+
return {
|
|
738
|
+
stage,
|
|
739
|
+
verified: false,
|
|
740
|
+
evidence: null,
|
|
741
|
+
reason: `No stage-appropriate evidence for ${stage}.`,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
if (stage === 'paid' && normalizeInteger(lead.revenue?.amountCents, 0) <= 0) {
|
|
745
|
+
return {
|
|
746
|
+
stage,
|
|
747
|
+
verified: false,
|
|
748
|
+
evidence: supportingEvent.evidence,
|
|
749
|
+
reason: 'Paid stage has no positive amountCents.',
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return {
|
|
754
|
+
stage,
|
|
755
|
+
verified: true,
|
|
756
|
+
evidence: supportingEvent.evidence,
|
|
757
|
+
evidenceAt: supportingEvent.at,
|
|
758
|
+
reason: null,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function evaluateLeadStageEvidence(lead = {}) {
|
|
763
|
+
return evaluateLeadEvidenceAtStage(lead, lead.stage);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function auditSalesPipeline(leads = []) {
|
|
767
|
+
const issues = [];
|
|
768
|
+
let verified = 0;
|
|
769
|
+
for (const lead of leads) {
|
|
770
|
+
const result = evaluateLeadStageEvidence(lead);
|
|
771
|
+
if (result.verified) {
|
|
772
|
+
verified += 1;
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
issues.push({
|
|
776
|
+
leadId: lead.leadId,
|
|
777
|
+
stage: lead.stage,
|
|
778
|
+
code: 'unverified_stage_evidence',
|
|
779
|
+
reason: result.reason,
|
|
780
|
+
allowedEvidenceKinds: SALES_STAGE_EVIDENCE_KINDS[lead.stage] || [],
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
return {
|
|
784
|
+
ok: issues.length === 0,
|
|
785
|
+
total: leads.length,
|
|
786
|
+
verified,
|
|
787
|
+
unverified: issues.length,
|
|
788
|
+
issues,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function summarizeSalesPipeline(leads = []) {
|
|
793
|
+
const byStage = Object.fromEntries(SALES_STAGE_FLOW.map((stage) => [stage, 0]));
|
|
794
|
+
const verifiedByStage = Object.fromEntries(SALES_STAGE_FLOW.map((stage) => [stage, 0]));
|
|
795
|
+
const unverifiedByStage = Object.fromEntries(SALES_STAGE_FLOW.map((stage) => [stage, 0]));
|
|
796
|
+
let bookedRevenueCents = 0;
|
|
797
|
+
for (const lead of leads) {
|
|
798
|
+
byStage[lead.stage] = (byStage[lead.stage] || 0) + 1;
|
|
799
|
+
const stageEvidence = evaluateLeadStageEvidence(lead);
|
|
800
|
+
const evidenceBucket = stageEvidence.verified ? verifiedByStage : unverifiedByStage;
|
|
801
|
+
evidenceBucket[lead.stage] = (evidenceBucket[lead.stage] || 0) + 1;
|
|
802
|
+
if (lead.stage === 'paid' && stageEvidence.verified) {
|
|
803
|
+
bookedRevenueCents += lead.revenue.amountCents || 0;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const countAtOrBeyond = (stageCounts, stage) => {
|
|
808
|
+
const startIndex = SALES_STAGE_FLOW.indexOf(stage);
|
|
809
|
+
return SALES_STAGE_FLOW.slice(startIndex)
|
|
810
|
+
.filter((candidate) => candidate !== 'lost')
|
|
811
|
+
.reduce((sum, candidate) => sum + (stageCounts[candidate] || 0), 0);
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
const rawContacted = countAtOrBeyond(byStage, 'contacted');
|
|
815
|
+
const rawReplies = countAtOrBeyond(byStage, 'replied');
|
|
816
|
+
const rawCallsBooked = countAtOrBeyond(byStage, 'call_booked');
|
|
817
|
+
|
|
818
|
+
return {
|
|
819
|
+
total: leads.length,
|
|
820
|
+
byStage,
|
|
821
|
+
verifiedByStage,
|
|
822
|
+
unverifiedByStage,
|
|
823
|
+
evidenceGapCount: Object.values(unverifiedByStage).reduce((sum, count) => sum + count, 0),
|
|
824
|
+
active: leads.filter((lead) => lead.stage !== 'paid' && lead.stage !== 'lost').length,
|
|
825
|
+
contacted: leads.filter((lead) => evaluateLeadEvidenceAtStage(lead, 'contacted').verified).length,
|
|
826
|
+
rawContacted,
|
|
827
|
+
replies: leads.filter((lead) => evaluateLeadEvidenceAtStage(lead, 'replied').verified).length,
|
|
828
|
+
rawReplies,
|
|
829
|
+
callsBooked: leads.filter((lead) => evaluateLeadEvidenceAtStage(lead, 'call_booked').verified).length,
|
|
830
|
+
rawCallsBooked,
|
|
831
|
+
paid: verifiedByStage.paid,
|
|
832
|
+
rawPaid: byStage.paid,
|
|
833
|
+
bookedRevenueCents,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function formatLeadContact(contact = {}) {
|
|
838
|
+
return contact.username ? `@${contact.username}` : (contact.email || 'n/a');
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function renderLeadQueueEntry(lead) {
|
|
842
|
+
const repo = lead.account.repoUrl || lead.account.repoName || lead.account.name || 'n/a';
|
|
843
|
+
const stageEvidence = evaluateLeadStageEvidence(lead);
|
|
844
|
+
return [
|
|
845
|
+
`### ${lead.leadId}`,
|
|
846
|
+
`- Stage: ${lead.stage}`,
|
|
847
|
+
`- Offer: ${lead.offer}`,
|
|
848
|
+
`- Repo/account: ${repo}`,
|
|
849
|
+
`- Contact: ${formatLeadContact(lead.contact)}`,
|
|
850
|
+
`- Stage evidence: ${stageEvidence.verified ? 'verified' : `unverified — ${stageEvidence.reason}`}`,
|
|
851
|
+
`- Concrete offer: ${lead.qualification.concreteOffer}`,
|
|
852
|
+
`- Proof rule: ${lead.qualification.proofTiming}`,
|
|
853
|
+
`- Outreach draft: ${lead.outbound.draft || 'n/a'}`,
|
|
854
|
+
`- Pain-confirmed follow-up: ${lead.outbound.followUpDraft || 'n/a'}`,
|
|
855
|
+
'',
|
|
856
|
+
];
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function renderSalesPipelineMarkdown({ leads = [], generatedAt = new Date().toISOString() } = {}) {
|
|
860
|
+
const summary = summarizeSalesPipeline(leads);
|
|
861
|
+
const leadQueueLines = leads.length
|
|
862
|
+
? leads.flatMap(renderLeadQueueEntry)
|
|
863
|
+
: ['- No leads tracked yet. Import a GTM revenue loop JSON report first.'];
|
|
864
|
+
const lines = [
|
|
865
|
+
'# Sales Pipeline',
|
|
866
|
+
'',
|
|
867
|
+
`Updated: ${generatedAt}`,
|
|
868
|
+
'',
|
|
869
|
+
'This is the first-dollar truth table. Posts are not sales; only stage movement counts.',
|
|
870
|
+
'',
|
|
871
|
+
'## Summary',
|
|
872
|
+
`- Total leads: ${summary.total}`,
|
|
873
|
+
`- Active leads: ${summary.active}`,
|
|
874
|
+
`- Verified contacted: ${summary.contacted} (raw stage-derived: ${summary.rawContacted})`,
|
|
875
|
+
`- Verified replied: ${summary.replies} (raw stage-derived: ${summary.rawReplies})`,
|
|
876
|
+
`- Verified calls booked: ${summary.callsBooked} (raw stage-derived: ${summary.rawCallsBooked})`,
|
|
877
|
+
`- Verified paid: ${summary.paid} (raw stage-derived: ${summary.rawPaid})`,
|
|
878
|
+
`- Verified booked revenue: $${(summary.bookedRevenueCents / 100).toFixed(2)}`,
|
|
879
|
+
'',
|
|
880
|
+
'## Stage Counts',
|
|
881
|
+
...SALES_STAGE_FLOW.map((stage) => `- ${stage}: ${summary.byStage[stage] || 0}`),
|
|
882
|
+
'',
|
|
883
|
+
'## Verified Stage Counts',
|
|
884
|
+
...SALES_STAGE_FLOW.map((stage) => `- ${stage}: ${summary.verifiedByStage[stage] || 0}`),
|
|
885
|
+
`- Evidence gaps: ${summary.evidenceGapCount}`,
|
|
886
|
+
'',
|
|
887
|
+
'## Lead Queue',
|
|
888
|
+
...leadQueueLines,
|
|
889
|
+
];
|
|
890
|
+
return `${lines.join('\n').trim()}\n`;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function writeSalesPipelineReport({ outPath, leads }) {
|
|
894
|
+
if (!outPath) return null;
|
|
895
|
+
const resolved = path.resolve(outPath);
|
|
896
|
+
ensureParentDir(resolved);
|
|
897
|
+
fs.writeFileSync(resolved, renderSalesPipelineMarkdown({ leads }), 'utf8');
|
|
898
|
+
return resolved;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function parseArgs(argv = []) {
|
|
902
|
+
const firstArg = argv[0];
|
|
903
|
+
const hasCommand = firstArg ? !firstArg.startsWith('--') : false;
|
|
904
|
+
const rawCommand = hasCommand ? firstArg : 'report';
|
|
905
|
+
const command = rawCommand === 'status' || rawCommand === 'summary'
|
|
906
|
+
? 'report'
|
|
907
|
+
: rawCommand;
|
|
908
|
+
const args = hasCommand ? argv.slice(1) : argv;
|
|
909
|
+
const options = { command };
|
|
910
|
+
|
|
911
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
912
|
+
const arg = args[index];
|
|
913
|
+
if (!arg.startsWith('--')) continue;
|
|
914
|
+
const eqIndex = arg.indexOf('=', 2);
|
|
915
|
+
const rawKey = eqIndex === -1 ? arg.slice(2) : arg.slice(2, eqIndex);
|
|
916
|
+
const key = rawKey.replaceAll(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
917
|
+
if (eqIndex !== -1) {
|
|
918
|
+
options[key] = arg.slice(eqIndex + 1);
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
const nextArg = args[index + 1];
|
|
922
|
+
if (nextArg && !nextArg.startsWith('--')) {
|
|
923
|
+
options[key] = nextArg;
|
|
924
|
+
index += 1;
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
options[key] = true;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
return options;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function runCli(argv = process.argv.slice(2)) {
|
|
934
|
+
const options = parseArgs(argv);
|
|
935
|
+
const stateOptions = {
|
|
936
|
+
statePath: options.state,
|
|
937
|
+
feedbackDir: options.feedbackDir,
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
switch (options.command) {
|
|
941
|
+
case 'import':
|
|
942
|
+
case 'import-gtm': {
|
|
943
|
+
if (!options.source) throw new Error('--source is required for import.');
|
|
944
|
+
const { report, sourcePath } = readRevenueLoopReport(options.source);
|
|
945
|
+
const result = importRevenueLoopReport(report, { ...stateOptions, sourcePath });
|
|
946
|
+
const leads = loadSalesLeads(stateOptions);
|
|
947
|
+
const reportPath = writeSalesPipelineReport({ outPath: options.out, leads });
|
|
948
|
+
return {
|
|
949
|
+
command: options.command,
|
|
950
|
+
imported: result.imported.length,
|
|
951
|
+
skipped: result.skipped.length,
|
|
952
|
+
statePath: getSalesPipelinePath(stateOptions),
|
|
953
|
+
reportPath,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
case 'advance': {
|
|
958
|
+
if (!options.lead && !options.leadId) throw new Error('leadId is required.');
|
|
959
|
+
if (options.stage === 'paid') {
|
|
960
|
+
throw new Error('The sales:pipeline CLI cannot mark a lead paid. Use sales:reconcile-payment with a live provider payment ID.');
|
|
961
|
+
}
|
|
962
|
+
const result = advanceSalesLead({
|
|
963
|
+
leadId: options.lead || options.leadId,
|
|
964
|
+
stage: options.stage,
|
|
965
|
+
actor: options.actor,
|
|
966
|
+
channel: options.channel,
|
|
967
|
+
note: options.note,
|
|
968
|
+
url: options.url,
|
|
969
|
+
amountCents: options.amountCents,
|
|
970
|
+
currency: options.currency,
|
|
971
|
+
evidenceKind: options.evidenceKind,
|
|
972
|
+
evidenceSource: options.evidenceSource,
|
|
973
|
+
evidenceRef: options.evidenceRef,
|
|
974
|
+
timestamp: options.timestamp,
|
|
975
|
+
force: options.force,
|
|
976
|
+
}, stateOptions);
|
|
977
|
+
const leads = loadSalesLeads(stateOptions);
|
|
978
|
+
const reportPath = writeSalesPipelineReport({ outPath: options.out, leads });
|
|
979
|
+
return {
|
|
980
|
+
command: options.command,
|
|
981
|
+
leadId: result.lead.leadId,
|
|
982
|
+
stage: result.lead.stage,
|
|
983
|
+
unchanged: result.unchanged,
|
|
984
|
+
statePath: getSalesPipelinePath(stateOptions),
|
|
985
|
+
reportPath,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
case 'add': {
|
|
990
|
+
if (options.stage === 'paid') {
|
|
991
|
+
throw new Error('The sales:pipeline CLI cannot add a paid lead. Add the lead first, then use sales:reconcile-payment with a live provider payment ID.');
|
|
992
|
+
}
|
|
993
|
+
const lead = addSalesLead({
|
|
994
|
+
leadId: options.lead || options.leadId,
|
|
995
|
+
source: options.source,
|
|
996
|
+
channel: options.channel,
|
|
997
|
+
stage: options.stage,
|
|
998
|
+
offer: options.offer,
|
|
999
|
+
username: options.username,
|
|
1000
|
+
name: options.name,
|
|
1001
|
+
email: options.email,
|
|
1002
|
+
contactUrl: options.contactUrl,
|
|
1003
|
+
account: options.account,
|
|
1004
|
+
repo: options.repo,
|
|
1005
|
+
repoUrl: options.repoUrl,
|
|
1006
|
+
description: options.description,
|
|
1007
|
+
stars: options.stars,
|
|
1008
|
+
pain: options.pain,
|
|
1009
|
+
concreteOffer: options.concreteOffer,
|
|
1010
|
+
proofTiming: options.proofTiming,
|
|
1011
|
+
draft: options.draft,
|
|
1012
|
+
cta: options.cta,
|
|
1013
|
+
campaign: options.campaign,
|
|
1014
|
+
utmSource: options.utmSource,
|
|
1015
|
+
utmMedium: options.utmMedium,
|
|
1016
|
+
utmCampaign: options.utmCampaign,
|
|
1017
|
+
actor: options.actor,
|
|
1018
|
+
note: options.note,
|
|
1019
|
+
url: options.url,
|
|
1020
|
+
evidenceKind: options.evidenceKind,
|
|
1021
|
+
evidenceSource: options.evidenceSource,
|
|
1022
|
+
evidenceRef: options.evidenceRef,
|
|
1023
|
+
amountCents: options.amountCents,
|
|
1024
|
+
currency: options.currency,
|
|
1025
|
+
timestamp: options.timestamp,
|
|
1026
|
+
force: options.force,
|
|
1027
|
+
}, stateOptions);
|
|
1028
|
+
const leads = loadSalesLeads(stateOptions);
|
|
1029
|
+
const reportPath = writeSalesPipelineReport({ outPath: options.out, leads });
|
|
1030
|
+
return {
|
|
1031
|
+
command: options.command,
|
|
1032
|
+
leadId: lead.leadId,
|
|
1033
|
+
stage: lead.stage,
|
|
1034
|
+
statePath: getSalesPipelinePath(stateOptions),
|
|
1035
|
+
reportPath,
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
case 'report': {
|
|
1040
|
+
const leads = loadSalesLeads(stateOptions);
|
|
1041
|
+
const reportPath = writeSalesPipelineReport({ outPath: options.out, leads });
|
|
1042
|
+
return {
|
|
1043
|
+
command: options.command,
|
|
1044
|
+
summary: summarizeSalesPipeline(leads),
|
|
1045
|
+
statePath: getSalesPipelinePath(stateOptions),
|
|
1046
|
+
reportPath,
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
case 'audit': {
|
|
1051
|
+
const leads = loadSalesLeads(stateOptions);
|
|
1052
|
+
return {
|
|
1053
|
+
command: options.command,
|
|
1054
|
+
audit: auditSalesPipeline(leads),
|
|
1055
|
+
statePath: getSalesPipelinePath(stateOptions),
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
default:
|
|
1060
|
+
throw new Error(`Unknown sales pipeline command: ${options.command}`);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function isCliInvocation(argv = process.argv) {
|
|
1065
|
+
const invokedPath = argv[1];
|
|
1066
|
+
if (!invokedPath) return false;
|
|
1067
|
+
try {
|
|
1068
|
+
return fs.realpathSync(path.resolve(invokedPath)) === fs.realpathSync(__filename);
|
|
1069
|
+
} catch {
|
|
1070
|
+
return path.resolve(invokedPath) === path.resolve(__filename);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
if (isCliInvocation()) {
|
|
1075
|
+
try {
|
|
1076
|
+
const result = runCli();
|
|
1077
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1078
|
+
} catch (err) {
|
|
1079
|
+
console.error(err?.message || err);
|
|
1080
|
+
process.exit(1);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
module.exports = {
|
|
1085
|
+
SALES_PIPELINE_FILE,
|
|
1086
|
+
SALES_PIPELINE_PATH_ENV,
|
|
1087
|
+
SALES_EVIDENCE_KINDS,
|
|
1088
|
+
SALES_STAGE_FLOW,
|
|
1089
|
+
SALES_STAGE_EVIDENCE_KINDS,
|
|
1090
|
+
SALES_STAGE_TRANSITIONS,
|
|
1091
|
+
VERIFIED_PAYMENT_DIGEST_PATTERN,
|
|
1092
|
+
VERIFIED_PAYMENT_PROVIDERS,
|
|
1093
|
+
VERIFIED_PAYMENT_SOURCE_PATTERN,
|
|
1094
|
+
LEGACY_TIMESTAMP_FALLBACK,
|
|
1095
|
+
addSalesLead,
|
|
1096
|
+
advanceSalesLead,
|
|
1097
|
+
appendSalesLeadSnapshot,
|
|
1098
|
+
auditSalesPipeline,
|
|
1099
|
+
buildSalesEvidence,
|
|
1100
|
+
buildLeadFromRevenueTarget,
|
|
1101
|
+
evaluateLeadStageEvidence,
|
|
1102
|
+
evaluateLeadEvidenceAtStage,
|
|
1103
|
+
findLinkedGitCommonRoot,
|
|
1104
|
+
getSalesPipelinePath,
|
|
1105
|
+
importRevenueLoopReport,
|
|
1106
|
+
isVerifiedProviderFinancialEvidence,
|
|
1107
|
+
isCliInvocation,
|
|
1108
|
+
loadSalesLeads,
|
|
1109
|
+
loadSalesLeadSnapshots,
|
|
1110
|
+
normalizeSalesEvidence,
|
|
1111
|
+
normalizeSalesStage,
|
|
1112
|
+
parseArgs,
|
|
1113
|
+
renderSalesPipelineMarkdown,
|
|
1114
|
+
runCli,
|
|
1115
|
+
sanitizeSalesLead,
|
|
1116
|
+
summarizeSalesPipeline,
|
|
1117
|
+
};
|