fullstackgtm 0.46.0 → 0.48.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 +44 -6
- package/INSTALL_FOR_AGENTS.md +3 -3
- package/README.md +16 -8
- package/dist/audit.js +7 -0
- package/dist/backfill.js +2 -16
- package/dist/cli/fix.d.ts +3 -0
- package/dist/cli/fix.js +70 -1
- package/dist/cli/help.d.ts +6 -0
- package/dist/cli/help.js +76 -2
- package/dist/cli/suggest.d.ts +2 -1
- package/dist/cli/suggest.js +49 -3
- package/dist/cli.js +40 -15
- package/dist/connector.js +96 -25
- package/dist/connectors/hubspot.d.ts +2 -1
- package/dist/connectors/hubspot.js +170 -4
- package/dist/connectors/salesforce.d.ts +2 -1
- package/dist/connectors/salesforce.js +135 -0
- package/dist/connectors/signalSources.js +2 -11
- package/dist/format.js +31 -5
- package/dist/freeEmailDomains.d.ts +1 -0
- package/dist/freeEmailDomains.js +14 -0
- package/dist/hierarchy.d.ts +29 -0
- package/dist/hierarchy.js +164 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +5 -1
- package/dist/mcp.js +19 -15
- package/dist/relationships.d.ts +39 -0
- package/dist/relationships.js +101 -0
- package/dist/route.d.ts +46 -0
- package/dist/route.js +228 -0
- package/dist/rules.d.ts +1 -0
- package/dist/rules.js +172 -12
- package/dist/types.d.ts +30 -0
- package/docs/api.md +22 -4
- package/docs/architecture.md +5 -2
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +2 -2
- package/src/audit.ts +7 -0
- package/src/backfill.ts +2 -17
- package/src/cli/fix.ts +68 -1
- package/src/cli/help.ts +83 -2
- package/src/cli/suggest.ts +58 -4
- package/src/cli.ts +38 -13
- package/src/connector.ts +94 -20
- package/src/connectors/hubspot.ts +164 -5
- package/src/connectors/salesforce.ts +136 -1
- package/src/connectors/signalSources.ts +2 -12
- package/src/format.ts +33 -5
- package/src/freeEmailDomains.ts +14 -0
- package/src/hierarchy.ts +185 -0
- package/src/index.ts +25 -0
- package/src/mcp.ts +22 -16
- package/src/relationships.ts +115 -0
- package/src/route.ts +278 -0
- package/src/rules.ts +192 -12
- package/src/types.ts +32 -0
package/dist/route.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { resolveAssignment } from "./assign.js";
|
|
2
|
+
import { FREE_EMAIL_DOMAINS } from "./freeEmailDomains.js";
|
|
3
|
+
import { normalizeDomain } from "./merge.js";
|
|
4
|
+
import { stableHash } from "./rules.js";
|
|
5
|
+
function norm(value) {
|
|
6
|
+
if (value === undefined || value === null)
|
|
7
|
+
return undefined;
|
|
8
|
+
const out = String(value).trim().toLowerCase();
|
|
9
|
+
return out || undefined;
|
|
10
|
+
}
|
|
11
|
+
function contactEmailDomain(contact) {
|
|
12
|
+
const email = norm(contact.email);
|
|
13
|
+
if (!email || !email.includes("@"))
|
|
14
|
+
return undefined;
|
|
15
|
+
return normalizeDomain(email.slice(email.lastIndexOf("@") + 1));
|
|
16
|
+
}
|
|
17
|
+
function rawString(record, keys) {
|
|
18
|
+
if (!record.raw || typeof record.raw !== "object")
|
|
19
|
+
return undefined;
|
|
20
|
+
const raw = record.raw;
|
|
21
|
+
for (const key of keys) {
|
|
22
|
+
const value = raw[key];
|
|
23
|
+
if (typeof value === "string" && value.trim())
|
|
24
|
+
return value.trim();
|
|
25
|
+
if (value && typeof value === "object") {
|
|
26
|
+
const nested = value;
|
|
27
|
+
for (const nestedKey of ["value", "name", "label"]) {
|
|
28
|
+
if (typeof nested[nestedKey] === "string" && String(nested[nestedKey]).trim()) {
|
|
29
|
+
return String(nested[nestedKey]).trim();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
function companyNameFor(contact) {
|
|
37
|
+
return rawString(contact, ["company", "companyName", "company_name", "account", "accountName", "account_name"]);
|
|
38
|
+
}
|
|
39
|
+
function uniqueAccount(matches) {
|
|
40
|
+
const byId = new Map(matches.map((account) => [String(account.id), account]));
|
|
41
|
+
const unique = [...byId.values()];
|
|
42
|
+
if (unique.length === 0)
|
|
43
|
+
return null;
|
|
44
|
+
if (unique.length > 1)
|
|
45
|
+
return "ambiguous";
|
|
46
|
+
return unique[0];
|
|
47
|
+
}
|
|
48
|
+
function employeeBand(employeeCount) {
|
|
49
|
+
if (employeeCount === undefined)
|
|
50
|
+
return undefined;
|
|
51
|
+
if (employeeCount <= 10)
|
|
52
|
+
return "1-10";
|
|
53
|
+
if (employeeCount <= 50)
|
|
54
|
+
return "11-50";
|
|
55
|
+
if (employeeCount <= 200)
|
|
56
|
+
return "51-200";
|
|
57
|
+
if (employeeCount <= 1000)
|
|
58
|
+
return "201-1000";
|
|
59
|
+
return "1001+";
|
|
60
|
+
}
|
|
61
|
+
function routeContext(contact, account) {
|
|
62
|
+
const geo = norm(rawString(contact, ["country", "countryCode", "country_code", "state", "region"])) ??
|
|
63
|
+
norm(rawString(account ?? { raw: undefined }, ["country", "countryCode", "country_code", "state", "region"]));
|
|
64
|
+
const department = norm(rawString(contact, ["department", "jobDepartment", "job_department"]));
|
|
65
|
+
return {
|
|
66
|
+
geo,
|
|
67
|
+
industry: norm(account?.industry),
|
|
68
|
+
employeeBand: employeeBand(account?.employeeCount),
|
|
69
|
+
department,
|
|
70
|
+
title: norm(contact.title),
|
|
71
|
+
accountOwnerId: account?.ownerId,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function activeOwnerIds(snapshot) {
|
|
75
|
+
const ids = new Set();
|
|
76
|
+
for (const user of snapshot.users) {
|
|
77
|
+
if (user.active === false)
|
|
78
|
+
continue;
|
|
79
|
+
ids.add(String(user.id));
|
|
80
|
+
if (user.crmId)
|
|
81
|
+
ids.add(String(user.crmId));
|
|
82
|
+
}
|
|
83
|
+
return ids;
|
|
84
|
+
}
|
|
85
|
+
export function buildLeadRoutePlan(snapshot, options = {}) {
|
|
86
|
+
const matchByDomain = options.matchByDomain ?? true;
|
|
87
|
+
const matchByCompanyName = options.matchByCompanyName ?? false;
|
|
88
|
+
const inheritAccountOwner = options.inheritAccountOwner ?? true;
|
|
89
|
+
const reassignExistingOwner = options.reassignExistingOwner ?? false;
|
|
90
|
+
const maxOperations = options.maxOperations ?? 500;
|
|
91
|
+
const accountsByDomain = new Map();
|
|
92
|
+
const accountsByName = new Map();
|
|
93
|
+
for (const account of snapshot.accounts) {
|
|
94
|
+
const domain = normalizeDomain(account.domain);
|
|
95
|
+
if (domain)
|
|
96
|
+
accountsByDomain.set(domain, [...(accountsByDomain.get(domain) ?? []), account]);
|
|
97
|
+
const name = norm(account.name);
|
|
98
|
+
if (name)
|
|
99
|
+
accountsByName.set(name, [...(accountsByName.get(name) ?? []), account]);
|
|
100
|
+
}
|
|
101
|
+
const operations = [];
|
|
102
|
+
const counts = {
|
|
103
|
+
contactsScanned: 0,
|
|
104
|
+
matchedContacts: 0,
|
|
105
|
+
matchedExistingAccount: 0,
|
|
106
|
+
matchedByDomain: 0,
|
|
107
|
+
matchedByCompanyName: 0,
|
|
108
|
+
ambiguousAccountMatches: 0,
|
|
109
|
+
skippedFreeEmail: 0,
|
|
110
|
+
linkOperations: 0,
|
|
111
|
+
ownerOperations: 0,
|
|
112
|
+
ownerAssigned: 0,
|
|
113
|
+
policyAssigned: 0,
|
|
114
|
+
unmatched: 0,
|
|
115
|
+
};
|
|
116
|
+
const knownOwners = activeOwnerIds(snapshot);
|
|
117
|
+
let policyAssignmentIndex = 0;
|
|
118
|
+
for (const contact of snapshot.contacts) {
|
|
119
|
+
counts.contactsScanned += 1;
|
|
120
|
+
const currentAccount = contact.accountId ? snapshot.accounts.find((account) => account.id === contact.accountId) : undefined;
|
|
121
|
+
let matchedAccount = currentAccount;
|
|
122
|
+
let matchRule = currentAccount ? "existing" : undefined;
|
|
123
|
+
if (currentAccount)
|
|
124
|
+
counts.matchedExistingAccount += 1;
|
|
125
|
+
let skippedFreeEmail = false;
|
|
126
|
+
if (!matchedAccount && matchByDomain) {
|
|
127
|
+
const domain = contactEmailDomain(contact);
|
|
128
|
+
if (domain && FREE_EMAIL_DOMAINS.has(domain)) {
|
|
129
|
+
counts.skippedFreeEmail += 1;
|
|
130
|
+
skippedFreeEmail = true;
|
|
131
|
+
}
|
|
132
|
+
else if (domain) {
|
|
133
|
+
const match = uniqueAccount(accountsByDomain.get(domain) ?? []);
|
|
134
|
+
if (match === "ambiguous")
|
|
135
|
+
counts.ambiguousAccountMatches += 1;
|
|
136
|
+
else if (match) {
|
|
137
|
+
matchedAccount = match;
|
|
138
|
+
matchRule = "domain";
|
|
139
|
+
counts.matchedByDomain += 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!matchedAccount && matchByCompanyName && !skippedFreeEmail) {
|
|
144
|
+
const company = norm(companyNameFor(contact));
|
|
145
|
+
if (company && (accountsByName.get(company) ?? []).length > 0) {
|
|
146
|
+
// Name-only account matches are not safe enough to auto-link. Keep the
|
|
147
|
+
// contact unmatched and surface the skipped match as ambiguous.
|
|
148
|
+
counts.ambiguousAccountMatches += 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (matchedAccount)
|
|
152
|
+
counts.matchedContacts += 1;
|
|
153
|
+
const contactOps = [];
|
|
154
|
+
if (matchedAccount && !contact.accountId) {
|
|
155
|
+
contactOps.push({
|
|
156
|
+
id: `op_${stableHash(`route:link:${contact.id}:${matchedAccount.id}`)}`,
|
|
157
|
+
objectType: "contact",
|
|
158
|
+
objectId: String(contact.id),
|
|
159
|
+
operation: "link_record",
|
|
160
|
+
field: "accountId",
|
|
161
|
+
beforeValue: contact.accountId ?? null,
|
|
162
|
+
afterValue: String(matchedAccount.id),
|
|
163
|
+
reason: options.reason ?? `Lead-to-account match by ${matchRule}: link contact to account ${matchedAccount.name} (${matchedAccount.id}).`,
|
|
164
|
+
riskLevel: "medium",
|
|
165
|
+
approvalRequired: true,
|
|
166
|
+
sourceRuleOrPolicy: "route.lead_to_account",
|
|
167
|
+
});
|
|
168
|
+
counts.linkOperations += 1;
|
|
169
|
+
}
|
|
170
|
+
let ownerId = null;
|
|
171
|
+
let ownerRule;
|
|
172
|
+
if (inheritAccountOwner &&
|
|
173
|
+
matchedAccount?.ownerId &&
|
|
174
|
+
matchedAccount.ownerId !== contact.ownerId &&
|
|
175
|
+
(!contact.ownerId || reassignExistingOwner) &&
|
|
176
|
+
knownOwners.has(matchedAccount.ownerId)) {
|
|
177
|
+
ownerId = matchedAccount.ownerId;
|
|
178
|
+
ownerRule = "account-owner";
|
|
179
|
+
}
|
|
180
|
+
else if (options.assignmentPolicy && !contact.ownerId) {
|
|
181
|
+
const assigned = resolveAssignment(options.assignmentPolicy, routeContext(contact, matchedAccount), policyAssignmentIndex, knownOwners);
|
|
182
|
+
policyAssignmentIndex += 1;
|
|
183
|
+
ownerId = assigned.ownerId;
|
|
184
|
+
ownerRule = assigned.rule;
|
|
185
|
+
if (ownerId)
|
|
186
|
+
counts.policyAssigned += 1;
|
|
187
|
+
}
|
|
188
|
+
if (ownerId && ownerId !== contact.ownerId) {
|
|
189
|
+
contactOps.push({
|
|
190
|
+
id: `op_${stableHash(`route:owner:${contact.id}:${ownerId}`)}`,
|
|
191
|
+
objectType: "contact",
|
|
192
|
+
objectId: String(contact.id),
|
|
193
|
+
operation: "set_field",
|
|
194
|
+
field: "ownerId",
|
|
195
|
+
beforeValue: contact.ownerId ?? null,
|
|
196
|
+
afterValue: ownerId,
|
|
197
|
+
reason: options.reason ?? `Route contact owner by ${ownerRule ?? "policy"}${matchedAccount ? ` after matching account ${matchedAccount.name}` : ""}.`,
|
|
198
|
+
riskLevel: "medium",
|
|
199
|
+
approvalRequired: true,
|
|
200
|
+
sourceRuleOrPolicy: "route.owner_assignment",
|
|
201
|
+
});
|
|
202
|
+
counts.ownerOperations += 1;
|
|
203
|
+
counts.ownerAssigned = counts.ownerOperations;
|
|
204
|
+
}
|
|
205
|
+
if (contactOps.length > 1) {
|
|
206
|
+
const groupId = `grp_route_${contact.id}`;
|
|
207
|
+
for (const operation of contactOps)
|
|
208
|
+
operation.groupId = groupId;
|
|
209
|
+
}
|
|
210
|
+
operations.push(...contactOps);
|
|
211
|
+
if (!matchedAccount)
|
|
212
|
+
counts.unmatched += 1;
|
|
213
|
+
}
|
|
214
|
+
if (operations.length > maxOperations) {
|
|
215
|
+
throw new Error(`route would create ${operations.length} operations — above the ${maxOperations}-operation safety cap. Raise --max-operations after reviewing scope.`);
|
|
216
|
+
}
|
|
217
|
+
const plan = {
|
|
218
|
+
id: `patch_plan_${stableHash(`route:${snapshot.provider}:${snapshot.generatedAt}:${operations.map((op) => op.id).join(",")}`)}`,
|
|
219
|
+
title: "Route leads: match contacts to accounts and owners",
|
|
220
|
+
createdAt: snapshot.generatedAt,
|
|
221
|
+
status: operations.length > 0 ? "needs_approval" : "draft",
|
|
222
|
+
dryRun: true,
|
|
223
|
+
summary: `${counts.matchedContacts} matched contact(s), ${counts.linkOperations} lead-to-account link(s), ${counts.ownerOperations} owner assignment(s), ${counts.ambiguousAccountMatches} ambiguous match(es), ${counts.unmatched} unmatched contact(s). Dry-run only; approve before apply.`,
|
|
224
|
+
findings: [],
|
|
225
|
+
operations,
|
|
226
|
+
};
|
|
227
|
+
return { plan, counts };
|
|
228
|
+
}
|
package/dist/rules.d.ts
CHANGED
|
@@ -34,4 +34,5 @@ export declare const duplicateOpenDealRule: GtmAuditRule;
|
|
|
34
34
|
export declare const activeDealAccountWithoutContactsRule: GtmAuditRule;
|
|
35
35
|
export declare const closingSoonInactiveRule: GtmAuditRule;
|
|
36
36
|
export declare const accountSingleSourceRule: GtmAuditRule;
|
|
37
|
+
export declare function isFindingsOnlyRule(rule: GtmAuditRule): boolean;
|
|
37
38
|
export declare const builtinAuditRules: GtmAuditRule[];
|
package/dist/rules.js
CHANGED
|
@@ -36,6 +36,91 @@ export function auditFindingId(ruleId, objectId) {
|
|
|
36
36
|
export function patchOperationId(ruleId, objectId) {
|
|
37
37
|
return `op_${stableHash(`${ruleId}:${objectId}`)}`;
|
|
38
38
|
}
|
|
39
|
+
const OPEN_DEAL_FLAGS_ASSUMPTION = {
|
|
40
|
+
id: "open-deal-derived-from-closed-won-flags",
|
|
41
|
+
text: "Derived open-deal status from canonical isClosed/isWon flags; a deal is open when neither flag is true.",
|
|
42
|
+
source: "CanonicalDeal.isClosed/isWon",
|
|
43
|
+
confidence: "derived",
|
|
44
|
+
};
|
|
45
|
+
const MISSING_CLOSED_WON_FLAGS_ASSUMPTION = {
|
|
46
|
+
id: "missing-closed-won-flags-treated-open",
|
|
47
|
+
text: "Treated deals with missing isClosed/isWon flags as open so hygiene rules do not silently ignore active pipeline.",
|
|
48
|
+
source: "CanonicalDeal.isClosed/isWon",
|
|
49
|
+
confidence: "heuristic",
|
|
50
|
+
};
|
|
51
|
+
const ORPHAN_ACCOUNT_RELATIONSHIP_ASSUMPTION = {
|
|
52
|
+
id: "account-without-contacts-or-deals-treated-orphan",
|
|
53
|
+
text: "Treated an account as orphaned when the snapshot has no contacts and no deals linked to that account.",
|
|
54
|
+
source: "snapshot account/contact/deal associations",
|
|
55
|
+
confidence: "derived",
|
|
56
|
+
};
|
|
57
|
+
const DEAL_OWNER_POLICY_ASSUMPTION = {
|
|
58
|
+
id: "deal-owner-required-by-policy",
|
|
59
|
+
text: "Treated a missing or unknown deal owner as invalid when requireDealOwner policy is enabled.",
|
|
60
|
+
source: "GtmPolicy.requireDealOwner",
|
|
61
|
+
confidence: "heuristic",
|
|
62
|
+
};
|
|
63
|
+
const DEAL_ACCOUNT_POLICY_ASSUMPTION = {
|
|
64
|
+
id: "deal-account-required-by-policy",
|
|
65
|
+
text: "Treated a missing or unknown deal account link as invalid when requireAccountForDeal policy is enabled.",
|
|
66
|
+
source: "GtmPolicy.requireAccountForDeal",
|
|
67
|
+
confidence: "heuristic",
|
|
68
|
+
};
|
|
69
|
+
const DEAL_AMOUNT_POLICY_ASSUMPTION = {
|
|
70
|
+
id: "open-deal-amount-required-by-policy",
|
|
71
|
+
text: "Treated open deals with missing or zero amount as forecast-risk findings unless requireDealAmount is disabled.",
|
|
72
|
+
source: "GtmPolicy.requireDealAmount",
|
|
73
|
+
confidence: "heuristic",
|
|
74
|
+
};
|
|
75
|
+
const DUPLICATE_ACCOUNT_DOMAIN_ASSUMPTION = {
|
|
76
|
+
id: "same-normalized-domain-duplicate-account",
|
|
77
|
+
text: "Treated accounts with the same normalized domain as duplicate-account candidates.",
|
|
78
|
+
source: "CanonicalAccount.domain",
|
|
79
|
+
confidence: "heuristic",
|
|
80
|
+
};
|
|
81
|
+
const DUPLICATE_CONTACT_EMAIL_ASSUMPTION = {
|
|
82
|
+
id: "same-email-duplicate-contact",
|
|
83
|
+
text: "Treated contacts with the same lowercased email address as duplicate-contact candidates.",
|
|
84
|
+
source: "CanonicalContact.email",
|
|
85
|
+
confidence: "heuristic",
|
|
86
|
+
};
|
|
87
|
+
const DUPLICATE_OPEN_DEAL_ASSUMPTION = {
|
|
88
|
+
id: "same-account-and-name-duplicate-open-deal",
|
|
89
|
+
text: "Treated open deals with the same normalized name on the same account scope as duplicate-opportunity candidates.",
|
|
90
|
+
source: "CanonicalDeal.accountId/name",
|
|
91
|
+
confidence: "heuristic",
|
|
92
|
+
};
|
|
93
|
+
const ACCOUNT_WITH_OPEN_DEAL_NEEDS_CONTACT_ASSUMPTION = {
|
|
94
|
+
id: "open-pipeline-account-needs-contact",
|
|
95
|
+
text: "Treated accounts with open pipeline but no linked contacts as coverage gaps requiring a buying-committee contact.",
|
|
96
|
+
source: "snapshot account/contact/deal associations",
|
|
97
|
+
confidence: "heuristic",
|
|
98
|
+
};
|
|
99
|
+
const SINGLE_SOURCE_ACCOUNT_ASSUMPTION = {
|
|
100
|
+
id: "single-source-account-is-cross-system-gap",
|
|
101
|
+
text: "Treated an account present in only one connected system as a cross-system reconciliation gap.",
|
|
102
|
+
source: "ProviderIdentity.provider",
|
|
103
|
+
confidence: "heuristic",
|
|
104
|
+
};
|
|
105
|
+
function staleDealPolicyAssumption(days) {
|
|
106
|
+
return {
|
|
107
|
+
id: "stale-deal-days-policy",
|
|
108
|
+
text: `Treated open deals with no recorded activity for more than ${days} days as stale.`,
|
|
109
|
+
source: "GtmPolicy.staleDealDays",
|
|
110
|
+
confidence: "heuristic",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function closingSoonPolicyAssumption(windowDays, idleDays) {
|
|
114
|
+
return {
|
|
115
|
+
id: "closing-soon-idle-policy",
|
|
116
|
+
text: `Treated open deals closing within ${windowDays} days and idle for more than ${idleDays} days as silent slip risk.`,
|
|
117
|
+
source: "GtmPolicy.closingSoonDays/closingSoonIdleDays",
|
|
118
|
+
confidence: "heuristic",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function assumptionsIfFindings(findings, assumptions) {
|
|
122
|
+
return findings.length > 0 ? assumptions : undefined;
|
|
123
|
+
}
|
|
39
124
|
export function stableHash(value) {
|
|
40
125
|
let hash = 0;
|
|
41
126
|
for (let index = 0; index < value.length; index += 1) {
|
|
@@ -150,7 +235,11 @@ export const orphanAccountRule = {
|
|
|
150
235
|
approvalRequired: true,
|
|
151
236
|
});
|
|
152
237
|
}
|
|
153
|
-
return {
|
|
238
|
+
return {
|
|
239
|
+
findings,
|
|
240
|
+
operations,
|
|
241
|
+
assumptions: assumptionsIfFindings(findings, [ORPHAN_ACCOUNT_RELATIONSHIP_ASSUMPTION]),
|
|
242
|
+
};
|
|
154
243
|
},
|
|
155
244
|
};
|
|
156
245
|
export const missingDealOwnerRule = {
|
|
@@ -180,7 +269,11 @@ export const missingDealOwnerRule = {
|
|
|
180
269
|
approvalRequired: true,
|
|
181
270
|
});
|
|
182
271
|
}
|
|
183
|
-
return {
|
|
272
|
+
return {
|
|
273
|
+
findings,
|
|
274
|
+
operations,
|
|
275
|
+
assumptions: assumptionsIfFindings(findings, [DEAL_OWNER_POLICY_ASSUMPTION]),
|
|
276
|
+
};
|
|
184
277
|
},
|
|
185
278
|
};
|
|
186
279
|
export const missingDealAccountRule = {
|
|
@@ -210,7 +303,11 @@ export const missingDealAccountRule = {
|
|
|
210
303
|
approvalRequired: true,
|
|
211
304
|
});
|
|
212
305
|
}
|
|
213
|
-
return {
|
|
306
|
+
return {
|
|
307
|
+
findings,
|
|
308
|
+
operations,
|
|
309
|
+
assumptions: assumptionsIfFindings(findings, [DEAL_ACCOUNT_POLICY_ASSUMPTION]),
|
|
310
|
+
};
|
|
214
311
|
},
|
|
215
312
|
};
|
|
216
313
|
export const pastCloseDateRule = {
|
|
@@ -240,7 +337,14 @@ export const pastCloseDateRule = {
|
|
|
240
337
|
approvalRequired: true,
|
|
241
338
|
});
|
|
242
339
|
}
|
|
243
|
-
return {
|
|
340
|
+
return {
|
|
341
|
+
findings,
|
|
342
|
+
operations,
|
|
343
|
+
assumptions: assumptionsIfFindings(findings, [
|
|
344
|
+
OPEN_DEAL_FLAGS_ASSUMPTION,
|
|
345
|
+
MISSING_CLOSED_WON_FLAGS_ASSUMPTION,
|
|
346
|
+
]),
|
|
347
|
+
};
|
|
244
348
|
},
|
|
245
349
|
};
|
|
246
350
|
export const staleDealRule = {
|
|
@@ -280,7 +384,15 @@ export const staleDealRule = {
|
|
|
280
384
|
approvalRequired: true,
|
|
281
385
|
});
|
|
282
386
|
}
|
|
283
|
-
return {
|
|
387
|
+
return {
|
|
388
|
+
findings,
|
|
389
|
+
operations,
|
|
390
|
+
assumptions: assumptionsIfFindings(findings, [
|
|
391
|
+
OPEN_DEAL_FLAGS_ASSUMPTION,
|
|
392
|
+
MISSING_CLOSED_WON_FLAGS_ASSUMPTION,
|
|
393
|
+
staleDealPolicyAssumption(policy.staleDealDays),
|
|
394
|
+
]),
|
|
395
|
+
};
|
|
284
396
|
},
|
|
285
397
|
};
|
|
286
398
|
export const missingDealAmountRule = {
|
|
@@ -312,7 +424,15 @@ export const missingDealAmountRule = {
|
|
|
312
424
|
approvalRequired: true,
|
|
313
425
|
});
|
|
314
426
|
}
|
|
315
|
-
return {
|
|
427
|
+
return {
|
|
428
|
+
findings,
|
|
429
|
+
operations,
|
|
430
|
+
assumptions: assumptionsIfFindings(findings, [
|
|
431
|
+
OPEN_DEAL_FLAGS_ASSUMPTION,
|
|
432
|
+
MISSING_CLOSED_WON_FLAGS_ASSUMPTION,
|
|
433
|
+
DEAL_AMOUNT_POLICY_ASSUMPTION,
|
|
434
|
+
]),
|
|
435
|
+
};
|
|
316
436
|
},
|
|
317
437
|
};
|
|
318
438
|
function duplicateGroups(items, keyOf) {
|
|
@@ -365,7 +485,11 @@ export const duplicateAccountDomainRule = {
|
|
|
365
485
|
rollback: "IRREVERSIBLE: provider merges cannot be unmerged. The pre-apply snapshot retains every record's field values; recreate a record manually from it if a merge was wrong.",
|
|
366
486
|
});
|
|
367
487
|
}
|
|
368
|
-
return {
|
|
488
|
+
return {
|
|
489
|
+
findings,
|
|
490
|
+
operations,
|
|
491
|
+
assumptions: assumptionsIfFindings(findings, [DUPLICATE_ACCOUNT_DOMAIN_ASSUMPTION]),
|
|
492
|
+
};
|
|
369
493
|
},
|
|
370
494
|
};
|
|
371
495
|
export const duplicateContactEmailRule = {
|
|
@@ -402,7 +526,11 @@ export const duplicateContactEmailRule = {
|
|
|
402
526
|
rollback: "IRREVERSIBLE: provider merges cannot be unmerged. The pre-apply snapshot retains every record's field values; recreate a record manually from it if a merge was wrong.",
|
|
403
527
|
});
|
|
404
528
|
}
|
|
405
|
-
return {
|
|
529
|
+
return {
|
|
530
|
+
findings,
|
|
531
|
+
operations,
|
|
532
|
+
assumptions: assumptionsIfFindings(findings, [DUPLICATE_CONTACT_EMAIL_ASSUMPTION]),
|
|
533
|
+
};
|
|
406
534
|
},
|
|
407
535
|
};
|
|
408
536
|
export const duplicateOpenDealRule = {
|
|
@@ -449,7 +577,15 @@ export const duplicateOpenDealRule = {
|
|
|
449
577
|
rollback: "IRREVERSIBLE: provider merges cannot be unmerged. The pre-apply snapshot retains every record's field values; recreate a record manually from it if a merge was wrong.",
|
|
450
578
|
});
|
|
451
579
|
}
|
|
452
|
-
return {
|
|
580
|
+
return {
|
|
581
|
+
findings,
|
|
582
|
+
operations,
|
|
583
|
+
assumptions: assumptionsIfFindings(findings, [
|
|
584
|
+
OPEN_DEAL_FLAGS_ASSUMPTION,
|
|
585
|
+
MISSING_CLOSED_WON_FLAGS_ASSUMPTION,
|
|
586
|
+
DUPLICATE_OPEN_DEAL_ASSUMPTION,
|
|
587
|
+
]),
|
|
588
|
+
};
|
|
453
589
|
},
|
|
454
590
|
};
|
|
455
591
|
export const activeDealAccountWithoutContactsRule = {
|
|
@@ -491,7 +627,15 @@ export const activeDealAccountWithoutContactsRule = {
|
|
|
491
627
|
approvalRequired: true,
|
|
492
628
|
});
|
|
493
629
|
}
|
|
494
|
-
return {
|
|
630
|
+
return {
|
|
631
|
+
findings,
|
|
632
|
+
operations,
|
|
633
|
+
assumptions: assumptionsIfFindings(findings, [
|
|
634
|
+
OPEN_DEAL_FLAGS_ASSUMPTION,
|
|
635
|
+
MISSING_CLOSED_WON_FLAGS_ASSUMPTION,
|
|
636
|
+
ACCOUNT_WITH_OPEN_DEAL_NEEDS_CONTACT_ASSUMPTION,
|
|
637
|
+
]),
|
|
638
|
+
};
|
|
495
639
|
},
|
|
496
640
|
};
|
|
497
641
|
export const closingSoonInactiveRule = {
|
|
@@ -536,12 +680,21 @@ export const closingSoonInactiveRule = {
|
|
|
536
680
|
approvalRequired: true,
|
|
537
681
|
});
|
|
538
682
|
}
|
|
539
|
-
return {
|
|
683
|
+
return {
|
|
684
|
+
findings,
|
|
685
|
+
operations,
|
|
686
|
+
assumptions: assumptionsIfFindings(findings, [
|
|
687
|
+
OPEN_DEAL_FLAGS_ASSUMPTION,
|
|
688
|
+
MISSING_CLOSED_WON_FLAGS_ASSUMPTION,
|
|
689
|
+
closingSoonPolicyAssumption(windowDays, idleDays),
|
|
690
|
+
]),
|
|
691
|
+
};
|
|
540
692
|
},
|
|
541
693
|
};
|
|
542
694
|
export const accountSingleSourceRule = {
|
|
543
695
|
id: "account-single-source",
|
|
544
696
|
title: "Account exists in only one connected system",
|
|
697
|
+
emitsOperations: false,
|
|
545
698
|
description: "On merged multi-system snapshots, flags accounts known to just one source — the seams where GTM systems disagree.",
|
|
546
699
|
category: "cross-system",
|
|
547
700
|
evaluate: ({ snapshot }) => {
|
|
@@ -566,9 +719,16 @@ export const accountSingleSourceRule = {
|
|
|
566
719
|
recommendation: "Check whether the account is missing from the other systems or matched under a different domain/name.",
|
|
567
720
|
});
|
|
568
721
|
}
|
|
569
|
-
return {
|
|
722
|
+
return {
|
|
723
|
+
findings,
|
|
724
|
+
operations: [],
|
|
725
|
+
assumptions: assumptionsIfFindings(findings, [SINGLE_SOURCE_ACCOUNT_ASSUMPTION]),
|
|
726
|
+
};
|
|
570
727
|
},
|
|
571
728
|
};
|
|
729
|
+
export function isFindingsOnlyRule(rule) {
|
|
730
|
+
return rule.emitsOperations === false;
|
|
731
|
+
}
|
|
572
732
|
export const builtinAuditRules = [
|
|
573
733
|
orphanAccountRule,
|
|
574
734
|
missingDealOwnerRule,
|
package/dist/types.d.ts
CHANGED
|
@@ -58,6 +58,13 @@ export type CreateRecordPayload = {
|
|
|
58
58
|
dealPipeline?: string;
|
|
59
59
|
};
|
|
60
60
|
export type AuditFindingSeverity = "info" | "warning" | "critical";
|
|
61
|
+
export type PatchPlanAssumptionConfidence = "derived" | "heuristic" | "guess";
|
|
62
|
+
export type PatchPlanAssumption = {
|
|
63
|
+
id: string;
|
|
64
|
+
text: string;
|
|
65
|
+
source?: string;
|
|
66
|
+
confidence?: PatchPlanAssumptionConfidence;
|
|
67
|
+
};
|
|
61
68
|
/**
|
|
62
69
|
* One claim that a canonical record exists in an external system. A record
|
|
63
70
|
* merged from several providers carries one identity per provider.
|
|
@@ -323,6 +330,10 @@ export type PatchPlan = {
|
|
|
323
330
|
* Account/contact hygiene findings are NOT here; read `findings` for those. */
|
|
324
331
|
pipelineFindings?: PipelineFinding[];
|
|
325
332
|
evidence?: GtmEvidence[];
|
|
333
|
+
/** Assumptions used to turn audit observations into findings and operations. */
|
|
334
|
+
assumptions?: PatchPlanAssumption[];
|
|
335
|
+
/** Human decisions or unresolved questions that affect how the plan should be applied. */
|
|
336
|
+
openQuestions?: string[];
|
|
326
337
|
operations: PatchOperation[];
|
|
327
338
|
/**
|
|
328
339
|
* The filter this plan's operations were selected by. Re-evaluated per
|
|
@@ -374,6 +385,8 @@ export type GtmRuleContext = {
|
|
|
374
385
|
export type GtmRuleResult = {
|
|
375
386
|
findings: AuditFinding[];
|
|
376
387
|
operations: PatchOperation[];
|
|
388
|
+
/** Rule-level assumptions that explain the policy or heuristic behind returned findings. */
|
|
389
|
+
assumptions?: PatchPlanAssumption[];
|
|
377
390
|
};
|
|
378
391
|
/**
|
|
379
392
|
* A deterministic audit rule. Rules read the snapshot through the context and
|
|
@@ -385,6 +398,8 @@ export type GtmAuditRule = {
|
|
|
385
398
|
description: string;
|
|
386
399
|
/** Grouping for docs and discovery, e.g. "hygiene", "forecast", "coverage". */
|
|
387
400
|
category?: string;
|
|
401
|
+
/** False for findings-only rules whose evaluator intentionally emits no patch operations. */
|
|
402
|
+
emitsOperations?: boolean;
|
|
388
403
|
evaluate: (context: GtmRuleContext) => GtmRuleResult;
|
|
389
404
|
};
|
|
390
405
|
export type PatchOperationResult = {
|
|
@@ -435,6 +450,21 @@ export type GtmConnector = {
|
|
|
435
450
|
* result per input op. Optional: connectors without it use `applyOperation`.
|
|
436
451
|
*/
|
|
437
452
|
applyCreateContactsBatch?: (operations: PatchOperation[]) => Promise<PatchOperationResult[]>;
|
|
453
|
+
/**
|
|
454
|
+
* Optional bulk apply path for homogeneous operation runs selected by
|
|
455
|
+
* `applyPatchPlan` (currently consecutive `set_field` ops on one object type
|
|
456
|
+
* and consecutive `archive_record` ops on one object type). Implementations
|
|
457
|
+
* MUST return exactly one result per input operation, preserve per-operation
|
|
458
|
+
* CAS/conflict semantics for field writes, and must not write operations that
|
|
459
|
+
* would have conflicted under the serial `readField` + `applyOperation` path.
|
|
460
|
+
* Connectors without it use `applyOperation` per operation.
|
|
461
|
+
*/
|
|
462
|
+
applyBatch?: (operations: PatchOperation[]) => Promise<PatchOperationResult[]>;
|
|
463
|
+
/**
|
|
464
|
+
* Maximum number of operations `applyPatchPlan` should pass to one
|
|
465
|
+
* `applyBatch` call. Defaults to 200 when unspecified.
|
|
466
|
+
*/
|
|
467
|
+
applyBatchLimit?: number;
|
|
438
468
|
/**
|
|
439
469
|
* Read the live value of one canonical field, used for compare-and-set:
|
|
440
470
|
* apply orchestration refuses to write over values that drifted since the
|
package/docs/api.md
CHANGED
|
@@ -37,9 +37,10 @@ release.
|
|
|
37
37
|
|
|
38
38
|
## Connectors
|
|
39
39
|
|
|
40
|
-
- `GtmConnector` — `{ provider, fetchSnapshot(), applyOperation?, applyCreateContactsBatch?, readField?, fetchChanges? }`.
|
|
40
|
+
- `GtmConnector` — `{ provider, fetchSnapshot(), applyOperation?, applyCreateContactsBatch?, applyBatch?, applyBatchLimit?, readField?, fetchChanges? }`.
|
|
41
41
|
- Connectors never silently drop unresolvable records; audits surface them.
|
|
42
42
|
- `fetchChanges(sinceIso)` returns a partial snapshot; change feeds may omit associations.
|
|
43
|
+
- `applyBatch?(operations)` — optional bulk apply path for consecutive homogeneous `set_field`, `clear_field`, or `archive_record` runs selected by `applyPatchPlan`. It must return one result per input operation; field-write implementations preserve CAS by batch-reading live target values first and writing only clean records. Connectors without it stay on the sequential `applyOperation` path. `applyBatchLimit?` declares max operations per `applyBatch` call (default 200; Salesforce 200, HubSpot 100). Salesforce uses SOQL `IN` CAS reads plus Composite sObject Collections update/delete (`allOrNone:false`); HubSpot uses batch/read + batch/update/archive.
|
|
43
44
|
- `applyCreateContactsBatch?(operations)` — optional bulk fast-path for
|
|
44
45
|
independent `create_record` contact ops. `applyPatchPlan` routes safe-to-batch
|
|
45
46
|
creates here (batched resolve-first + batched create — ~N/100 calls instead of
|
|
@@ -68,7 +69,8 @@ release.
|
|
|
68
69
|
Commands: `init`, `login` / `logout`, `snapshot`, `audit`, `report`, `diff`, `merge`, `plans`,
|
|
69
70
|
`apply`, `suggest`, `audit-log` (`export` / `verify`),
|
|
70
71
|
`call` (`parse` / `classify` / `score` / `link` / `plan`), `resolve`,
|
|
71
|
-
`
|
|
72
|
+
`hierarchy` (`report`), `relationships` (`account`),
|
|
73
|
+
`route` (`leads`), `bulk-update`, `dedupe`, `reassign`, `fix`, `health`,
|
|
72
74
|
`market` (`init` / `capture` / `classify` / `worksheet` / `observe` / `fronts` /
|
|
73
75
|
`axes` / `overlay` / `scale` / `report` / `refresh`),
|
|
74
76
|
`tam` (`estimate` / `accounts` / `status` / `report` / `populate`),
|
|
@@ -159,8 +161,8 @@ per-rule detail with capped examples, and next steps. `auditReportToMarkdown` /
|
|
|
159
161
|
|
|
160
162
|
## Governed write verbs
|
|
161
163
|
|
|
162
|
-
Plan builders behind `bulk-update`, `dedupe`, and `reassign` — every
|
|
163
|
-
emits a standard dry-run `PatchPlan` for the normal approve → apply chain:
|
|
164
|
+
Plan builders behind `route`, `bulk-update`, `dedupe`, and `reassign` — every
|
|
165
|
+
one emits a standard dry-run `PatchPlan` for the normal approve → apply chain:
|
|
164
166
|
|
|
165
167
|
- `buildBulkUpdatePlan(snapshot, options: BulkUpdateOptions)` with
|
|
166
168
|
`parseWhere` (filter expressions: `=`, `!=`, `~`, `!~`, `:empty`,
|
|
@@ -178,6 +180,22 @@ emits a standard dry-run `PatchPlan` for the normal approve → apply chain:
|
|
|
178
180
|
`assignUnowned` (CLI `--assign-unowned`) it targets ownerless records
|
|
179
181
|
(`ownerId:empty`) and claims them for `--to` — the backfill twin of
|
|
180
182
|
`enrich acquire`'s create-time assignment, for clearing existing ownerless debt.
|
|
183
|
+
- `buildLeadRoutePlan(snapshot, options: LeadRouteOptions)` — matches contacts
|
|
184
|
+
to accounts by email domain and optional company-name fallback, skips free
|
|
185
|
+
email domains and ambiguous matches, then emits approval-gated account-link
|
|
186
|
+
and owner-assignment operations. Owner routing can inherit active account
|
|
187
|
+
owners for ownerless contacts or use the shared deterministic
|
|
188
|
+
`AssignmentPolicy`; existing owners are preserved unless
|
|
189
|
+
`reassignExistingOwner` (CLI `--reassign-owned`) is set.
|
|
190
|
+
|
|
191
|
+
Read-only prevention reports:
|
|
192
|
+
|
|
193
|
+
- `buildAccountHierarchy(snapshot)` / `accountHierarchyToMarkdown(report)` —
|
|
194
|
+
provider parent hints plus subdomain inference, with duplicate-domain,
|
|
195
|
+
ambiguous-parent, and parent-cycle conflicts surfaced rather than written.
|
|
196
|
+
- `buildRelationshipMap(snapshot, { accountId | domain })` /
|
|
197
|
+
`relationshipMapToMarkdown(map)` — account stakeholders, inferred roles,
|
|
198
|
+
activity sentiment evidence, open deals, and missing-role gaps.
|
|
181
199
|
|
|
182
200
|
`fix` is CLI-only composition of existing surfaces (audit → suggest →
|
|
183
201
|
approve → apply for one rule).
|
package/docs/architecture.md
CHANGED
|
@@ -57,9 +57,12 @@ provider ──fetchSnapshot()──► CanonicalGtmSnapshot
|
|
|
57
57
|
- `auditLog.ts` — hash-chained, signed export of every apply run.
|
|
58
58
|
|
|
59
59
|
**Governed write verbs (each builds a plan; never writes directly)**
|
|
60
|
-
- `bulkUpdate.ts`, `dedupe.ts`, `reassign.ts` — filtered
|
|
61
|
-
|
|
60
|
+
- `bulkUpdate.ts`, `dedupe.ts`, `reassign.ts`, `route.ts` — filtered,
|
|
61
|
+
duplicate, ownership handoff, and lead-to-account/owner routing plan builders.
|
|
62
|
+
`merge.ts` — snapshot diff/merge + entity resolution.
|
|
62
63
|
- `resolve.ts` — the create-gate (exists / ambiguous / safe_to_create).
|
|
64
|
+
- `hierarchy.ts`, `relationships.ts` — read-only account hierarchy and
|
|
65
|
+
stakeholder relationship-map reports for planning/prevention workflows.
|
|
63
66
|
|
|
64
67
|
**Connectors** (`connectors/`)
|
|
65
68
|
- `hubspot.ts`, `salesforce.ts`, `stripe.ts` + their `*Auth.ts` OAuth/device
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.0",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
|
|
@@ -94,7 +94,7 @@ Tools over stdio: `fullstackgtm_audit` (read-only), `fullstackgtm_rules`,
|
|
|
94
94
|
This CLI ships governed **primitives** — there is no `outbound` mega-command.
|
|
95
95
|
**You (the agent) are the orchestrator:** chain these verbs into the play the
|
|
96
96
|
operator wants, surface the one approve gate, and bridge the last mile to the
|
|
97
|
-
sender (**the package never sends**). [docs/recipes.md](https://github.com/fullstackgtm/core/blob/main/
|
|
97
|
+
sender (**the package never sends**). [docs/recipes.md](https://github.com/fullstackgtm/core/blob/main/docs/recipes.md)
|
|
98
98
|
has five worked plays — cold-start lead-fill, the trigger→judge→draft outbound
|
|
99
99
|
loop, scheduled-continuous, ABM-from-companies, and hygiene-gated outbound.
|
|
100
100
|
`fullstackgtm init` scaffolds a workspace (icp.json + enrich config + a PLAYBOOK
|
|
@@ -102,7 +102,7 @@ pointing at those recipes) to start from.
|
|
|
102
102
|
|
|
103
103
|
## Going deeper
|
|
104
104
|
|
|
105
|
-
- [docs/recipes.md](https://github.com/fullstackgtm/core/blob/main/
|
|
105
|
+
- [docs/recipes.md](https://github.com/fullstackgtm/core/blob/main/docs/recipes.md) — five composable GTM plays over the primitives (cold-start, outbound loop, scheduled, ABM, hygiene-gated)
|
|
106
106
|
- [llms.txt](https://github.com/fullstackgtm/core/blob/main/llms.txt) — the full invariant map per layer (calls, market, write verbs, enrich, schedule, engagement/health)
|
|
107
107
|
- [INSTALL_FOR_AGENTS.md](https://github.com/fullstackgtm/core/blob/main/INSTALL_FOR_AGENTS.md) — deterministic install-and-verify with expected outputs
|
|
108
108
|
- [docs/api.md](https://github.com/fullstackgtm/core/blob/main/docs/api.md) — semver-covered surfaces: canonical model, rule interface, plan/apply contract, connectors, config, CLI, MCP
|