fullstackgtm 0.41.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 +97 -0
- package/dist/calls.d.ts +13 -0
- package/dist/calls.js +14 -3
- package/dist/cli.js +85 -5
- package/dist/connector.js +34 -0
- package/dist/connectors/hubspot.js +182 -24
- package/dist/connectors/salesforce.js +152 -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 +23 -0
- package/docs/api.md +16 -1
- 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/connector.ts +37 -0
- package/src/connectors/hubspot.ts +180 -23
- package/src/connectors/salesforce.ts +152 -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 +23 -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.
|
|
@@ -597,6 +622,118 @@ export function createSalesforceConnector(
|
|
|
597
622
|
};
|
|
598
623
|
}
|
|
599
624
|
|
|
625
|
+
/**
|
|
626
|
+
* Batch create_record for contacts: bulk resolve-first (SOQL IN), then create
|
|
627
|
+
* the misses via the Composite sObject Collections API (allOrNone:false,
|
|
628
|
+
* 200/call), instead of two round-trips per lead. A record-level failure in
|
|
629
|
+
* the collection (e.g. a duplicate rule) falls back to per-record createRecord
|
|
630
|
+
* so one bad row never sinks the rest. Returns one result per input op.
|
|
631
|
+
*/
|
|
632
|
+
async function applyCreateContactsBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
|
|
633
|
+
const byId = new Map<string, PatchOperationResult>();
|
|
634
|
+
const groups = new Map<string, PatchOperation[]>();
|
|
635
|
+
for (const op of operations) {
|
|
636
|
+
const matchKey = (op.afterValue as CreateRecordPayload | undefined)?.matchKey || "email";
|
|
637
|
+
const searchField = mappedField(mappings, "contacts", matchKey, SALESFORCE_DEFAULT_FIELD_MAPPINGS.contacts[matchKey] ?? matchKey);
|
|
638
|
+
let arr = groups.get(searchField);
|
|
639
|
+
if (!arr) groups.set(searchField, (arr = []));
|
|
640
|
+
arr.push(op);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
for (const [searchField, ops] of groups) {
|
|
644
|
+
// 1. Bulk resolve-first via SOQL IN (chunked; values SOQL-escaped).
|
|
645
|
+
const existing = new Map<string, string>();
|
|
646
|
+
const uniqueValues = [
|
|
647
|
+
...new Set(ops.map((op) => String((op.afterValue as CreateRecordPayload).matchValue ?? "").trim()).filter(Boolean)),
|
|
648
|
+
];
|
|
649
|
+
for (let i = 0; i < uniqueValues.length; i += 200) {
|
|
650
|
+
const inList = uniqueValues
|
|
651
|
+
.slice(i, i + 200)
|
|
652
|
+
.map((v) => `'${v.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`)
|
|
653
|
+
.join(",");
|
|
654
|
+
const rows = await query(`SELECT Id, ${searchField} FROM Contact WHERE ${searchField} IN (${inList})`);
|
|
655
|
+
for (const row of rows) {
|
|
656
|
+
const v = row?.[searchField];
|
|
657
|
+
if (typeof v === "string" && row.Id) existing.set(v.toLowerCase(), String(row.Id));
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// 2. Partition: existing / already-this-run / dup-in-batch / to-create.
|
|
662
|
+
const toCreate: PatchOperation[] = [];
|
|
663
|
+
const seenInBatch = new Set<string>();
|
|
664
|
+
for (const op of ops) {
|
|
665
|
+
const payload = op.afterValue as CreateRecordPayload;
|
|
666
|
+
const matchKey = payload.matchKey || "email";
|
|
667
|
+
const matchValue = String(payload.matchValue ?? "").trim();
|
|
668
|
+
if (!matchValue) {
|
|
669
|
+
byId.set(op.id, { operationId: op.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." });
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
const lower = matchValue.toLowerCase();
|
|
673
|
+
const dedupeKey = `${matchKey}:${lower}`;
|
|
674
|
+
const already = createdContactsByMatch.get(dedupeKey);
|
|
675
|
+
if (already) {
|
|
676
|
+
byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: already, existing: true } });
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
const existingId = existing.get(lower);
|
|
680
|
+
if (existingId) {
|
|
681
|
+
createdContactsByMatch.set(dedupeKey, existingId);
|
|
682
|
+
byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already exists (${existingId}); resolve-first declined to create.`, providerData: { id: existingId, existing: true } });
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
if (seenInBatch.has(lower)) {
|
|
686
|
+
byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} duplicated within this batch; not duplicating.` });
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
seenInBatch.add(lower);
|
|
690
|
+
toCreate.push(op);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// 3. Bulk create via Composite sObject Collections (allOrNone:false →
|
|
694
|
+
// per-record results in request order).
|
|
695
|
+
for (let i = 0; i < toCreate.length; i += 200) {
|
|
696
|
+
const chunk = toCreate.slice(i, i + 200);
|
|
697
|
+
const records = chunk.map((op) => {
|
|
698
|
+
const payload = op.afterValue as CreateRecordPayload;
|
|
699
|
+
const fields: Record<string, string> = { ...payload.properties };
|
|
700
|
+
if (payload.ownerId) {
|
|
701
|
+
const ownerField = mappedField(mappings, "contacts", "ownerId", SALESFORCE_DEFAULT_FIELD_MAPPINGS.contacts.ownerId ?? "OwnerId");
|
|
702
|
+
if (!fields[ownerField]) fields[ownerField] = String(payload.ownerId);
|
|
703
|
+
}
|
|
704
|
+
return { attributes: { type: "Contact" }, ...fields };
|
|
705
|
+
});
|
|
706
|
+
let results: Array<{ id?: string; success?: boolean }>;
|
|
707
|
+
try {
|
|
708
|
+
results = (await request(`/services/data/${apiVersion}/composite/sobjects`, {
|
|
709
|
+
method: "POST",
|
|
710
|
+
body: JSON.stringify({ allOrNone: false, records }),
|
|
711
|
+
})) as Array<{ id?: string; success?: boolean }>;
|
|
712
|
+
} catch {
|
|
713
|
+
for (const op of chunk) byId.set(op.id, await createRecord(op));
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
717
|
+
const op = chunk[j];
|
|
718
|
+
const res = Array.isArray(results) ? results[j] : undefined;
|
|
719
|
+
const payload = op.afterValue as CreateRecordPayload;
|
|
720
|
+
const matchKey = payload.matchKey || "email";
|
|
721
|
+
const matchValue = String(payload.matchValue).trim();
|
|
722
|
+
if (res?.success && res.id) {
|
|
723
|
+
createdContactsByMatch.set(`${matchKey}:${matchValue.toLowerCase()}`, String(res.id));
|
|
724
|
+
byId.set(op.id, { operationId: op.id, status: "applied", detail: `Created contact ${matchKey}=${matchValue} (${res.id}).`, providerData: { id: String(res.id), created: true } });
|
|
725
|
+
} else {
|
|
726
|
+
byId.set(op.id, await createRecord(op)); // record-level failure → isolate per record
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return operations.map(
|
|
733
|
+
(op) => byId.get(op.id) ?? { operationId: op.id, status: "skipped", detail: "create_record was not processed." },
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
600
737
|
async function applyOperation(operation: PatchOperation): Promise<PatchOperationResult> {
|
|
601
738
|
try {
|
|
602
739
|
switch (operation.operation) {
|
|
@@ -614,7 +751,7 @@ export function createSalesforceConnector(
|
|
|
614
751
|
if (!name) {
|
|
615
752
|
return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
|
|
616
753
|
}
|
|
617
|
-
const resolved = await
|
|
754
|
+
const resolved = await resolveOrCreateAccount(name);
|
|
618
755
|
if ("ambiguous" in resolved) {
|
|
619
756
|
return {
|
|
620
757
|
operationId: operation.id,
|
|
@@ -683,6 +820,7 @@ export function createSalesforceConnector(
|
|
|
683
820
|
fetchSnapshot,
|
|
684
821
|
fetchChanges,
|
|
685
822
|
applyOperation,
|
|
823
|
+
applyCreateContactsBatch,
|
|
686
824
|
readField,
|
|
687
825
|
};
|
|
688
826
|
}
|
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
|
+
}
|