thumbgate 1.4.6 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +241 -228
- package/adapters/README.md +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +4 -2
- package/adapters/mcp/server-stdio.js +34 -3
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +21 -8
- package/bin/postinstall.js +25 -17
- package/config/evals/agent-safety-eval.json +131 -0
- package/config/github-about.json +5 -2
- package/config/specs/agent-safety.json +79 -0
- package/package.json +44 -8
- package/public/compare.html +3 -3
- package/public/guide.html +2 -2
- package/public/index.html +255 -94
- package/public/lessons.html +2 -2
- package/scripts/auto-wire-hooks.js +77 -27
- package/scripts/billing.js +8 -2
- package/scripts/bot-detection.js +165 -0
- package/scripts/cli-feedback.js +6 -2
- package/scripts/commercial-offer.js +5 -5
- package/scripts/dashboard.js +152 -2
- package/scripts/decision-trace.js +354 -0
- package/scripts/feedback-loop.js +4 -8
- package/scripts/rate-limiter.js +77 -24
- package/scripts/sales-pipeline.js +681 -0
- package/scripts/session-episode-store.js +329 -0
- package/scripts/session-health-sensor.js +242 -0
- package/scripts/spec-gate.js +362 -0
- package/scripts/statusline.sh +6 -9
- package/skills/thumbgate/SKILL.md +1 -1
- package/src/api/server.js +368 -12
|
@@ -0,0 +1,681 @@
|
|
|
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_STAGE_FLOW = [
|
|
13
|
+
'targeted',
|
|
14
|
+
'contacted',
|
|
15
|
+
'replied',
|
|
16
|
+
'call_booked',
|
|
17
|
+
'checkout_started',
|
|
18
|
+
'sprint_intake',
|
|
19
|
+
'paid',
|
|
20
|
+
'lost',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const SALES_STAGE_TRANSITIONS = {
|
|
24
|
+
targeted: ['contacted', 'lost'],
|
|
25
|
+
contacted: ['replied', 'lost'],
|
|
26
|
+
replied: ['call_booked', 'checkout_started', 'sprint_intake', 'lost'],
|
|
27
|
+
call_booked: ['checkout_started', 'sprint_intake', 'paid', 'lost'],
|
|
28
|
+
checkout_started: ['paid', 'lost'],
|
|
29
|
+
sprint_intake: ['paid', 'lost'],
|
|
30
|
+
paid: [],
|
|
31
|
+
lost: [],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function normalizeText(value, maxLength = 1000) {
|
|
35
|
+
if (value === undefined || value === null) return null;
|
|
36
|
+
const text = String(value).trim();
|
|
37
|
+
if (!text) return null;
|
|
38
|
+
return text.slice(0, maxLength);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeUrl(value) {
|
|
42
|
+
const text = normalizeText(value, 1000);
|
|
43
|
+
if (!text) return null;
|
|
44
|
+
try {
|
|
45
|
+
return new URL(text).toString();
|
|
46
|
+
} catch {
|
|
47
|
+
return text;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeSalesStage(value, fallback = null) {
|
|
52
|
+
const normalized = normalizeText(value, 80);
|
|
53
|
+
if (!normalized) return fallback;
|
|
54
|
+
return SALES_STAGE_FLOW.includes(normalized) ? normalized : fallback;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeInteger(value, fallback = 0) {
|
|
58
|
+
const parsed = Number.parseInt(String(value || '').trim(), 10);
|
|
59
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function slugify(value, fallback = 'lead') {
|
|
63
|
+
const normalized = normalizeText(value, 320);
|
|
64
|
+
if (!normalized) return fallback;
|
|
65
|
+
let slug = '';
|
|
66
|
+
let pendingSeparator = false;
|
|
67
|
+
for (const char of normalized.toLowerCase()) {
|
|
68
|
+
const code = char.codePointAt(0);
|
|
69
|
+
const alphaNumeric = (code >= 97 && code <= 122) || (code >= 48 && code <= 57);
|
|
70
|
+
if (alphaNumeric) {
|
|
71
|
+
if (pendingSeparator && slug) slug += '_';
|
|
72
|
+
slug += char;
|
|
73
|
+
pendingSeparator = false;
|
|
74
|
+
} else {
|
|
75
|
+
pendingSeparator = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return slug || fallback;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function shortHash(value) {
|
|
82
|
+
return crypto.createHash('sha256').update(String(value || '')).digest('hex').slice(0, 10);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildSalesLeadId(entry = {}) {
|
|
86
|
+
const explicit = normalizeText(entry.leadId, 160);
|
|
87
|
+
if (explicit) return explicit;
|
|
88
|
+
|
|
89
|
+
const source = normalizeText(entry.source, 80) || 'manual';
|
|
90
|
+
const username = normalizeText(entry.contact?.username, 160)
|
|
91
|
+
|| normalizeText(entry.username, 160);
|
|
92
|
+
const repoName = normalizeText(entry.account?.repoName, 200)
|
|
93
|
+
|| normalizeText(entry.repoName, 200);
|
|
94
|
+
const accountName = normalizeText(entry.account?.name, 200)
|
|
95
|
+
|| normalizeText(entry.company, 200);
|
|
96
|
+
const stableKey = [source, username, repoName || accountName].filter(Boolean).join(':');
|
|
97
|
+
|
|
98
|
+
if (stableKey) {
|
|
99
|
+
return slugify(stableKey, `lead_${shortHash(JSON.stringify(entry))}`);
|
|
100
|
+
}
|
|
101
|
+
return `lead_${shortHash(JSON.stringify(entry))}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildHistoryEntry({
|
|
105
|
+
fromStage = null,
|
|
106
|
+
toStage,
|
|
107
|
+
actor = null,
|
|
108
|
+
channel = null,
|
|
109
|
+
note = null,
|
|
110
|
+
url = null,
|
|
111
|
+
timestamp = new Date().toISOString(),
|
|
112
|
+
} = {}) {
|
|
113
|
+
return {
|
|
114
|
+
fromStage: normalizeSalesStage(fromStage, null),
|
|
115
|
+
toStage: normalizeSalesStage(toStage, 'targeted'),
|
|
116
|
+
at: normalizeText(timestamp, 64) || new Date().toISOString(),
|
|
117
|
+
actor: normalizeText(actor, 160),
|
|
118
|
+
channel: normalizeText(channel, 80),
|
|
119
|
+
note: normalizeText(note, 2000),
|
|
120
|
+
url: normalizeUrl(url),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeLeadHistory(entry, stage, updatedAt) {
|
|
125
|
+
const hasHistory = Array.isArray(entry.history) ? entry.history.length > 0 : false;
|
|
126
|
+
return hasHistory
|
|
127
|
+
? entry.history.map((item) => buildHistoryEntry(item))
|
|
128
|
+
: [buildHistoryEntry({
|
|
129
|
+
toStage: stage,
|
|
130
|
+
actor: entry.actor || 'sales-pipeline',
|
|
131
|
+
channel: entry.channel || entry.source || 'manual',
|
|
132
|
+
note: entry.note || 'Lead entered pipeline.',
|
|
133
|
+
timestamp: updatedAt,
|
|
134
|
+
})];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeLeadContact(entry = {}) {
|
|
138
|
+
const contact = entry.contact || {};
|
|
139
|
+
return {
|
|
140
|
+
username: normalizeText(contact.username, 160),
|
|
141
|
+
name: normalizeText(contact.name, 160),
|
|
142
|
+
email: normalizeText(contact.email, 320),
|
|
143
|
+
url: normalizeUrl(contact.url),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function normalizeLeadAccount(entry = {}) {
|
|
148
|
+
const account = entry.account || {};
|
|
149
|
+
return {
|
|
150
|
+
name: normalizeText(account.name, 200),
|
|
151
|
+
repoName: normalizeText(account.repoName, 200),
|
|
152
|
+
repoUrl: normalizeUrl(account.repoUrl),
|
|
153
|
+
description: normalizeText(account.description, 1000),
|
|
154
|
+
stars: normalizeInteger(account.stars, 0),
|
|
155
|
+
updatedAt: normalizeText(account.updatedAt, 64),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeLeadQualification(entry = {}) {
|
|
160
|
+
const qualification = entry.qualification || {};
|
|
161
|
+
return {
|
|
162
|
+
painHypothesis: normalizeText(qualification.painHypothesis, 1200),
|
|
163
|
+
concreteOffer: normalizeText(qualification.concreteOffer, 400)
|
|
164
|
+
|| 'I will harden one AI-agent workflow for you.',
|
|
165
|
+
proofTiming: normalizeText(qualification.proofTiming, 240)
|
|
166
|
+
|| 'Use proof pack only after the buyer confirms pain.',
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function normalizeLeadOutbound(entry = {}) {
|
|
171
|
+
const outbound = entry.outbound || {};
|
|
172
|
+
return {
|
|
173
|
+
draft: normalizeText(outbound.draft, 2000),
|
|
174
|
+
cta: normalizeUrl(outbound.cta),
|
|
175
|
+
lastSentAt: normalizeText(outbound.lastSentAt, 64),
|
|
176
|
+
lastSentUrl: normalizeUrl(outbound.lastSentUrl),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function normalizeLeadRevenue(entry = {}) {
|
|
181
|
+
const revenue = entry.revenue || {};
|
|
182
|
+
return {
|
|
183
|
+
amountCents: Math.max(0, normalizeInteger(revenue.amountCents, 0)),
|
|
184
|
+
currency: normalizeText(revenue.currency, 16) || 'usd',
|
|
185
|
+
paidAt: normalizeText(revenue.paidAt, 64),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function normalizeLeadAttribution(entry = {}) {
|
|
190
|
+
const attribution = entry.attribution || {};
|
|
191
|
+
return {
|
|
192
|
+
sourceReport: normalizeText(attribution.sourceReport, 1000),
|
|
193
|
+
campaign: normalizeText(attribution.campaign, 160),
|
|
194
|
+
utmSource: normalizeText(attribution.utmSource, 120),
|
|
195
|
+
utmMedium: normalizeText(attribution.utmMedium, 120),
|
|
196
|
+
utmCampaign: normalizeText(attribution.utmCampaign, 160),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function sanitizeSalesLead(entry = {}) {
|
|
201
|
+
const createdAt = normalizeText(entry.createdAt, 64) || new Date().toISOString();
|
|
202
|
+
const updatedAt = normalizeText(entry.updatedAt, 64) || createdAt;
|
|
203
|
+
const stage = normalizeSalesStage(entry.stage, 'targeted');
|
|
204
|
+
const source = normalizeText(entry.source, 80) || 'manual';
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
leadId: buildSalesLeadId(entry),
|
|
208
|
+
createdAt,
|
|
209
|
+
updatedAt,
|
|
210
|
+
stage,
|
|
211
|
+
source,
|
|
212
|
+
channel: normalizeText(entry.channel, 80) || source,
|
|
213
|
+
offer: normalizeText(entry.offer, 120) || 'workflow_hardening_sprint',
|
|
214
|
+
contact: normalizeLeadContact(entry),
|
|
215
|
+
account: normalizeLeadAccount(entry),
|
|
216
|
+
qualification: normalizeLeadQualification(entry),
|
|
217
|
+
outbound: normalizeLeadOutbound(entry),
|
|
218
|
+
revenue: normalizeLeadRevenue(entry),
|
|
219
|
+
attribution: normalizeLeadAttribution(entry),
|
|
220
|
+
history: normalizeLeadHistory(entry, stage, updatedAt),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function getSalesPipelinePath({ statePath = null, feedbackDir = null } = {}) {
|
|
225
|
+
if (statePath) return path.resolve(statePath);
|
|
226
|
+
const baseDir = feedbackDir || getFeedbackPaths().FEEDBACK_DIR;
|
|
227
|
+
return path.join(baseDir, SALES_PIPELINE_FILE);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function appendSalesLeadSnapshot(lead = {}, options = {}) {
|
|
231
|
+
const sanitized = sanitizeSalesLead(lead);
|
|
232
|
+
appendJsonl(getSalesPipelinePath(options), sanitized);
|
|
233
|
+
return sanitized;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function loadSalesLeadSnapshots(options = {}) {
|
|
237
|
+
return readJsonl(getSalesPipelinePath(options))
|
|
238
|
+
.map((entry) => {
|
|
239
|
+
try {
|
|
240
|
+
return sanitizeSalesLead(entry);
|
|
241
|
+
} catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
.filter(Boolean);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function loadSalesLeads(options = {}) {
|
|
249
|
+
const latestByLeadId = new Map();
|
|
250
|
+
for (const snapshot of loadSalesLeadSnapshots(options)) {
|
|
251
|
+
const existing = latestByLeadId.get(snapshot.leadId);
|
|
252
|
+
if (!existing || String(snapshot.updatedAt || '') >= String(existing.updatedAt || '')) {
|
|
253
|
+
latestByLeadId.set(snapshot.leadId, snapshot);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return Array.from(latestByLeadId.values())
|
|
257
|
+
.sort((a, b) => String(a.updatedAt || '').localeCompare(String(b.updatedAt || '')));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function buildLeadFromRevenueTarget(target = {}, { sourcePath = null } = {}) {
|
|
261
|
+
const username = normalizeText(target.username, 160);
|
|
262
|
+
const repoName = normalizeText(target.repoName, 200);
|
|
263
|
+
const repoUrl = normalizeUrl(target.repoUrl);
|
|
264
|
+
return sanitizeSalesLead({
|
|
265
|
+
source: 'github',
|
|
266
|
+
channel: 'github',
|
|
267
|
+
stage: 'targeted',
|
|
268
|
+
offer: 'workflow_hardening_sprint',
|
|
269
|
+
contact: {
|
|
270
|
+
username,
|
|
271
|
+
url: username ? `https://github.com/${username}` : null,
|
|
272
|
+
},
|
|
273
|
+
account: {
|
|
274
|
+
name: username,
|
|
275
|
+
repoName,
|
|
276
|
+
repoUrl,
|
|
277
|
+
description: target.description,
|
|
278
|
+
stars: target.stars,
|
|
279
|
+
updatedAt: target.updatedAt,
|
|
280
|
+
},
|
|
281
|
+
qualification: {
|
|
282
|
+
painHypothesis: target.motionReason || target.description,
|
|
283
|
+
concreteOffer: 'I will harden one AI-agent workflow for you.',
|
|
284
|
+
proofTiming: 'Use proof pack only after the buyer confirms pain.',
|
|
285
|
+
},
|
|
286
|
+
outbound: {
|
|
287
|
+
draft: target.message,
|
|
288
|
+
cta: target.cta,
|
|
289
|
+
},
|
|
290
|
+
attribution: {
|
|
291
|
+
sourceReport: sourcePath,
|
|
292
|
+
campaign: 'workflow_hardening_sprint_outbound',
|
|
293
|
+
utmSource: 'github',
|
|
294
|
+
utmMedium: 'direct_outbound',
|
|
295
|
+
utmCampaign: 'workflow_hardening_sprint',
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function importRevenueLoopReport(report = {}, options = {}) {
|
|
301
|
+
const existing = new Map(loadSalesLeads(options).map((lead) => [lead.leadId, lead]));
|
|
302
|
+
const targets = Array.isArray(report.targets) ? report.targets : [];
|
|
303
|
+
const imported = [];
|
|
304
|
+
const skipped = [];
|
|
305
|
+
|
|
306
|
+
for (const target of targets) {
|
|
307
|
+
const candidate = buildLeadFromRevenueTarget(target, { sourcePath: options.sourcePath || null });
|
|
308
|
+
if (existing.has(candidate.leadId)) {
|
|
309
|
+
skipped.push(candidate.leadId);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
imported.push(appendSalesLeadSnapshot(candidate, options));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
imported,
|
|
317
|
+
skipped,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function addSalesLead(payload = {}, options = {}) {
|
|
322
|
+
const lead = sanitizeSalesLead({
|
|
323
|
+
leadId: payload.leadId,
|
|
324
|
+
source: payload.source || 'manual',
|
|
325
|
+
channel: payload.channel || payload.source || 'manual',
|
|
326
|
+
stage: payload.stage || 'targeted',
|
|
327
|
+
offer: payload.offer || 'workflow_hardening_sprint',
|
|
328
|
+
contact: {
|
|
329
|
+
username: payload.username,
|
|
330
|
+
name: payload.name,
|
|
331
|
+
email: payload.email,
|
|
332
|
+
url: payload.contactUrl,
|
|
333
|
+
},
|
|
334
|
+
account: {
|
|
335
|
+
name: payload.account,
|
|
336
|
+
repoName: payload.repo,
|
|
337
|
+
repoUrl: payload.repoUrl,
|
|
338
|
+
description: payload.description,
|
|
339
|
+
stars: payload.stars,
|
|
340
|
+
},
|
|
341
|
+
qualification: {
|
|
342
|
+
painHypothesis: payload.pain || payload.description,
|
|
343
|
+
concreteOffer: payload.concreteOffer || 'I will harden one AI-agent workflow for you.',
|
|
344
|
+
proofTiming: payload.proofTiming || 'Use proof pack only after the buyer confirms pain.',
|
|
345
|
+
},
|
|
346
|
+
outbound: {
|
|
347
|
+
draft: payload.draft,
|
|
348
|
+
cta: payload.cta,
|
|
349
|
+
},
|
|
350
|
+
attribution: {
|
|
351
|
+
campaign: payload.campaign || 'workflow_hardening_sprint_outbound',
|
|
352
|
+
utmSource: payload.utmSource || payload.source || 'manual',
|
|
353
|
+
utmMedium: payload.utmMedium || 'direct_outbound',
|
|
354
|
+
utmCampaign: payload.utmCampaign || 'workflow_hardening_sprint',
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const existing = loadSalesLeads(options).find((entry) => entry.leadId === lead.leadId);
|
|
359
|
+
if (existing && !payload.force) {
|
|
360
|
+
throw new Error(`Sales lead already exists: ${lead.leadId}`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return appendSalesLeadSnapshot(lead, options);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function readRevenueLoopReport(sourcePath) {
|
|
367
|
+
const resolved = path.resolve(sourcePath || '');
|
|
368
|
+
const parsed = JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
|
369
|
+
return {
|
|
370
|
+
report: parsed,
|
|
371
|
+
sourcePath: resolved,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function validateStageTransition(currentStage, nextStage, { force = false } = {}) {
|
|
376
|
+
if (force || currentStage === nextStage) return;
|
|
377
|
+
const allowed = SALES_STAGE_TRANSITIONS[currentStage] || [];
|
|
378
|
+
if (!allowed.includes(nextStage)) {
|
|
379
|
+
throw new Error(`Invalid sales pipeline transition: ${currentStage} -> ${nextStage}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function advanceSalesLead(payload = {}, options = {}) {
|
|
384
|
+
const leadId = normalizeText(payload.leadId || payload.lead, 160);
|
|
385
|
+
const nextStage = normalizeSalesStage(payload.stage, null);
|
|
386
|
+
if (!leadId) throw new Error('leadId is required.');
|
|
387
|
+
if (!nextStage) throw new Error(`stage must be one of: ${SALES_STAGE_FLOW.join(', ')}`);
|
|
388
|
+
|
|
389
|
+
const currentLead = loadSalesLeads(options).find((lead) => lead.leadId === leadId);
|
|
390
|
+
if (!currentLead) throw new Error(`Unknown sales lead: ${leadId}`);
|
|
391
|
+
validateStageTransition(currentLead.stage, nextStage, { force: Boolean(payload.force) });
|
|
392
|
+
|
|
393
|
+
if (currentLead.stage === nextStage) {
|
|
394
|
+
return {
|
|
395
|
+
lead: currentLead,
|
|
396
|
+
unchanged: true,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const updatedAt = normalizeText(payload.timestamp, 64) || new Date().toISOString();
|
|
401
|
+
const revenueAmount = normalizeInteger(payload.amountCents, currentLead.revenue.amountCents || 0);
|
|
402
|
+
const updatedLead = appendSalesLeadSnapshot({
|
|
403
|
+
...currentLead,
|
|
404
|
+
updatedAt,
|
|
405
|
+
stage: nextStage,
|
|
406
|
+
outbound: {
|
|
407
|
+
...currentLead.outbound,
|
|
408
|
+
lastSentAt: nextStage === 'contacted' ? updatedAt : currentLead.outbound.lastSentAt,
|
|
409
|
+
lastSentUrl: nextStage === 'contacted'
|
|
410
|
+
? normalizeUrl(payload.url) || currentLead.outbound.lastSentUrl
|
|
411
|
+
: currentLead.outbound.lastSentUrl,
|
|
412
|
+
},
|
|
413
|
+
revenue: {
|
|
414
|
+
...currentLead.revenue,
|
|
415
|
+
amountCents: nextStage === 'paid' ? revenueAmount : currentLead.revenue.amountCents,
|
|
416
|
+
currency: normalizeText(payload.currency, 16) || currentLead.revenue.currency,
|
|
417
|
+
paidAt: nextStage === 'paid' ? updatedAt : currentLead.revenue.paidAt,
|
|
418
|
+
},
|
|
419
|
+
history: currentLead.history.concat(buildHistoryEntry({
|
|
420
|
+
fromStage: currentLead.stage,
|
|
421
|
+
toStage: nextStage,
|
|
422
|
+
actor: payload.actor || 'operator',
|
|
423
|
+
channel: payload.channel || currentLead.channel,
|
|
424
|
+
note: payload.note,
|
|
425
|
+
url: payload.url,
|
|
426
|
+
timestamp: updatedAt,
|
|
427
|
+
})),
|
|
428
|
+
}, options);
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
lead: updatedLead,
|
|
432
|
+
unchanged: false,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function summarizeSalesPipeline(leads = []) {
|
|
437
|
+
const byStage = Object.fromEntries(SALES_STAGE_FLOW.map((stage) => [stage, 0]));
|
|
438
|
+
let bookedRevenueCents = 0;
|
|
439
|
+
for (const lead of leads) {
|
|
440
|
+
byStage[lead.stage] = (byStage[lead.stage] || 0) + 1;
|
|
441
|
+
if (lead.stage === 'paid') {
|
|
442
|
+
bookedRevenueCents += lead.revenue.amountCents || 0;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
total: leads.length,
|
|
448
|
+
byStage,
|
|
449
|
+
active: leads.filter((lead) => lead.stage !== 'paid' && lead.stage !== 'lost').length,
|
|
450
|
+
contacted: byStage.contacted + byStage.replied + byStage.call_booked
|
|
451
|
+
+ byStage.checkout_started + byStage.sprint_intake + byStage.paid,
|
|
452
|
+
replies: byStage.replied + byStage.call_booked + byStage.checkout_started + byStage.sprint_intake + byStage.paid,
|
|
453
|
+
callsBooked: byStage.call_booked + byStage.checkout_started + byStage.sprint_intake + byStage.paid,
|
|
454
|
+
paid: byStage.paid,
|
|
455
|
+
bookedRevenueCents,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function formatLeadContact(contact = {}) {
|
|
460
|
+
return contact.username ? `@${contact.username}` : (contact.email || 'n/a');
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function renderLeadQueueEntry(lead) {
|
|
464
|
+
const repo = lead.account.repoUrl || lead.account.repoName || lead.account.name || 'n/a';
|
|
465
|
+
return [
|
|
466
|
+
`### ${lead.leadId}`,
|
|
467
|
+
`- Stage: ${lead.stage}`,
|
|
468
|
+
`- Offer: ${lead.offer}`,
|
|
469
|
+
`- Repo/account: ${repo}`,
|
|
470
|
+
`- Contact: ${formatLeadContact(lead.contact)}`,
|
|
471
|
+
`- Concrete offer: ${lead.qualification.concreteOffer}`,
|
|
472
|
+
`- Proof rule: ${lead.qualification.proofTiming}`,
|
|
473
|
+
`- Outreach draft: ${lead.outbound.draft || 'n/a'}`,
|
|
474
|
+
'',
|
|
475
|
+
];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function renderSalesPipelineMarkdown({ leads = [], generatedAt = new Date().toISOString() } = {}) {
|
|
479
|
+
const summary = summarizeSalesPipeline(leads);
|
|
480
|
+
const leadQueueLines = leads.length
|
|
481
|
+
? leads.flatMap(renderLeadQueueEntry)
|
|
482
|
+
: ['- No leads tracked yet. Import a GTM revenue loop JSON report first.'];
|
|
483
|
+
const lines = [
|
|
484
|
+
'# Sales Pipeline',
|
|
485
|
+
'',
|
|
486
|
+
`Updated: ${generatedAt}`,
|
|
487
|
+
'',
|
|
488
|
+
'This is the first-dollar truth table. Posts are not sales; only stage movement counts.',
|
|
489
|
+
'',
|
|
490
|
+
'## Summary',
|
|
491
|
+
`- Total leads: ${summary.total}`,
|
|
492
|
+
`- Active leads: ${summary.active}`,
|
|
493
|
+
`- Contacted: ${summary.contacted}`,
|
|
494
|
+
`- Replied: ${summary.replies}`,
|
|
495
|
+
`- Calls booked: ${summary.callsBooked}`,
|
|
496
|
+
`- Paid: ${summary.paid}`,
|
|
497
|
+
`- Booked revenue: $${(summary.bookedRevenueCents / 100).toFixed(2)}`,
|
|
498
|
+
'',
|
|
499
|
+
'## Stage Counts',
|
|
500
|
+
...SALES_STAGE_FLOW.map((stage) => `- ${stage}: ${summary.byStage[stage] || 0}`),
|
|
501
|
+
'',
|
|
502
|
+
'## Lead Queue',
|
|
503
|
+
...leadQueueLines,
|
|
504
|
+
];
|
|
505
|
+
return `${lines.join('\n').trim()}\n`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function writeSalesPipelineReport({ outPath, leads }) {
|
|
509
|
+
if (!outPath) return null;
|
|
510
|
+
const resolved = path.resolve(outPath);
|
|
511
|
+
ensureParentDir(resolved);
|
|
512
|
+
fs.writeFileSync(resolved, renderSalesPipelineMarkdown({ leads }), 'utf8');
|
|
513
|
+
return resolved;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function parseArgs(argv = []) {
|
|
517
|
+
const firstArg = argv[0];
|
|
518
|
+
const hasCommand = firstArg ? !firstArg.startsWith('--') : false;
|
|
519
|
+
const command = hasCommand ? firstArg : 'report';
|
|
520
|
+
const args = hasCommand ? argv.slice(1) : argv;
|
|
521
|
+
const options = { command };
|
|
522
|
+
|
|
523
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
524
|
+
const arg = args[index];
|
|
525
|
+
if (!arg.startsWith('--')) continue;
|
|
526
|
+
const eqIndex = arg.indexOf('=', 2);
|
|
527
|
+
const rawKey = eqIndex === -1 ? arg.slice(2) : arg.slice(2, eqIndex);
|
|
528
|
+
const key = rawKey.replaceAll(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
529
|
+
if (eqIndex !== -1) {
|
|
530
|
+
options[key] = arg.slice(eqIndex + 1);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
const nextArg = args[index + 1];
|
|
534
|
+
if (nextArg && !nextArg.startsWith('--')) {
|
|
535
|
+
options[key] = nextArg;
|
|
536
|
+
index += 1;
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
options[key] = true;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return options;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function runCli(argv = process.argv.slice(2)) {
|
|
546
|
+
const options = parseArgs(argv);
|
|
547
|
+
const stateOptions = {
|
|
548
|
+
statePath: options.state,
|
|
549
|
+
feedbackDir: options.feedbackDir,
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
switch (options.command) {
|
|
553
|
+
case 'import':
|
|
554
|
+
case 'import-gtm': {
|
|
555
|
+
if (!options.source) throw new Error('--source is required for import.');
|
|
556
|
+
const { report, sourcePath } = readRevenueLoopReport(options.source);
|
|
557
|
+
const result = importRevenueLoopReport(report, { ...stateOptions, sourcePath });
|
|
558
|
+
const leads = loadSalesLeads(stateOptions);
|
|
559
|
+
const reportPath = writeSalesPipelineReport({ outPath: options.out, leads });
|
|
560
|
+
return {
|
|
561
|
+
command: options.command,
|
|
562
|
+
imported: result.imported.length,
|
|
563
|
+
skipped: result.skipped.length,
|
|
564
|
+
statePath: getSalesPipelinePath(stateOptions),
|
|
565
|
+
reportPath,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
case 'advance': {
|
|
570
|
+
const result = advanceSalesLead({
|
|
571
|
+
leadId: options.lead || options.leadId,
|
|
572
|
+
stage: options.stage,
|
|
573
|
+
actor: options.actor,
|
|
574
|
+
channel: options.channel,
|
|
575
|
+
note: options.note,
|
|
576
|
+
url: options.url,
|
|
577
|
+
amountCents: options.amountCents,
|
|
578
|
+
currency: options.currency,
|
|
579
|
+
force: options.force,
|
|
580
|
+
}, stateOptions);
|
|
581
|
+
const leads = loadSalesLeads(stateOptions);
|
|
582
|
+
const reportPath = writeSalesPipelineReport({ outPath: options.out, leads });
|
|
583
|
+
return {
|
|
584
|
+
command: options.command,
|
|
585
|
+
leadId: result.lead.leadId,
|
|
586
|
+
stage: result.lead.stage,
|
|
587
|
+
unchanged: result.unchanged,
|
|
588
|
+
statePath: getSalesPipelinePath(stateOptions),
|
|
589
|
+
reportPath,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
case 'add': {
|
|
594
|
+
const lead = addSalesLead({
|
|
595
|
+
leadId: options.lead || options.leadId,
|
|
596
|
+
source: options.source,
|
|
597
|
+
channel: options.channel,
|
|
598
|
+
stage: options.stage,
|
|
599
|
+
offer: options.offer,
|
|
600
|
+
username: options.username,
|
|
601
|
+
name: options.name,
|
|
602
|
+
email: options.email,
|
|
603
|
+
contactUrl: options.contactUrl,
|
|
604
|
+
account: options.account,
|
|
605
|
+
repo: options.repo,
|
|
606
|
+
repoUrl: options.repoUrl,
|
|
607
|
+
description: options.description,
|
|
608
|
+
stars: options.stars,
|
|
609
|
+
pain: options.pain,
|
|
610
|
+
concreteOffer: options.concreteOffer,
|
|
611
|
+
proofTiming: options.proofTiming,
|
|
612
|
+
draft: options.draft,
|
|
613
|
+
cta: options.cta,
|
|
614
|
+
campaign: options.campaign,
|
|
615
|
+
utmSource: options.utmSource,
|
|
616
|
+
utmMedium: options.utmMedium,
|
|
617
|
+
utmCampaign: options.utmCampaign,
|
|
618
|
+
force: options.force,
|
|
619
|
+
}, stateOptions);
|
|
620
|
+
const leads = loadSalesLeads(stateOptions);
|
|
621
|
+
const reportPath = writeSalesPipelineReport({ outPath: options.out, leads });
|
|
622
|
+
return {
|
|
623
|
+
command: options.command,
|
|
624
|
+
leadId: lead.leadId,
|
|
625
|
+
stage: lead.stage,
|
|
626
|
+
statePath: getSalesPipelinePath(stateOptions),
|
|
627
|
+
reportPath,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
case 'report': {
|
|
632
|
+
const leads = loadSalesLeads(stateOptions);
|
|
633
|
+
const reportPath = writeSalesPipelineReport({ outPath: options.out, leads });
|
|
634
|
+
return {
|
|
635
|
+
command: options.command,
|
|
636
|
+
summary: summarizeSalesPipeline(leads),
|
|
637
|
+
statePath: getSalesPipelinePath(stateOptions),
|
|
638
|
+
reportPath,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
default:
|
|
643
|
+
throw new Error(`Unknown sales pipeline command: ${options.command}`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function isCliInvocation(argv = process.argv) {
|
|
648
|
+
const invokedPath = argv[1];
|
|
649
|
+
return Boolean(invokedPath) && !path.relative(path.resolve(invokedPath), __filename);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (isCliInvocation()) {
|
|
653
|
+
try {
|
|
654
|
+
const result = runCli();
|
|
655
|
+
console.log(JSON.stringify(result, null, 2));
|
|
656
|
+
} catch (err) {
|
|
657
|
+
console.error(err?.message || err);
|
|
658
|
+
process.exit(1);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
module.exports = {
|
|
663
|
+
SALES_PIPELINE_FILE,
|
|
664
|
+
SALES_STAGE_FLOW,
|
|
665
|
+
SALES_STAGE_TRANSITIONS,
|
|
666
|
+
addSalesLead,
|
|
667
|
+
advanceSalesLead,
|
|
668
|
+
appendSalesLeadSnapshot,
|
|
669
|
+
buildLeadFromRevenueTarget,
|
|
670
|
+
getSalesPipelinePath,
|
|
671
|
+
importRevenueLoopReport,
|
|
672
|
+
isCliInvocation,
|
|
673
|
+
loadSalesLeads,
|
|
674
|
+
loadSalesLeadSnapshots,
|
|
675
|
+
normalizeSalesStage,
|
|
676
|
+
parseArgs,
|
|
677
|
+
renderSalesPipelineMarkdown,
|
|
678
|
+
runCli,
|
|
679
|
+
sanitizeSalesLead,
|
|
680
|
+
summarizeSalesPipeline,
|
|
681
|
+
};
|