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
|
@@ -37,29 +37,53 @@ export function createSalesforceConnector(options) {
|
|
|
37
37
|
// confirmed miss is created, and an ambiguous name (>1 match) is refused.
|
|
38
38
|
// Caches within the run so the same name is never created twice — shared by
|
|
39
39
|
// `link_record`'s create:<Name> path and `create_record`'s company linking.
|
|
40
|
-
async function
|
|
41
|
-
const
|
|
42
|
-
const cached = createdAccountsByName.get(
|
|
40
|
+
async function resolveOrCreateAccount(name, domain) {
|
|
41
|
+
const cacheKey = domain ? `d:${domain.toLowerCase()}` : `n:${name.toLowerCase()}`;
|
|
42
|
+
const cached = createdAccountsByName.get(cacheKey);
|
|
43
43
|
if (cached)
|
|
44
44
|
return { id: cached, createdNew: false };
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
const esc = (v) => v.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
46
|
+
// 1. Domain-first match on Website (the accurate key; Website often stores a
|
|
47
|
+
// full URL, so match by substring).
|
|
48
|
+
if (domain) {
|
|
49
|
+
const byDomain = await query(`SELECT Id FROM Account WHERE Website LIKE '%${esc(domain)}%' LIMIT 3`);
|
|
50
|
+
if (byDomain.length > 1)
|
|
51
|
+
return { ambiguous: byDomain.length };
|
|
52
|
+
if (byDomain.length === 1) {
|
|
53
|
+
const id = String(byDomain[0].Id);
|
|
54
|
+
createdAccountsByName.set(cacheKey, id);
|
|
55
|
+
return { id, createdNew: false };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// 2. Exact-name match; stamp Website if the account has none (fill-blank).
|
|
59
|
+
const byName = await query(`SELECT Id, Website FROM Account WHERE Name = '${esc(name)}' LIMIT 3`);
|
|
60
|
+
if (byName.length > 1)
|
|
61
|
+
return { ambiguous: byName.length };
|
|
49
62
|
let id;
|
|
50
63
|
let createdNew = false;
|
|
51
|
-
if (
|
|
52
|
-
id = String(
|
|
64
|
+
if (byName.length === 1) {
|
|
65
|
+
id = String(byName[0].Id);
|
|
66
|
+
if (domain && !stringOrUndefined(byName[0].Website)) {
|
|
67
|
+
try {
|
|
68
|
+
await request(`/services/data/${apiVersion}/sobjects/Account/${encodeURIComponent(id)}`, {
|
|
69
|
+
method: "PATCH",
|
|
70
|
+
body: JSON.stringify({ Website: domain }),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// best-effort fill — a failed Website stamp must not sink the create.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
53
77
|
}
|
|
54
78
|
else {
|
|
55
79
|
const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
|
|
56
80
|
method: "POST",
|
|
57
|
-
body: JSON.stringify({ Name: name }),
|
|
81
|
+
body: JSON.stringify({ Name: name, ...(domain ? { Website: domain } : {}) }),
|
|
58
82
|
});
|
|
59
83
|
id = String(created.id);
|
|
60
84
|
createdNew = true;
|
|
61
85
|
}
|
|
62
|
-
createdAccountsByName.set(
|
|
86
|
+
createdAccountsByName.set(cacheKey, id);
|
|
63
87
|
return { id, createdNew };
|
|
64
88
|
}
|
|
65
89
|
async function request(path, init = {}) {
|
|
@@ -427,7 +451,7 @@ export function createSalesforceConnector(options) {
|
|
|
427
451
|
return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
|
|
428
452
|
}
|
|
429
453
|
if (operation.objectType === "account") {
|
|
430
|
-
const resolved = await
|
|
454
|
+
const resolved = await resolveOrCreateAccount(matchValue);
|
|
431
455
|
if ("ambiguous" in resolved) {
|
|
432
456
|
return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — ${resolved.ambiguous} accounts named "${matchValue}". Not creating.` };
|
|
433
457
|
}
|
|
@@ -465,7 +489,7 @@ export function createSalesforceConnector(options) {
|
|
|
465
489
|
let companyNote = "";
|
|
466
490
|
if (payload.associateCompanyName) {
|
|
467
491
|
try {
|
|
468
|
-
const resolved = await
|
|
492
|
+
const resolved = await resolveOrCreateAccount(payload.associateCompanyName, payload.associateCompanyDomain);
|
|
469
493
|
if ("ambiguous" in resolved) {
|
|
470
494
|
companyNote = ` Account "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
|
|
471
495
|
}
|
|
@@ -474,7 +498,8 @@ export function createSalesforceConnector(options) {
|
|
|
474
498
|
method: "PATCH",
|
|
475
499
|
body: JSON.stringify({ AccountId: resolved.id }),
|
|
476
500
|
});
|
|
477
|
-
|
|
501
|
+
const domainNote = payload.associateCompanyDomain ? ` (${payload.associateCompanyDomain})` : "";
|
|
502
|
+
companyNote = ` Linked to account "${payload.associateCompanyName}"${domainNote} (${resolved.id}).`;
|
|
478
503
|
}
|
|
479
504
|
}
|
|
480
505
|
catch (error) {
|
|
@@ -489,6 +514,117 @@ export function createSalesforceConnector(options) {
|
|
|
489
514
|
providerData: { id: contactId, created: true },
|
|
490
515
|
};
|
|
491
516
|
}
|
|
517
|
+
/**
|
|
518
|
+
* Batch create_record for contacts: bulk resolve-first (SOQL IN), then create
|
|
519
|
+
* the misses via the Composite sObject Collections API (allOrNone:false,
|
|
520
|
+
* 200/call), instead of two round-trips per lead. A record-level failure in
|
|
521
|
+
* the collection (e.g. a duplicate rule) falls back to per-record createRecord
|
|
522
|
+
* so one bad row never sinks the rest. Returns one result per input op.
|
|
523
|
+
*/
|
|
524
|
+
async function applyCreateContactsBatch(operations) {
|
|
525
|
+
const byId = new Map();
|
|
526
|
+
const groups = new Map();
|
|
527
|
+
for (const op of operations) {
|
|
528
|
+
const matchKey = op.afterValue?.matchKey || "email";
|
|
529
|
+
const searchField = mappedField(mappings, "contacts", matchKey, SALESFORCE_DEFAULT_FIELD_MAPPINGS.contacts[matchKey] ?? matchKey);
|
|
530
|
+
let arr = groups.get(searchField);
|
|
531
|
+
if (!arr)
|
|
532
|
+
groups.set(searchField, (arr = []));
|
|
533
|
+
arr.push(op);
|
|
534
|
+
}
|
|
535
|
+
for (const [searchField, ops] of groups) {
|
|
536
|
+
// 1. Bulk resolve-first via SOQL IN (chunked; values SOQL-escaped).
|
|
537
|
+
const existing = new Map();
|
|
538
|
+
const uniqueValues = [
|
|
539
|
+
...new Set(ops.map((op) => String(op.afterValue.matchValue ?? "").trim()).filter(Boolean)),
|
|
540
|
+
];
|
|
541
|
+
for (let i = 0; i < uniqueValues.length; i += 200) {
|
|
542
|
+
const inList = uniqueValues
|
|
543
|
+
.slice(i, i + 200)
|
|
544
|
+
.map((v) => `'${v.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`)
|
|
545
|
+
.join(",");
|
|
546
|
+
const rows = await query(`SELECT Id, ${searchField} FROM Contact WHERE ${searchField} IN (${inList})`);
|
|
547
|
+
for (const row of rows) {
|
|
548
|
+
const v = row?.[searchField];
|
|
549
|
+
if (typeof v === "string" && row.Id)
|
|
550
|
+
existing.set(v.toLowerCase(), String(row.Id));
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// 2. Partition: existing / already-this-run / dup-in-batch / to-create.
|
|
554
|
+
const toCreate = [];
|
|
555
|
+
const seenInBatch = new Set();
|
|
556
|
+
for (const op of ops) {
|
|
557
|
+
const payload = op.afterValue;
|
|
558
|
+
const matchKey = payload.matchKey || "email";
|
|
559
|
+
const matchValue = String(payload.matchValue ?? "").trim();
|
|
560
|
+
if (!matchValue) {
|
|
561
|
+
byId.set(op.id, { operationId: op.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." });
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
const lower = matchValue.toLowerCase();
|
|
565
|
+
const dedupeKey = `${matchKey}:${lower}`;
|
|
566
|
+
const already = createdContactsByMatch.get(dedupeKey);
|
|
567
|
+
if (already) {
|
|
568
|
+
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 } });
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
const existingId = existing.get(lower);
|
|
572
|
+
if (existingId) {
|
|
573
|
+
createdContactsByMatch.set(dedupeKey, existingId);
|
|
574
|
+
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 } });
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (seenInBatch.has(lower)) {
|
|
578
|
+
byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} duplicated within this batch; not duplicating.` });
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
seenInBatch.add(lower);
|
|
582
|
+
toCreate.push(op);
|
|
583
|
+
}
|
|
584
|
+
// 3. Bulk create via Composite sObject Collections (allOrNone:false →
|
|
585
|
+
// per-record results in request order).
|
|
586
|
+
for (let i = 0; i < toCreate.length; i += 200) {
|
|
587
|
+
const chunk = toCreate.slice(i, i + 200);
|
|
588
|
+
const records = chunk.map((op) => {
|
|
589
|
+
const payload = op.afterValue;
|
|
590
|
+
const fields = { ...payload.properties };
|
|
591
|
+
if (payload.ownerId) {
|
|
592
|
+
const ownerField = mappedField(mappings, "contacts", "ownerId", SALESFORCE_DEFAULT_FIELD_MAPPINGS.contacts.ownerId ?? "OwnerId");
|
|
593
|
+
if (!fields[ownerField])
|
|
594
|
+
fields[ownerField] = String(payload.ownerId);
|
|
595
|
+
}
|
|
596
|
+
return { attributes: { type: "Contact" }, ...fields };
|
|
597
|
+
});
|
|
598
|
+
let results;
|
|
599
|
+
try {
|
|
600
|
+
results = (await request(`/services/data/${apiVersion}/composite/sobjects`, {
|
|
601
|
+
method: "POST",
|
|
602
|
+
body: JSON.stringify({ allOrNone: false, records }),
|
|
603
|
+
}));
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
for (const op of chunk)
|
|
607
|
+
byId.set(op.id, await createRecord(op));
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
611
|
+
const op = chunk[j];
|
|
612
|
+
const res = Array.isArray(results) ? results[j] : undefined;
|
|
613
|
+
const payload = op.afterValue;
|
|
614
|
+
const matchKey = payload.matchKey || "email";
|
|
615
|
+
const matchValue = String(payload.matchValue).trim();
|
|
616
|
+
if (res?.success && res.id) {
|
|
617
|
+
createdContactsByMatch.set(`${matchKey}:${matchValue.toLowerCase()}`, String(res.id));
|
|
618
|
+
byId.set(op.id, { operationId: op.id, status: "applied", detail: `Created contact ${matchKey}=${matchValue} (${res.id}).`, providerData: { id: String(res.id), created: true } });
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
byId.set(op.id, await createRecord(op)); // record-level failure → isolate per record
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return operations.map((op) => byId.get(op.id) ?? { operationId: op.id, status: "skipped", detail: "create_record was not processed." });
|
|
627
|
+
}
|
|
492
628
|
async function applyOperation(operation) {
|
|
493
629
|
try {
|
|
494
630
|
switch (operation.operation) {
|
|
@@ -506,7 +642,7 @@ export function createSalesforceConnector(options) {
|
|
|
506
642
|
if (!name) {
|
|
507
643
|
return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
|
|
508
644
|
}
|
|
509
|
-
const resolved = await
|
|
645
|
+
const resolved = await resolveOrCreateAccount(name);
|
|
510
646
|
if ("ambiguous" in resolved) {
|
|
511
647
|
return {
|
|
512
648
|
operationId: operation.id,
|
|
@@ -568,6 +704,7 @@ export function createSalesforceConnector(options) {
|
|
|
568
704
|
fetchSnapshot,
|
|
569
705
|
fetchChanges,
|
|
570
706
|
applyOperation,
|
|
707
|
+
applyCreateContactsBatch,
|
|
571
708
|
readField,
|
|
572
709
|
};
|
|
573
710
|
}
|
package/dist/draft.js
CHANGED
|
@@ -197,9 +197,6 @@ export function draft(opts) {
|
|
|
197
197
|
const minScore = opts.minScore ?? 80;
|
|
198
198
|
const channel = opts.channel ?? "task";
|
|
199
199
|
const freshnessDays = opts.freshnessDays ?? DEFAULT_FRESHNESS_DAYS;
|
|
200
|
-
// `task`/`email`/`linkedin` all write a CRM task through the same gate; the
|
|
201
|
-
// object the task hangs off is the contact when known, else the account.
|
|
202
|
-
const objectType = "contact";
|
|
203
200
|
const hot = opts.decisions
|
|
204
201
|
.filter((d) => d.decision === "send" && d.score >= minScore)
|
|
205
202
|
.sort((a, b) => b.score - a.score || a.accountDomain.localeCompare(b.accountDomain));
|
|
@@ -235,17 +232,42 @@ export function draft(opts) {
|
|
|
235
232
|
});
|
|
236
233
|
continue;
|
|
237
234
|
}
|
|
235
|
+
// Resolve a REAL CRM target from the judge decision: a contact id (preferred
|
|
236
|
+
// — that's who we message), else the account id. A domain-only decision
|
|
237
|
+
// (the account isn't in the CRM yet) cannot hang a task off a record — reject
|
|
238
|
+
// it with the fix, instead of forging a contact-typed op carrying a domain.
|
|
239
|
+
let objectType;
|
|
240
|
+
let objectId;
|
|
241
|
+
let targetNote;
|
|
242
|
+
if (decision.contact?.id) {
|
|
243
|
+
objectType = "contact";
|
|
244
|
+
objectId = decision.contact.id;
|
|
245
|
+
targetNote = `contact ${decision.contact.email ?? decision.contact.id}`;
|
|
246
|
+
}
|
|
247
|
+
else if (decision.accountId) {
|
|
248
|
+
objectType = "account";
|
|
249
|
+
objectId = decision.accountId;
|
|
250
|
+
targetNote = `account ${domain}`;
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
rejected.push({
|
|
254
|
+
accountDomain: domain,
|
|
255
|
+
opener,
|
|
256
|
+
reason: `account ${domain} is not in the CRM — acquire it first (enrich acquire), then judge with a snapshot, before drafting`,
|
|
257
|
+
});
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
238
260
|
const stale = isStaleTrigger(signal.firstSeen, now, freshnessDays);
|
|
239
261
|
const ev = draftEvidence(signal, nowIso);
|
|
240
262
|
evidence.push(ev);
|
|
241
|
-
const reasonBase = `Signal-grounded opener for ${domain}: "${signal.trigger}"`;
|
|
263
|
+
const reasonBase = `Signal-grounded opener for ${domain} → ${targetNote}: "${signal.trigger}"`;
|
|
242
264
|
const reason = stale
|
|
243
265
|
? `${reasonBase} [staleTrigger: grounding signal first seen ${signal.firstSeen}, older than ${freshnessDays}d — could this have gone out last month unchanged?]`
|
|
244
266
|
: reasonBase;
|
|
245
267
|
operations.push({
|
|
246
268
|
id: draftOperationId(channel, domain),
|
|
247
269
|
objectType,
|
|
248
|
-
objectId
|
|
270
|
+
objectId,
|
|
249
271
|
operation: "create_task",
|
|
250
272
|
field: "follow_up_task",
|
|
251
273
|
beforeValue: null,
|
package/dist/enrich.d.ts
CHANGED
|
@@ -68,6 +68,12 @@ export type AcquireCreateMap = {
|
|
|
68
68
|
matchKey: string;
|
|
69
69
|
/** Optional source path to a company name; the connector resolves-or-creates it. */
|
|
70
70
|
associateCompanyFrom?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Optional source path to the company domain. With it (or a derivable email
|
|
73
|
+
* domain) the connector resolves the account by domain and stamps the domain
|
|
74
|
+
* on it — so the lead's account is signal-watchable, not just a text field.
|
|
75
|
+
*/
|
|
76
|
+
associateCompanyDomainFrom?: string;
|
|
71
77
|
};
|
|
72
78
|
/**
|
|
73
79
|
* Net-new discovery for an API acquire source. `provider` selects the adapter
|
package/dist/enrich.js
CHANGED
|
@@ -247,6 +247,8 @@ export function builtinAcquirePreset(source) {
|
|
|
247
247
|
company: "companyName",
|
|
248
248
|
email: "email",
|
|
249
249
|
},
|
|
250
|
+
associateCompanyFrom: "companyName",
|
|
251
|
+
associateCompanyDomainFrom: "companyDomain",
|
|
250
252
|
},
|
|
251
253
|
},
|
|
252
254
|
},
|
|
@@ -274,6 +276,8 @@ export function builtinAcquirePreset(source) {
|
|
|
274
276
|
company: "companyName",
|
|
275
277
|
hs_linkedin_url: "linkedin",
|
|
276
278
|
},
|
|
279
|
+
associateCompanyFrom: "companyName",
|
|
280
|
+
associateCompanyDomainFrom: "companyDomain",
|
|
277
281
|
},
|
|
278
282
|
},
|
|
279
283
|
},
|
|
@@ -490,6 +494,28 @@ function crmFieldValue(snapshot, objectType, objectId, field) {
|
|
|
490
494
|
function isEmptyValue(value) {
|
|
491
495
|
return value === undefined || value === null || (typeof value === "string" && value.trim() === "");
|
|
492
496
|
}
|
|
497
|
+
/** Bare registrable host from a domain or URL ("https://www.x.com/a" → "x.com"). */
|
|
498
|
+
function normalizeCompanyDomain(value) {
|
|
499
|
+
if (typeof value !== "string" || !value.trim())
|
|
500
|
+
return undefined;
|
|
501
|
+
const host = value
|
|
502
|
+
.trim()
|
|
503
|
+
.toLowerCase()
|
|
504
|
+
.replace(/^https?:\/\//, "")
|
|
505
|
+
.replace(/^www\./, "")
|
|
506
|
+
.replace(/\/.*$/, "")
|
|
507
|
+
.replace(/\s+/g, "");
|
|
508
|
+
return host && host.includes(".") ? host : undefined;
|
|
509
|
+
}
|
|
510
|
+
/** The domain of a work email ("vp@acme.com" → "acme.com"), else undefined. */
|
|
511
|
+
function domainFromEmail(value) {
|
|
512
|
+
if (typeof value !== "string")
|
|
513
|
+
return undefined;
|
|
514
|
+
const at = value.lastIndexOf("@");
|
|
515
|
+
if (at < 0)
|
|
516
|
+
return undefined;
|
|
517
|
+
return normalizeCompanyDomain(value.slice(at + 1));
|
|
518
|
+
}
|
|
493
519
|
/** Values compare as trimmed strings; numbers compare numerically. */
|
|
494
520
|
function sameValue(a, b) {
|
|
495
521
|
if (isEmptyValue(a) && isEmptyValue(b))
|
|
@@ -831,6 +857,21 @@ export function buildAcquirePlan(options) {
|
|
|
831
857
|
if (!isEmptyValue(companyValue))
|
|
832
858
|
associateCompanyName = String(companyValue);
|
|
833
859
|
}
|
|
860
|
+
// The company domain makes the lead's account signal-watchable. Prefer the
|
|
861
|
+
// configured source path; fall back to the work email's domain (free, and
|
|
862
|
+
// for B2B leads the email domain IS the company domain).
|
|
863
|
+
let associateCompanyDomain;
|
|
864
|
+
if (associateCompanyName) {
|
|
865
|
+
const fromPath = createMap.associateCompanyDomainFrom
|
|
866
|
+
? sourceValueAt(record.payload, createMap.associateCompanyDomainFrom)
|
|
867
|
+
: undefined;
|
|
868
|
+
const candidate = !isEmptyValue(fromPath)
|
|
869
|
+
? String(fromPath)
|
|
870
|
+
: domainFromEmail(sourceValueAt(record.payload, "email"));
|
|
871
|
+
const normalized = normalizeCompanyDomain(candidate);
|
|
872
|
+
if (normalized)
|
|
873
|
+
associateCompanyDomain = normalized;
|
|
874
|
+
}
|
|
834
875
|
const recordEvidence = evidenceFor(source, sourceConfig.kind, sourceConfig.format, record, undefined, nowIso);
|
|
835
876
|
evidence.push(recordEvidence);
|
|
836
877
|
// Resolve ownership BEFORE the meter-charged create lands, so the lead is
|
|
@@ -853,8 +894,13 @@ export function buildAcquirePlan(options) {
|
|
|
853
894
|
source,
|
|
854
895
|
estCostUsd: costPerRecord,
|
|
855
896
|
associateCompanyName,
|
|
897
|
+
...(associateCompanyDomain ? { associateCompanyDomain } : {}),
|
|
856
898
|
...(ownerId ? { ownerId, assignedBy } : {}),
|
|
857
899
|
};
|
|
900
|
+
// Surface the account link in the dry-run so it is not a silent side-effect.
|
|
901
|
+
const accountNote = associateCompanyName
|
|
902
|
+
? ` Resolves/creates its account "${associateCompanyName}"${associateCompanyDomain ? ` (${associateCompanyDomain})` : ""} and links the lead.`
|
|
903
|
+
: "";
|
|
858
904
|
operations.push({
|
|
859
905
|
id: `op_acq_${fnv1a(`${source}:${record.objectType}:${matchValue}`)}`,
|
|
860
906
|
objectType: canonicalObjectType(record.objectType),
|
|
@@ -863,7 +909,7 @@ export function buildAcquirePlan(options) {
|
|
|
863
909
|
beforeValue: null,
|
|
864
910
|
afterValue: payload,
|
|
865
911
|
reason: `${source} sourced net-new ${record.objectType} "${describeSourceRecord(record)}" ` +
|
|
866
|
-
`(${createMap.matchKey}=${matchValue}); no CRM match — create as a lead
|
|
912
|
+
`(${createMap.matchKey}=${matchValue}); no CRM match — create as a lead.${accountNote}`,
|
|
867
913
|
sourceRuleOrPolicy: `acquire:${source}`,
|
|
868
914
|
riskLevel: "medium",
|
|
869
915
|
approvalRequired: true,
|
package/dist/health.d.ts
CHANGED
|
@@ -21,6 +21,18 @@ export type HealthEntry = {
|
|
|
21
21
|
byRule: Record<string, number>;
|
|
22
22
|
/** Finding count per severity. */
|
|
23
23
|
severityCounts: Record<AuditFindingSeverity, number>;
|
|
24
|
+
/**
|
|
25
|
+
* Per-object-type breakdown — so "is my contact data clean but my pipeline
|
|
26
|
+
* messy?" is answerable without re-auditing. Each type carries its own
|
|
27
|
+
* record-normalized score (same curve as the overall score, scoped to that
|
|
28
|
+
* type's records + findings).
|
|
29
|
+
*/
|
|
30
|
+
byObjectType: Record<"account" | "contact" | "deal", {
|
|
31
|
+
records: number;
|
|
32
|
+
findings: number;
|
|
33
|
+
weightedFindings: number;
|
|
34
|
+
score: number;
|
|
35
|
+
}>;
|
|
24
36
|
};
|
|
25
37
|
export type HealthRuleDelta = {
|
|
26
38
|
ruleId: string;
|
package/dist/health.js
CHANGED
|
@@ -23,11 +23,21 @@ const SEVERITY_WEIGHT = { info: 1, warning: 3, critical: 10 };
|
|
|
23
23
|
export function computeHealth(plan, snapshot, at) {
|
|
24
24
|
const byRule = {};
|
|
25
25
|
const severityCounts = { info: 0, warning: 0, critical: 0 };
|
|
26
|
+
const typeTally = {
|
|
27
|
+
account: { findings: 0, weightedFindings: 0 },
|
|
28
|
+
contact: { findings: 0, weightedFindings: 0 },
|
|
29
|
+
deal: { findings: 0, weightedFindings: 0 },
|
|
30
|
+
};
|
|
26
31
|
let weightedFindings = 0;
|
|
27
32
|
for (const finding of plan.findings) {
|
|
28
33
|
byRule[finding.ruleId] = (byRule[finding.ruleId] ?? 0) + 1;
|
|
29
34
|
severityCounts[finding.severity] += 1;
|
|
30
35
|
weightedFindings += SEVERITY_WEIGHT[finding.severity];
|
|
36
|
+
const t = finding.objectType;
|
|
37
|
+
if (t === "account" || t === "contact" || t === "deal") {
|
|
38
|
+
typeTally[t].findings += 1;
|
|
39
|
+
typeTally[t].weightedFindings += SEVERITY_WEIGHT[finding.severity];
|
|
40
|
+
}
|
|
31
41
|
}
|
|
32
42
|
const records = {
|
|
33
43
|
accounts: snapshot.accounts.length,
|
|
@@ -35,8 +45,18 @@ export function computeHealth(plan, snapshot, at) {
|
|
|
35
45
|
deals: snapshot.deals.length,
|
|
36
46
|
total: snapshot.accounts.length + snapshot.contacts.length + snapshot.deals.length,
|
|
37
47
|
};
|
|
38
|
-
const
|
|
39
|
-
const
|
|
48
|
+
const scoreFor = (weighted, recordCount) => Math.round(100 / (1 + weighted / Math.max(recordCount, 1)));
|
|
49
|
+
const recordsByType = { account: records.accounts, contact: records.contacts, deal: records.deals };
|
|
50
|
+
const byObjectType = ["account", "contact", "deal"].reduce((acc, t) => {
|
|
51
|
+
acc[t] = {
|
|
52
|
+
records: recordsByType[t],
|
|
53
|
+
findings: typeTally[t].findings,
|
|
54
|
+
weightedFindings: typeTally[t].weightedFindings,
|
|
55
|
+
score: scoreFor(typeTally[t].weightedFindings, recordsByType[t]),
|
|
56
|
+
};
|
|
57
|
+
return acc;
|
|
58
|
+
}, {});
|
|
59
|
+
const score = scoreFor(weightedFindings, records.total);
|
|
40
60
|
return {
|
|
41
61
|
at,
|
|
42
62
|
planId: plan.id,
|
|
@@ -46,6 +66,7 @@ export function computeHealth(plan, snapshot, at) {
|
|
|
46
66
|
records,
|
|
47
67
|
byRule,
|
|
48
68
|
severityCounts,
|
|
69
|
+
byObjectType,
|
|
49
70
|
};
|
|
50
71
|
}
|
|
51
72
|
/** Roll a timeline up into the current state, the change since last time, and per-rule deltas. */
|
|
@@ -95,6 +116,12 @@ export function healthToMarkdown(rollup) {
|
|
|
95
116
|
? "(first audit — no prior reading)"
|
|
96
117
|
: `${arrow(scoreDelta)} ${scoreDelta >= 0 ? "+" : ""}${scoreDelta} since the previous audit`;
|
|
97
118
|
lines.push(`Score: **${current.score}/100** ${deltaText}`, `Audits: ${rollup.auditCount} over ${shortDate(rollup.first)} → ${shortDate(rollup.latest)}`, `Findings: ${current.findings} (${current.severityCounts.critical} critical, ${current.severityCounts.warning} warning, ${current.severityCounts.info} info)`, `Records: ${current.records.accounts} accounts, ${current.records.contacts} contacts, ${current.records.deals} deals`, "");
|
|
119
|
+
lines.push("## By object type", "", "| Type | Score | Findings | Records |", "| --- | --- | --- | --- |");
|
|
120
|
+
for (const t of ["account", "contact", "deal"]) {
|
|
121
|
+
const b = current.byObjectType[t];
|
|
122
|
+
lines.push(`| ${t} | ${b.score}/100 | ${b.findings} | ${b.records} |`);
|
|
123
|
+
}
|
|
124
|
+
lines.push("");
|
|
98
125
|
if (rollup.history.length > 1) {
|
|
99
126
|
lines.push("## Trend", "");
|
|
100
127
|
for (const point of rollup.history) {
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export { createStripeConnector, type StripeConnectorOptions } from "./connectors
|
|
|
13
13
|
export { activeProfile, credentialsDir, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotAccessToken, resolveHubspotConnection, setActiveProfile, storeCredential, type HubspotConnection, type StoredCredential, } from "./credentials.ts";
|
|
14
14
|
export { generateDemoSnapshot, type DemoSnapshotOptions } from "./demo.ts";
|
|
15
15
|
export { buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, enrichRunId, inferIngestObjectType, ingestKeyValue, latestStamps, loadEnrichConfig, matchSourceRecord, parseCsv, parseEnrichConfig, resolveCrmField, selectStaleWork, sourceValueAt, stagedSourceRecords, staleDaysFor, type BuildEnrichPlanOptions, type EnrichAmbiguity, type EnrichConfig, type EnrichCounts, type EnrichFieldConfig, type EnrichMatchConfig, type EnrichMode, type EnrichObjectType, type EnrichPlanResult, type EnrichRun, type EnrichRunStore, type EnrichSourceConfig, type EnrichSourceRecord, type EnrichStamp, type EnrichWorkItem, type MatchOutcome, } from "./enrich.ts";
|
|
16
|
+
export { scaffoldWorkspace, starterEnrichConfig, starterIcp, starterPlaybook, type InitProvider, type InitSource, type ScaffoldFile, type ScaffoldOptions, } from "./init.ts";
|
|
16
17
|
export { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, type ApolloClient, type ApolloClientOptions, type ApolloPullKey, type ApolloPullResult, } from "./enrichApollo.ts";
|
|
17
18
|
export { diffFindings, diffSnapshots, diffToMarkdown, type CollectionDiff, type FieldChange, type FindingsDrift, type RecordChange, type SnapshotDiff, } from "./diff.ts";
|
|
18
19
|
export { mergeSnapshots, type MergeConflict, type MergeMatch, type MergeReport, type MergeSuggestion, } from "./merge.ts";
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ export { createStripeConnector } from "./connectors/stripe.js";
|
|
|
13
13
|
export { activeProfile, credentialsDir, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotAccessToken, resolveHubspotConnection, setActiveProfile, storeCredential, } from "./credentials.js";
|
|
14
14
|
export { generateDemoSnapshot } from "./demo.js";
|
|
15
15
|
export { buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, enrichRunId, inferIngestObjectType, ingestKeyValue, latestStamps, loadEnrichConfig, matchSourceRecord, parseCsv, parseEnrichConfig, resolveCrmField, selectStaleWork, sourceValueAt, stagedSourceRecords, staleDaysFor, } from "./enrich.js";
|
|
16
|
+
export { scaffoldWorkspace, starterEnrichConfig, starterIcp, starterPlaybook, } from "./init.js";
|
|
16
17
|
export { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, } from "./enrichApollo.js";
|
|
17
18
|
export { diffFindings, diffSnapshots, diffToMarkdown, } from "./diff.js";
|
|
18
19
|
export { mergeSnapshots, } from "./merge.js";
|
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
import { type EnrichConfig } from "./enrich.ts";
|
|
18
|
+
import { type Icp } from "./icp.ts";
|
|
19
|
+
export type InitProvider = "hubspot" | "salesforce";
|
|
20
|
+
export type InitSource = "pipe0" | "explorium" | "linkedin";
|
|
21
|
+
export type ScaffoldOptions = {
|
|
22
|
+
/** discovery source the acquire preset + playbook are wired for. Default "pipe0". */
|
|
23
|
+
source?: InitSource;
|
|
24
|
+
/** CRM the playbook writes against. Default "hubspot". */
|
|
25
|
+
provider?: InitProvider;
|
|
26
|
+
/** profile the playbook scopes commands to (omitted = default profile). */
|
|
27
|
+
profile?: string;
|
|
28
|
+
};
|
|
29
|
+
export type ScaffoldFile = {
|
|
30
|
+
path: string;
|
|
31
|
+
content: string;
|
|
32
|
+
};
|
|
33
|
+
/** A generic, valid starter ICP. Edit it, or replace via `icp interview`. */
|
|
34
|
+
export declare function starterIcp(): Icp;
|
|
35
|
+
/** The acquire preset for the chosen source, with an explicit (placeholder)
|
|
36
|
+
* assign policy so the seam is visible — leads are never silently ownerless. */
|
|
37
|
+
export declare function starterEnrichConfig(source: InitSource): EnrichConfig;
|
|
38
|
+
/** The workspace PLAYBOOK: the cold-start + outbound-loop recipes wired with the
|
|
39
|
+
* chosen source/provider/profile, pointing at docs/recipes.md for the rest. */
|
|
40
|
+
export declare function starterPlaybook(opts: Required<Pick<ScaffoldOptions, "source" | "provider">> & {
|
|
41
|
+
profile?: string;
|
|
42
|
+
}): string;
|
|
43
|
+
/**
|
|
44
|
+
* Build the set of files `init` would write (pure — no IO). The caller decides
|
|
45
|
+
* which to actually write (skipping existing files unless --force).
|
|
46
|
+
*/
|
|
47
|
+
export declare function scaffoldWorkspace(opts?: ScaffoldOptions): ScaffoldFile[];
|