fullstackgtm 0.42.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -0
- package/dist/calls.d.ts +13 -0
- package/dist/calls.js +14 -3
- package/dist/cli.js +85 -5
- package/dist/connectors/hubspot.js +58 -24
- package/dist/connectors/salesforce.js +40 -15
- package/dist/draft.js +27 -5
- package/dist/enrich.d.ts +6 -0
- package/dist/enrich.js +47 -1
- package/dist/health.d.ts +12 -0
- package/dist/health.js +29 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init.d.ts +47 -0
- package/dist/init.js +140 -0
- package/dist/judge.d.ts +36 -3
- package/dist/judge.js +46 -10
- package/dist/signals.d.ts +4 -0
- package/dist/signals.js +1 -0
- package/dist/types.d.ts +12 -0
- package/docs/api.md +8 -0
- package/docs/recipes.md +158 -0
- package/llms.txt +25 -0
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +16 -1
- package/src/calls.ts +24 -3
- package/src/cli.ts +96 -5
- package/src/connectors/hubspot.ts +55 -23
- package/src/connectors/salesforce.ts +39 -14
- package/src/draft.ts +26 -5
- package/src/enrich.ts +51 -1
- package/src/health.ts +47 -2
- package/src/index.ts +10 -0
- package/src/init.ts +163 -0
- package/src/judge.ts +85 -11
- package/src/signals.ts +5 -0
- package/src/types.ts +12 -0
|
@@ -80,28 +80,52 @@ export function createSalesforceConnector(
|
|
|
80
80
|
// confirmed miss is created, and an ambiguous name (>1 match) is refused.
|
|
81
81
|
// Caches within the run so the same name is never created twice — shared by
|
|
82
82
|
// `link_record`'s create:<Name> path and `create_record`'s company linking.
|
|
83
|
-
async function
|
|
83
|
+
async function resolveOrCreateAccount(
|
|
84
84
|
name: string,
|
|
85
|
+
domain?: string,
|
|
85
86
|
): Promise<{ id: string; createdNew: boolean } | { ambiguous: number }> {
|
|
86
|
-
const
|
|
87
|
-
const cached = createdAccountsByName.get(
|
|
87
|
+
const cacheKey = domain ? `d:${domain.toLowerCase()}` : `n:${name.toLowerCase()}`;
|
|
88
|
+
const cached = createdAccountsByName.get(cacheKey);
|
|
88
89
|
if (cached) return { id: cached, createdNew: false };
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
const esc = (v: string) => v.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
91
|
+
|
|
92
|
+
// 1. Domain-first match on Website (the accurate key; Website often stores a
|
|
93
|
+
// full URL, so match by substring).
|
|
94
|
+
if (domain) {
|
|
95
|
+
const byDomain = await query(`SELECT Id FROM Account WHERE Website LIKE '%${esc(domain)}%' LIMIT 3`);
|
|
96
|
+
if (byDomain.length > 1) return { ambiguous: byDomain.length };
|
|
97
|
+
if (byDomain.length === 1) {
|
|
98
|
+
const id = String(byDomain[0].Id);
|
|
99
|
+
createdAccountsByName.set(cacheKey, id);
|
|
100
|
+
return { id, createdNew: false };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// 2. Exact-name match; stamp Website if the account has none (fill-blank).
|
|
104
|
+
const byName = await query(`SELECT Id, Website FROM Account WHERE Name = '${esc(name)}' LIMIT 3`);
|
|
105
|
+
if (byName.length > 1) return { ambiguous: byName.length };
|
|
92
106
|
let id: string;
|
|
93
107
|
let createdNew = false;
|
|
94
|
-
if (
|
|
95
|
-
id = String(
|
|
108
|
+
if (byName.length === 1) {
|
|
109
|
+
id = String(byName[0].Id);
|
|
110
|
+
if (domain && !stringOrUndefined(byName[0].Website)) {
|
|
111
|
+
try {
|
|
112
|
+
await request(`/services/data/${apiVersion}/sobjects/Account/${encodeURIComponent(id)}`, {
|
|
113
|
+
method: "PATCH",
|
|
114
|
+
body: JSON.stringify({ Website: domain }),
|
|
115
|
+
});
|
|
116
|
+
} catch {
|
|
117
|
+
// best-effort fill — a failed Website stamp must not sink the create.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
96
120
|
} else {
|
|
97
121
|
const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
|
|
98
122
|
method: "POST",
|
|
99
|
-
body: JSON.stringify({ Name: name }),
|
|
123
|
+
body: JSON.stringify({ Name: name, ...(domain ? { Website: domain } : {}) }),
|
|
100
124
|
});
|
|
101
125
|
id = String(created.id);
|
|
102
126
|
createdNew = true;
|
|
103
127
|
}
|
|
104
|
-
createdAccountsByName.set(
|
|
128
|
+
createdAccountsByName.set(cacheKey, id);
|
|
105
129
|
return { id, createdNew };
|
|
106
130
|
}
|
|
107
131
|
|
|
@@ -534,7 +558,7 @@ export function createSalesforceConnector(
|
|
|
534
558
|
}
|
|
535
559
|
|
|
536
560
|
if (operation.objectType === "account") {
|
|
537
|
-
const resolved = await
|
|
561
|
+
const resolved = await resolveOrCreateAccount(matchValue);
|
|
538
562
|
if ("ambiguous" in resolved) {
|
|
539
563
|
return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — ${resolved.ambiguous} accounts named "${matchValue}". Not creating.` };
|
|
540
564
|
}
|
|
@@ -574,7 +598,7 @@ export function createSalesforceConnector(
|
|
|
574
598
|
let companyNote = "";
|
|
575
599
|
if (payload.associateCompanyName) {
|
|
576
600
|
try {
|
|
577
|
-
const resolved = await
|
|
601
|
+
const resolved = await resolveOrCreateAccount(payload.associateCompanyName, payload.associateCompanyDomain);
|
|
578
602
|
if ("ambiguous" in resolved) {
|
|
579
603
|
companyNote = ` Account "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
|
|
580
604
|
} else {
|
|
@@ -582,7 +606,8 @@ export function createSalesforceConnector(
|
|
|
582
606
|
method: "PATCH",
|
|
583
607
|
body: JSON.stringify({ AccountId: resolved.id }),
|
|
584
608
|
});
|
|
585
|
-
|
|
609
|
+
const domainNote = payload.associateCompanyDomain ? ` (${payload.associateCompanyDomain})` : "";
|
|
610
|
+
companyNote = ` Linked to account "${payload.associateCompanyName}"${domainNote} (${resolved.id}).`;
|
|
586
611
|
}
|
|
587
612
|
} catch (error) {
|
|
588
613
|
// Association is best-effort — the contact create already succeeded.
|
|
@@ -726,7 +751,7 @@ export function createSalesforceConnector(
|
|
|
726
751
|
if (!name) {
|
|
727
752
|
return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
|
|
728
753
|
}
|
|
729
|
-
const resolved = await
|
|
754
|
+
const resolved = await resolveOrCreateAccount(name);
|
|
730
755
|
if ("ambiguous" in resolved) {
|
|
731
756
|
return {
|
|
732
757
|
operationId: operation.id,
|
package/src/draft.ts
CHANGED
|
@@ -305,9 +305,6 @@ export function draft(opts: {
|
|
|
305
305
|
const minScore = opts.minScore ?? 80;
|
|
306
306
|
const channel: DraftChannel = opts.channel ?? "task";
|
|
307
307
|
const freshnessDays = opts.freshnessDays ?? DEFAULT_FRESHNESS_DAYS;
|
|
308
|
-
// `task`/`email`/`linkedin` all write a CRM task through the same gate; the
|
|
309
|
-
// object the task hangs off is the contact when known, else the account.
|
|
310
|
-
const objectType: GtmObjectType = "contact";
|
|
311
308
|
|
|
312
309
|
const hot = opts.decisions
|
|
313
310
|
.filter((d) => d.decision === "send" && d.score >= minScore)
|
|
@@ -349,11 +346,35 @@ export function draft(opts: {
|
|
|
349
346
|
continue;
|
|
350
347
|
}
|
|
351
348
|
|
|
349
|
+
// Resolve a REAL CRM target from the judge decision: a contact id (preferred
|
|
350
|
+
// — that's who we message), else the account id. A domain-only decision
|
|
351
|
+
// (the account isn't in the CRM yet) cannot hang a task off a record — reject
|
|
352
|
+
// it with the fix, instead of forging a contact-typed op carrying a domain.
|
|
353
|
+
let objectType: GtmObjectType;
|
|
354
|
+
let objectId: string;
|
|
355
|
+
let targetNote: string;
|
|
356
|
+
if (decision.contact?.id) {
|
|
357
|
+
objectType = "contact";
|
|
358
|
+
objectId = decision.contact.id;
|
|
359
|
+
targetNote = `contact ${decision.contact.email ?? decision.contact.id}`;
|
|
360
|
+
} else if (decision.accountId) {
|
|
361
|
+
objectType = "account";
|
|
362
|
+
objectId = decision.accountId;
|
|
363
|
+
targetNote = `account ${domain}`;
|
|
364
|
+
} else {
|
|
365
|
+
rejected.push({
|
|
366
|
+
accountDomain: domain,
|
|
367
|
+
opener,
|
|
368
|
+
reason: `account ${domain} is not in the CRM — acquire it first (enrich acquire), then judge with a snapshot, before drafting`,
|
|
369
|
+
});
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
|
|
352
373
|
const stale = isStaleTrigger(signal.firstSeen, now, freshnessDays);
|
|
353
374
|
const ev = draftEvidence(signal, nowIso);
|
|
354
375
|
evidence.push(ev);
|
|
355
376
|
|
|
356
|
-
const reasonBase = `Signal-grounded opener for ${domain}: "${signal.trigger}"`;
|
|
377
|
+
const reasonBase = `Signal-grounded opener for ${domain} → ${targetNote}: "${signal.trigger}"`;
|
|
357
378
|
const reason = stale
|
|
358
379
|
? `${reasonBase} [staleTrigger: grounding signal first seen ${signal.firstSeen}, older than ${freshnessDays}d — could this have gone out last month unchanged?]`
|
|
359
380
|
: reasonBase;
|
|
@@ -361,7 +382,7 @@ export function draft(opts: {
|
|
|
361
382
|
operations.push({
|
|
362
383
|
id: draftOperationId(channel, domain),
|
|
363
384
|
objectType,
|
|
364
|
-
objectId
|
|
385
|
+
objectId,
|
|
365
386
|
operation: "create_task",
|
|
366
387
|
field: "follow_up_task",
|
|
367
388
|
beforeValue: null,
|
package/src/enrich.ts
CHANGED
|
@@ -92,6 +92,12 @@ export type AcquireCreateMap = {
|
|
|
92
92
|
matchKey: string;
|
|
93
93
|
/** Optional source path to a company name; the connector resolves-or-creates it. */
|
|
94
94
|
associateCompanyFrom?: string;
|
|
95
|
+
/**
|
|
96
|
+
* Optional source path to the company domain. With it (or a derivable email
|
|
97
|
+
* domain) the connector resolves the account by domain and stamps the domain
|
|
98
|
+
* on it — so the lead's account is signal-watchable, not just a text field.
|
|
99
|
+
*/
|
|
100
|
+
associateCompanyDomainFrom?: string;
|
|
95
101
|
};
|
|
96
102
|
|
|
97
103
|
/**
|
|
@@ -392,6 +398,8 @@ export function builtinAcquirePreset(source: string | undefined): EnrichConfig |
|
|
|
392
398
|
company: "companyName",
|
|
393
399
|
email: "email",
|
|
394
400
|
},
|
|
401
|
+
associateCompanyFrom: "companyName",
|
|
402
|
+
associateCompanyDomainFrom: "companyDomain",
|
|
395
403
|
},
|
|
396
404
|
},
|
|
397
405
|
},
|
|
@@ -418,6 +426,8 @@ export function builtinAcquirePreset(source: string | undefined): EnrichConfig |
|
|
|
418
426
|
company: "companyName",
|
|
419
427
|
hs_linkedin_url: "linkedin",
|
|
420
428
|
},
|
|
429
|
+
associateCompanyFrom: "companyName",
|
|
430
|
+
associateCompanyDomainFrom: "companyDomain",
|
|
421
431
|
},
|
|
422
432
|
},
|
|
423
433
|
},
|
|
@@ -733,6 +743,27 @@ function isEmptyValue(value: unknown): boolean {
|
|
|
733
743
|
return value === undefined || value === null || (typeof value === "string" && value.trim() === "");
|
|
734
744
|
}
|
|
735
745
|
|
|
746
|
+
/** Bare registrable host from a domain or URL ("https://www.x.com/a" → "x.com"). */
|
|
747
|
+
function normalizeCompanyDomain(value: unknown): string | undefined {
|
|
748
|
+
if (typeof value !== "string" || !value.trim()) return undefined;
|
|
749
|
+
const host = value
|
|
750
|
+
.trim()
|
|
751
|
+
.toLowerCase()
|
|
752
|
+
.replace(/^https?:\/\//, "")
|
|
753
|
+
.replace(/^www\./, "")
|
|
754
|
+
.replace(/\/.*$/, "")
|
|
755
|
+
.replace(/\s+/g, "");
|
|
756
|
+
return host && host.includes(".") ? host : undefined;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** The domain of a work email ("vp@acme.com" → "acme.com"), else undefined. */
|
|
760
|
+
function domainFromEmail(value: unknown): string | undefined {
|
|
761
|
+
if (typeof value !== "string") return undefined;
|
|
762
|
+
const at = value.lastIndexOf("@");
|
|
763
|
+
if (at < 0) return undefined;
|
|
764
|
+
return normalizeCompanyDomain(value.slice(at + 1));
|
|
765
|
+
}
|
|
766
|
+
|
|
736
767
|
/** Values compare as trimmed strings; numbers compare numerically. */
|
|
737
768
|
function sameValue(a: unknown, b: unknown): boolean {
|
|
738
769
|
if (isEmptyValue(a) && isEmptyValue(b)) return true;
|
|
@@ -1130,6 +1161,20 @@ export function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanR
|
|
|
1130
1161
|
const companyValue = sourceValueAt(record.payload, createMap.associateCompanyFrom);
|
|
1131
1162
|
if (!isEmptyValue(companyValue)) associateCompanyName = String(companyValue);
|
|
1132
1163
|
}
|
|
1164
|
+
// The company domain makes the lead's account signal-watchable. Prefer the
|
|
1165
|
+
// configured source path; fall back to the work email's domain (free, and
|
|
1166
|
+
// for B2B leads the email domain IS the company domain).
|
|
1167
|
+
let associateCompanyDomain: string | undefined;
|
|
1168
|
+
if (associateCompanyName) {
|
|
1169
|
+
const fromPath = createMap.associateCompanyDomainFrom
|
|
1170
|
+
? sourceValueAt(record.payload, createMap.associateCompanyDomainFrom)
|
|
1171
|
+
: undefined;
|
|
1172
|
+
const candidate = !isEmptyValue(fromPath)
|
|
1173
|
+
? String(fromPath)
|
|
1174
|
+
: domainFromEmail(sourceValueAt(record.payload, "email"));
|
|
1175
|
+
const normalized = normalizeCompanyDomain(candidate);
|
|
1176
|
+
if (normalized) associateCompanyDomain = normalized;
|
|
1177
|
+
}
|
|
1133
1178
|
|
|
1134
1179
|
const recordEvidence = evidenceFor(
|
|
1135
1180
|
source,
|
|
@@ -1162,8 +1207,13 @@ export function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanR
|
|
|
1162
1207
|
source,
|
|
1163
1208
|
estCostUsd: costPerRecord,
|
|
1164
1209
|
associateCompanyName,
|
|
1210
|
+
...(associateCompanyDomain ? { associateCompanyDomain } : {}),
|
|
1165
1211
|
...(ownerId ? { ownerId, assignedBy } : {}),
|
|
1166
1212
|
};
|
|
1213
|
+
// Surface the account link in the dry-run so it is not a silent side-effect.
|
|
1214
|
+
const accountNote = associateCompanyName
|
|
1215
|
+
? ` Resolves/creates its account "${associateCompanyName}"${associateCompanyDomain ? ` (${associateCompanyDomain})` : ""} and links the lead.`
|
|
1216
|
+
: "";
|
|
1167
1217
|
operations.push({
|
|
1168
1218
|
id: `op_acq_${fnv1a(`${source}:${record.objectType}:${matchValue}`)}`,
|
|
1169
1219
|
objectType: canonicalObjectType(record.objectType),
|
|
@@ -1173,7 +1223,7 @@ export function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanR
|
|
|
1173
1223
|
afterValue: payload,
|
|
1174
1224
|
reason:
|
|
1175
1225
|
`${source} sourced net-new ${record.objectType} "${describeSourceRecord(record)}" ` +
|
|
1176
|
-
`(${createMap.matchKey}=${matchValue}); no CRM match — create as a lead
|
|
1226
|
+
`(${createMap.matchKey}=${matchValue}); no CRM match — create as a lead.${accountNote}`,
|
|
1177
1227
|
sourceRuleOrPolicy: `acquire:${source}`,
|
|
1178
1228
|
riskLevel: "medium",
|
|
1179
1229
|
approvalRequired: true,
|
package/src/health.ts
CHANGED
|
@@ -35,6 +35,18 @@ export type HealthEntry = {
|
|
|
35
35
|
byRule: Record<string, number>;
|
|
36
36
|
/** Finding count per severity. */
|
|
37
37
|
severityCounts: Record<AuditFindingSeverity, number>;
|
|
38
|
+
/**
|
|
39
|
+
* Per-object-type breakdown — so "is my contact data clean but my pipeline
|
|
40
|
+
* messy?" is answerable without re-auditing. Each type carries its own
|
|
41
|
+
* record-normalized score (same curve as the overall score, scoped to that
|
|
42
|
+
* type's records + findings).
|
|
43
|
+
*/
|
|
44
|
+
byObjectType: Record<"account" | "contact" | "deal", {
|
|
45
|
+
records: number;
|
|
46
|
+
findings: number;
|
|
47
|
+
weightedFindings: number;
|
|
48
|
+
score: number;
|
|
49
|
+
}>;
|
|
38
50
|
};
|
|
39
51
|
|
|
40
52
|
export type HealthRuleDelta = {
|
|
@@ -68,11 +80,21 @@ export type HealthRollup = {
|
|
|
68
80
|
export function computeHealth(plan: PatchPlan, snapshot: CanonicalGtmSnapshot, at: string): HealthEntry {
|
|
69
81
|
const byRule: Record<string, number> = {};
|
|
70
82
|
const severityCounts: Record<AuditFindingSeverity, number> = { info: 0, warning: 0, critical: 0 };
|
|
83
|
+
const typeTally: Record<"account" | "contact" | "deal", { findings: number; weightedFindings: number }> = {
|
|
84
|
+
account: { findings: 0, weightedFindings: 0 },
|
|
85
|
+
contact: { findings: 0, weightedFindings: 0 },
|
|
86
|
+
deal: { findings: 0, weightedFindings: 0 },
|
|
87
|
+
};
|
|
71
88
|
let weightedFindings = 0;
|
|
72
89
|
for (const finding of plan.findings) {
|
|
73
90
|
byRule[finding.ruleId] = (byRule[finding.ruleId] ?? 0) + 1;
|
|
74
91
|
severityCounts[finding.severity] += 1;
|
|
75
92
|
weightedFindings += SEVERITY_WEIGHT[finding.severity];
|
|
93
|
+
const t = finding.objectType;
|
|
94
|
+
if (t === "account" || t === "contact" || t === "deal") {
|
|
95
|
+
typeTally[t].findings += 1;
|
|
96
|
+
typeTally[t].weightedFindings += SEVERITY_WEIGHT[finding.severity];
|
|
97
|
+
}
|
|
76
98
|
}
|
|
77
99
|
|
|
78
100
|
const records = {
|
|
@@ -82,8 +104,23 @@ export function computeHealth(plan: PatchPlan, snapshot: CanonicalGtmSnapshot, a
|
|
|
82
104
|
total: snapshot.accounts.length + snapshot.contacts.length + snapshot.deals.length,
|
|
83
105
|
};
|
|
84
106
|
|
|
85
|
-
const
|
|
86
|
-
|
|
107
|
+
const scoreFor = (weighted: number, recordCount: number) =>
|
|
108
|
+
Math.round(100 / (1 + weighted / Math.max(recordCount, 1)));
|
|
109
|
+
const recordsByType = { account: records.accounts, contact: records.contacts, deal: records.deals };
|
|
110
|
+
const byObjectType = (["account", "contact", "deal"] as const).reduce(
|
|
111
|
+
(acc, t) => {
|
|
112
|
+
acc[t] = {
|
|
113
|
+
records: recordsByType[t],
|
|
114
|
+
findings: typeTally[t].findings,
|
|
115
|
+
weightedFindings: typeTally[t].weightedFindings,
|
|
116
|
+
score: scoreFor(typeTally[t].weightedFindings, recordsByType[t]),
|
|
117
|
+
};
|
|
118
|
+
return acc;
|
|
119
|
+
},
|
|
120
|
+
{} as HealthEntry["byObjectType"],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const score = scoreFor(weightedFindings, records.total);
|
|
87
124
|
|
|
88
125
|
return {
|
|
89
126
|
at,
|
|
@@ -94,6 +131,7 @@ export function computeHealth(plan: PatchPlan, snapshot: CanonicalGtmSnapshot, a
|
|
|
94
131
|
records,
|
|
95
132
|
byRule,
|
|
96
133
|
severityCounts,
|
|
134
|
+
byObjectType,
|
|
97
135
|
};
|
|
98
136
|
}
|
|
99
137
|
|
|
@@ -155,6 +193,13 @@ export function healthToMarkdown(rollup: HealthRollup): string {
|
|
|
155
193
|
"",
|
|
156
194
|
);
|
|
157
195
|
|
|
196
|
+
lines.push("## By object type", "", "| Type | Score | Findings | Records |", "| --- | --- | --- | --- |");
|
|
197
|
+
for (const t of ["account", "contact", "deal"] as const) {
|
|
198
|
+
const b = current.byObjectType[t];
|
|
199
|
+
lines.push(`| ${t} | ${b.score}/100 | ${b.findings} | ${b.records} |`);
|
|
200
|
+
}
|
|
201
|
+
lines.push("");
|
|
202
|
+
|
|
158
203
|
if (rollup.history.length > 1) {
|
|
159
204
|
lines.push("## Trend", "");
|
|
160
205
|
for (const point of rollup.history) {
|
package/src/index.ts
CHANGED
|
@@ -97,6 +97,16 @@ export {
|
|
|
97
97
|
type EnrichWorkItem,
|
|
98
98
|
type MatchOutcome,
|
|
99
99
|
} from "./enrich.ts";
|
|
100
|
+
export {
|
|
101
|
+
scaffoldWorkspace,
|
|
102
|
+
starterEnrichConfig,
|
|
103
|
+
starterIcp,
|
|
104
|
+
starterPlaybook,
|
|
105
|
+
type InitProvider,
|
|
106
|
+
type InitSource,
|
|
107
|
+
type ScaffoldFile,
|
|
108
|
+
type ScaffoldOptions,
|
|
109
|
+
} from "./init.ts";
|
|
100
110
|
export {
|
|
101
111
|
apolloPullKeysForAppend,
|
|
102
112
|
apolloPullKeysForRefresh,
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fullstackgtm init` — scaffold a GTM workspace from cold scratch.
|
|
3
|
+
*
|
|
4
|
+
* The product thesis (see docs/recipes.md): the CLI ships governed PRIMITIVES;
|
|
5
|
+
* the user's coding agent is the orchestrator. There is deliberately no
|
|
6
|
+
* `outbound` mega-verb. `init` is the one piece of scaffolding that earns its
|
|
7
|
+
* keep — it writes the three files a workspace needs so the very first
|
|
8
|
+
* `enrich acquire` / `signals` / `judge` / `draft` commands work, plus a
|
|
9
|
+
* PLAYBOOK that points at the recipes wired with THIS workspace's source and
|
|
10
|
+
* provider.
|
|
11
|
+
*
|
|
12
|
+
* It is a pure file-writer: no network, no credentials, never overwrites
|
|
13
|
+
* without `--force`. The starter ICP is a valid (editable) example so
|
|
14
|
+
* `icp show` / `enrich acquire` run immediately; re-run `icp interview` to
|
|
15
|
+
* replace it with a real one.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { builtinAcquirePreset, ENRICH_CONFIG_FILE_NAME, type EnrichConfig } from "./enrich.ts";
|
|
19
|
+
import { DEFAULT_FIT_THRESHOLD, type Icp } from "./icp.ts";
|
|
20
|
+
|
|
21
|
+
export type InitProvider = "hubspot" | "salesforce";
|
|
22
|
+
export type InitSource = "pipe0" | "explorium" | "linkedin";
|
|
23
|
+
|
|
24
|
+
export type ScaffoldOptions = {
|
|
25
|
+
/** discovery source the acquire preset + playbook are wired for. Default "pipe0". */
|
|
26
|
+
source?: InitSource;
|
|
27
|
+
/** CRM the playbook writes against. Default "hubspot". */
|
|
28
|
+
provider?: InitProvider;
|
|
29
|
+
/** profile the playbook scopes commands to (omitted = default profile). */
|
|
30
|
+
profile?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type ScaffoldFile = { path: string; content: string };
|
|
34
|
+
|
|
35
|
+
/** A placeholder owner id that can never match a real one, so the assign block
|
|
36
|
+
* is VISIBLE (leads route through it) yet safe: an unknown owner resolves to
|
|
37
|
+
* unassigned with a warning, never a wrong owner. Replace before acquiring. */
|
|
38
|
+
const PLACEHOLDER_OWNER = "REPLACE_WITH_OWNER_ID";
|
|
39
|
+
|
|
40
|
+
/** A generic, valid starter ICP. Edit it, or replace via `icp interview`. */
|
|
41
|
+
export function starterIcp(): Icp {
|
|
42
|
+
return {
|
|
43
|
+
name: "Example ICP — edit me (or rebuild with `fullstackgtm icp interview`)",
|
|
44
|
+
firmographics: {
|
|
45
|
+
industries: ["software", "saas"],
|
|
46
|
+
employeeBands: ["51-200", "201-500", "501-1000"],
|
|
47
|
+
geos: ["us"],
|
|
48
|
+
},
|
|
49
|
+
persona: {
|
|
50
|
+
jobLevels: ["vp", "director", "manager"],
|
|
51
|
+
departments: ["sales", "operations"],
|
|
52
|
+
titleKeywords: ["revenue operations", "revops", "sales operations", "gtm operations"],
|
|
53
|
+
},
|
|
54
|
+
scoring: { threshold: DEFAULT_FIT_THRESHOLD },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The acquire preset for the chosen source, with an explicit (placeholder)
|
|
59
|
+
* assign policy so the seam is visible — leads are never silently ownerless. */
|
|
60
|
+
export function starterEnrichConfig(source: InitSource): EnrichConfig {
|
|
61
|
+
const preset = builtinAcquirePreset(source);
|
|
62
|
+
if (!preset?.acquire) {
|
|
63
|
+
// builtinAcquirePreset covers pipe0/explorium/linkedin, so this is unreachable
|
|
64
|
+
// for the typed InitSource set — guard anyway rather than emit a broken file.
|
|
65
|
+
throw new Error(`init: no acquire preset for source "${source}"`);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
...preset,
|
|
69
|
+
acquire: { ...preset.acquire, assign: { strategy: "fixed", ownerId: PLACEHOLDER_OWNER } },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function profileFlag(profile?: string): string {
|
|
74
|
+
return profile ? ` --profile ${profile}` : "";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The workspace PLAYBOOK: the cold-start + outbound-loop recipes wired with the
|
|
78
|
+
* chosen source/provider/profile, pointing at docs/recipes.md for the rest. */
|
|
79
|
+
export function starterPlaybook(opts: Required<Pick<ScaffoldOptions, "source" | "provider">> & { profile?: string }): string {
|
|
80
|
+
const { source, provider, profile } = opts;
|
|
81
|
+
const p = profileFlag(profile);
|
|
82
|
+
const loginSource = source === "linkedin" ? "heyreach" : source;
|
|
83
|
+
return `# Workspace playbook
|
|
84
|
+
|
|
85
|
+
Scaffolded by \`fullstackgtm init\` for **${provider}**, discovery via **${source}**${
|
|
86
|
+
profile ? `, profile **${profile}**` : ""
|
|
87
|
+
}.
|
|
88
|
+
|
|
89
|
+
This CLI ships governed **primitives** — there is no \`outbound\` command. **You
|
|
90
|
+
(the coding agent) are the orchestrator:** chain the verbs into the play the
|
|
91
|
+
operator wants, surface the one approve gate, and bridge the last mile to the
|
|
92
|
+
sender. **The package never sends** — \`draft\` produces an approved task/opener;
|
|
93
|
+
the actual send happens in the operator's own channel tool.
|
|
94
|
+
|
|
95
|
+
Full recipe set: **docs/recipes.md** (cold-start, the trigger→judge→draft
|
|
96
|
+
outbound loop, scheduled-continuous, ABM-from-companies, hygiene-gated).
|
|
97
|
+
|
|
98
|
+
## 0. Connect (secrets via stdin/env, never argv)
|
|
99
|
+
|
|
100
|
+
\`\`\`bash
|
|
101
|
+
echo "$${provider === "hubspot" ? "HUBSPOT_TOKEN" : "SALESFORCE_ACCESS_TOKEN"}" | fullstackgtm login ${provider}${p}
|
|
102
|
+
echo "$${loginSource.toUpperCase()}_API_KEY" | fullstackgtm login ${loginSource}${p}
|
|
103
|
+
\`\`\`
|
|
104
|
+
|
|
105
|
+
## 1. Edit your targeting + assignment
|
|
106
|
+
|
|
107
|
+
- \`icp.json\` — the starter ICP. Edit it, or rebuild: \`fullstackgtm icp interview\`
|
|
108
|
+
(an agent drives the questions) → \`fullstackgtm icp set answers.json\`.
|
|
109
|
+
- \`${ENRICH_CONFIG_FILE_NAME}\` — set \`acquire.assign.ownerId\` (currently
|
|
110
|
+
\`${PLACEHOLDER_OWNER}\`) so acquired leads are never ownerless, or pass
|
|
111
|
+
\`--assign-owner <id>\` per run. Tune \`acquire.budget\` (records + spend caps).
|
|
112
|
+
|
|
113
|
+
## 2. Cold start — fill the CRM with targeted, owned, emailed leads
|
|
114
|
+
|
|
115
|
+
\`\`\`bash
|
|
116
|
+
fullstackgtm enrich acquire --source ${source} --provider ${provider}${p} --json # dry-run plan, writes NOTHING
|
|
117
|
+
fullstackgtm enrich acquire --source ${source} --provider ${provider}${p} --save # persist as needs_approval
|
|
118
|
+
fullstackgtm plans approve <plan-id>${p} --operations all
|
|
119
|
+
fullstackgtm apply --plan-id <plan-id> --provider ${provider}${p}
|
|
120
|
+
\`\`\`
|
|
121
|
+
|
|
122
|
+
Each lead lands owner-stamped and linked to a domain-stamped **account**, so the
|
|
123
|
+
signals/judge layer can watch it.
|
|
124
|
+
|
|
125
|
+
## 3. Outbound loop — reach an account the week something changes
|
|
126
|
+
|
|
127
|
+
\`\`\`bash
|
|
128
|
+
fullstackgtm signals fetch --bucket job,funding --watchlist crm:<segment>${p} --save
|
|
129
|
+
fullstackgtm icp judge --signals-from latest --provider ${provider}${p} --save --json # pass the snapshot → resolves accountId + contact
|
|
130
|
+
fullstackgtm draft --from-judge latest --channel email${p} --save --json # one grounded opener per hot account, as a create_task
|
|
131
|
+
fullstackgtm plans approve <plan-id>${p} --operations all
|
|
132
|
+
fullstackgtm apply --plan-id <plan-id> --provider ${provider}${p}
|
|
133
|
+
# → the agent sends the approved opener via the operator's channel tool, then:
|
|
134
|
+
fullstackgtm signals outcome --account <domain> --contact <contactId> --result replied${p}
|
|
135
|
+
\`\`\`
|
|
136
|
+
|
|
137
|
+
A domain-only judge decision (account not yet in the CRM) is rejected by \`draft\`
|
|
138
|
+
with "acquire it first" — run step 2 for that account, then re-judge.
|
|
139
|
+
|
|
140
|
+
## The boundary
|
|
141
|
+
|
|
142
|
+
- **Read freely. Write only through \`plans approve → apply\`.**
|
|
143
|
+
- **The package never sends.** \`draft\` is the last governed step.
|
|
144
|
+
- **You are the orchestrator.** These are starting points — compose them.
|
|
145
|
+
`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Build the set of files `init` would write (pure — no IO). The caller decides
|
|
150
|
+
* which to actually write (skipping existing files unless --force).
|
|
151
|
+
*/
|
|
152
|
+
export function scaffoldWorkspace(opts: ScaffoldOptions = {}): ScaffoldFile[] {
|
|
153
|
+
const source: InitSource = opts.source ?? "pipe0";
|
|
154
|
+
const provider: InitProvider = opts.provider ?? "hubspot";
|
|
155
|
+
return [
|
|
156
|
+
{ path: "icp.json", content: `${JSON.stringify(starterIcp(), null, 2)}\n` },
|
|
157
|
+
{
|
|
158
|
+
path: ENRICH_CONFIG_FILE_NAME,
|
|
159
|
+
content: `${JSON.stringify(starterEnrichConfig(source), null, 2)}\n`,
|
|
160
|
+
},
|
|
161
|
+
{ path: "PLAYBOOK.md", content: starterPlaybook({ source, provider, profile: opts.profile }) },
|
|
162
|
+
];
|
|
163
|
+
}
|
package/src/judge.ts
CHANGED
|
@@ -35,6 +35,23 @@ export type JudgeDecisionKind = "send" | "nurture" | "skip";
|
|
|
35
35
|
|
|
36
36
|
export type JudgeDecision = {
|
|
37
37
|
accountDomain: string;
|
|
38
|
+
/**
|
|
39
|
+
* The CRM account this domain resolves to, when a snapshot was provided.
|
|
40
|
+
* Absent = the account is not in the CRM (a net-new domain) — downstream
|
|
41
|
+
* verbs must acquire it before they can write against it.
|
|
42
|
+
*/
|
|
43
|
+
accountId?: string;
|
|
44
|
+
/**
|
|
45
|
+
* The contact at the account to reach, when resolvable from the snapshot —
|
|
46
|
+
* the answer to "who do I message at this hot account". `draft` targets this
|
|
47
|
+
* contact's id; absent contact + present accountId targets the account.
|
|
48
|
+
*/
|
|
49
|
+
contact?: ContactRef;
|
|
50
|
+
/**
|
|
51
|
+
* All in-CRM contacts at the account (primary first), capped — so an agent can
|
|
52
|
+
* multi-thread beyond the single primary. `contact` is `contacts[0]`.
|
|
53
|
+
*/
|
|
54
|
+
contacts?: ContactRef[];
|
|
38
55
|
/** 0-100. */
|
|
39
56
|
score: number;
|
|
40
57
|
decision: JudgeDecisionKind;
|
|
@@ -333,22 +350,75 @@ export function accountRecentlyTouched(
|
|
|
333
350
|
}
|
|
334
351
|
|
|
335
352
|
/**
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
* fit
|
|
353
|
+
* Resolve a signal's account domain to the CRM account record and the best
|
|
354
|
+
* contact at it (preferring one with a title). The single domain→account→contact
|
|
355
|
+
* join used by both fit scoring and target surfacing — so "who do I message at
|
|
356
|
+
* this account" is computed once and consistently.
|
|
339
357
|
*/
|
|
340
|
-
|
|
358
|
+
function findAccountAndBestContact(
|
|
341
359
|
accountDomain: string,
|
|
342
360
|
snapshot: CanonicalGtmSnapshot,
|
|
343
|
-
): {
|
|
361
|
+
): {
|
|
362
|
+
account: CanonicalGtmSnapshot["accounts"][number];
|
|
363
|
+
contact?: CanonicalGtmSnapshot["contacts"][number];
|
|
364
|
+
contacts: CanonicalGtmSnapshot["contacts"];
|
|
365
|
+
} | undefined {
|
|
344
366
|
const domain = normalizeAccountDomain(accountDomain);
|
|
345
367
|
if (!domain) return undefined;
|
|
346
368
|
const account = (snapshot.accounts ?? []).find((a) => normalizeAccountDomain(a.domain ?? "") === domain);
|
|
347
369
|
if (!account) return undefined;
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
370
|
+
// Titled contacts first (a title is what fit scores on); the first is primary.
|
|
371
|
+
const contacts = (snapshot.contacts ?? [])
|
|
372
|
+
.filter((c) => c.accountId === account.id)
|
|
373
|
+
.sort((a, b) => (b.title ? 1 : 0) - (a.title ? 1 : 0));
|
|
374
|
+
return { account, contact: contacts[0], contacts };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Best-matching contact for an account, shaped for fit scoring (the title is
|
|
379
|
+
* what `scoreProspectAgainstIcp` reads). Returns undefined when the
|
|
380
|
+
* account/contact isn't in the snapshot.
|
|
381
|
+
*/
|
|
382
|
+
export function bestContactForAccount(
|
|
383
|
+
accountDomain: string,
|
|
384
|
+
snapshot: CanonicalGtmSnapshot,
|
|
385
|
+
): { jobTitle?: string; jobLevel?: string; jobDepartment?: string; headline?: string } | undefined {
|
|
386
|
+
const found = findAccountAndBestContact(accountDomain, snapshot);
|
|
387
|
+
if (!found?.contact) return undefined;
|
|
388
|
+
return { jobTitle: found.contact.title };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Resolve the CRM target for an account domain: its `accountId` (when the
|
|
393
|
+
* account exists in the snapshot) and the best `contact` to reach (id + email +
|
|
394
|
+
* title). This is what `draft` writes against — a real record id, never the
|
|
395
|
+
* domain. `{}` when the account is not in the CRM (a net-new domain).
|
|
396
|
+
*/
|
|
397
|
+
export type ContactRef = { id: string; email?: string; title?: string };
|
|
398
|
+
|
|
399
|
+
/** Cap on candidate contacts surfaced per account (the rest stay in the CRM). */
|
|
400
|
+
const MAX_CANDIDATE_CONTACTS = 10;
|
|
401
|
+
|
|
402
|
+
const toContactRef = (c: CanonicalGtmSnapshot["contacts"][number]): ContactRef => ({
|
|
403
|
+
id: c.id,
|
|
404
|
+
...(c.email ? { email: c.email } : {}),
|
|
405
|
+
...(c.title ? { title: c.title } : {}),
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
export function resolveAccountTarget(
|
|
409
|
+
accountDomain: string,
|
|
410
|
+
snapshot: CanonicalGtmSnapshot,
|
|
411
|
+
): { accountId?: string; contact?: ContactRef; contacts?: ContactRef[] } {
|
|
412
|
+
const found = findAccountAndBestContact(accountDomain, snapshot);
|
|
413
|
+
if (!found) return {};
|
|
414
|
+
const { account, contact, contacts } = found;
|
|
415
|
+
return {
|
|
416
|
+
accountId: account.id,
|
|
417
|
+
...(contact ? { contact: toContactRef(contact) } : {}),
|
|
418
|
+
// The candidate contacts at the account, so an agent can multi-thread
|
|
419
|
+
// beyond the single primary. Capped; primary is contacts[0].
|
|
420
|
+
...(contacts.length ? { contacts: contacts.slice(0, MAX_CANDIDATE_CONTACTS).map(toContactRef) } : {}),
|
|
421
|
+
};
|
|
352
422
|
}
|
|
353
423
|
|
|
354
424
|
// ---------------------------------------------------------------------------
|
|
@@ -559,10 +629,11 @@ export async function judgeSignals(opts: {
|
|
|
559
629
|
|
|
560
630
|
const decisions: JudgeDecision[] = [];
|
|
561
631
|
for (const [domain, signals] of byAccount) {
|
|
632
|
+
// Memory + fit stay gated on --with-history (scoring semantics unchanged).
|
|
562
633
|
const recentlyTouched =
|
|
563
634
|
opts.withHistory && opts.snapshot ? accountRecentlyTouched(domain, opts.snapshot, now) : false;
|
|
564
635
|
const bestContact =
|
|
565
|
-
opts.icp && opts.snapshot ? bestContactForAccount(domain, opts.snapshot) : undefined;
|
|
636
|
+
opts.withHistory && opts.icp && opts.snapshot ? bestContactForAccount(domain, opts.snapshot) : undefined;
|
|
566
637
|
const score = scoreAccount({
|
|
567
638
|
accountDomain: domain,
|
|
568
639
|
signals,
|
|
@@ -578,7 +649,10 @@ export async function judgeSignals(opts: {
|
|
|
578
649
|
opts.llm && opts.promptTemplate
|
|
579
650
|
? await judgeDecisionLlm(score, opts.promptTemplate, opts.llm)
|
|
580
651
|
: deterministicDecision(score);
|
|
581
|
-
|
|
652
|
+
// Surface the CRM target (accountId + best contact) whenever a snapshot is
|
|
653
|
+
// present — independent of --with-history. This is what makes a decision
|
|
654
|
+
// actionable: draft writes against contact.id / accountId, never the domain.
|
|
655
|
+
decisions.push(opts.snapshot ? { ...decision, ...resolveAccountTarget(domain, opts.snapshot) } : decision);
|
|
582
656
|
}
|
|
583
657
|
|
|
584
658
|
return decisions.sort((a, b) => b.score - a.score || a.accountDomain.localeCompare(b.accountDomain));
|