fullstackgtm 0.47.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 +38 -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/connectors/hubspot.d.ts +2 -1
- package/dist/connectors/salesforce.d.ts +2 -1
- 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 +15 -0
- package/docs/api.md +20 -3
- 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/connectors/hubspot.ts +3 -1
- package/src/connectors/salesforce.ts +3 -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 +17 -0
package/src/index.ts
CHANGED
|
@@ -12,6 +12,27 @@ export {
|
|
|
12
12
|
export { buildBulkUpdatePlan, isFilterableField, parseWhere, type BulkUpdateOptions } from "./bulkUpdate.ts";
|
|
13
13
|
export { buildDedupePlan, dedupeKey, type DedupeOptions } from "./dedupe.ts";
|
|
14
14
|
export { buildReassignPlans, type ReassignObjectType, type ReassignOptions } from "./reassign.ts";
|
|
15
|
+
export {
|
|
16
|
+
buildLeadRoutePlan,
|
|
17
|
+
type LeadRouteCounts,
|
|
18
|
+
type LeadRouteOptions,
|
|
19
|
+
type LeadRouteResult,
|
|
20
|
+
} from "./route.ts";
|
|
21
|
+
export {
|
|
22
|
+
accountHierarchyToMarkdown,
|
|
23
|
+
buildAccountHierarchy,
|
|
24
|
+
type AccountHierarchyConflict,
|
|
25
|
+
type AccountHierarchyNode,
|
|
26
|
+
type AccountHierarchyReport,
|
|
27
|
+
} from "./hierarchy.ts";
|
|
28
|
+
export {
|
|
29
|
+
buildRelationshipMap,
|
|
30
|
+
relationshipMapToMarkdown,
|
|
31
|
+
type RelationshipMap,
|
|
32
|
+
type StakeholderNode,
|
|
33
|
+
type StakeholderRole,
|
|
34
|
+
type StakeholderSentiment,
|
|
35
|
+
} from "./relationships.ts";
|
|
15
36
|
export {
|
|
16
37
|
CONFIG_FILE_NAME,
|
|
17
38
|
loadConfig,
|
|
@@ -209,6 +230,7 @@ export {
|
|
|
209
230
|
type AuditLogExport,
|
|
210
231
|
type AuditLogVerification,
|
|
211
232
|
} from "./auditLog.ts";
|
|
233
|
+
export { FREE_EMAIL_DOMAINS } from "./freeEmailDomains.ts";
|
|
212
234
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
213
235
|
export {
|
|
214
236
|
computeHealth,
|
|
@@ -236,6 +258,7 @@ export {
|
|
|
236
258
|
buildSnapshotIndex,
|
|
237
259
|
builtinAuditRules,
|
|
238
260
|
closingSoonInactiveRule,
|
|
261
|
+
isFindingsOnlyRule,
|
|
239
262
|
duplicateAccountDomainRule,
|
|
240
263
|
duplicateContactEmailRule,
|
|
241
264
|
duplicateOpenDealRule,
|
|
@@ -540,6 +563,8 @@ export type {
|
|
|
540
563
|
PatchOperationResult,
|
|
541
564
|
PatchOperationType,
|
|
542
565
|
PatchPlan,
|
|
566
|
+
PatchPlanAssumption,
|
|
567
|
+
PatchPlanAssumptionConfidence,
|
|
543
568
|
PatchPlanRun,
|
|
544
569
|
PatchPlanRunStatus,
|
|
545
570
|
PatchVerification,
|
package/src/mcp.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ZodRawShape } from "zod/v4";
|
|
1
2
|
import { readFileSync } from "node:fs";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
3
4
|
import { join, resolve } from "node:path";
|
|
@@ -159,13 +160,17 @@ function packageVersion() {
|
|
|
159
160
|
}
|
|
160
161
|
}
|
|
161
162
|
|
|
163
|
+
function strictSchema<T extends ZodRawShape>(shape: T) {
|
|
164
|
+
return z.object(shape).strict();
|
|
165
|
+
}
|
|
166
|
+
|
|
162
167
|
type ToolDefinition = {
|
|
163
168
|
name: string;
|
|
164
169
|
// Whether the tool can write to a CRM. Everything else reads, or writes
|
|
165
170
|
// only local workspace state (market_observe appends to the local
|
|
166
171
|
// observation store).
|
|
167
172
|
writesCrm: boolean;
|
|
168
|
-
config: { title: string; description: string; inputSchema?:
|
|
173
|
+
config: { title: string; description: string; inputSchema?: unknown };
|
|
169
174
|
// Args are validated by the SDK against config.inputSchema before the
|
|
170
175
|
// handler runs; the loose typing here is what lets one table drive a
|
|
171
176
|
// registration loop instead of per-call generics.
|
|
@@ -187,7 +192,7 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
187
192
|
"Run a dry-run GTM hygiene audit and return a reviewable patch plan. " +
|
|
188
193
|
"Sources: the realistic zero-credential demo CRM (provider: \"demo\" — richest test data), " +
|
|
189
194
|
"the minimal sample dataset, a snapshot file, or a live provider.",
|
|
190
|
-
inputSchema: {
|
|
195
|
+
inputSchema: strictSchema({
|
|
191
196
|
provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
|
|
192
197
|
inputPath: z.string().optional(),
|
|
193
198
|
configPath: z.string().optional(),
|
|
@@ -195,7 +200,7 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
195
200
|
output: z.enum(["json", "markdown"]).optional(),
|
|
196
201
|
today: z.string().optional(),
|
|
197
202
|
staleDealDays: z.number().int().positive().optional(),
|
|
198
|
-
},
|
|
203
|
+
}),
|
|
199
204
|
},
|
|
200
205
|
handler: async ({ provider, inputPath, configPath, rules, output, today, staleDealDays }) => {
|
|
201
206
|
const loaded = configPath ? loadConfig(configPath) : null;
|
|
@@ -219,11 +224,11 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
219
224
|
"Derive values for a plan's requires_human_* placeholder operations from snapshot " +
|
|
220
225
|
"evidence (account-name matching, contact associations), with confidence levels and " +
|
|
221
226
|
"reasons. Read-only; feed accepted values into fullstackgtm_apply's valueOverrides.",
|
|
222
|
-
inputSchema: {
|
|
227
|
+
inputSchema: strictSchema({
|
|
223
228
|
planPath: z.string(),
|
|
224
229
|
provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
|
|
225
230
|
inputPath: z.string().optional(),
|
|
226
|
-
},
|
|
231
|
+
}),
|
|
227
232
|
},
|
|
228
233
|
handler: async ({ planPath, provider, inputPath }) => {
|
|
229
234
|
const plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8")) as PatchPlan;
|
|
@@ -242,14 +247,14 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
242
247
|
"uses LLM extraction when an Anthropic/OpenAI key is configured in the server " +
|
|
243
248
|
"environment or credential store, else the free deterministic keyword baseline; " +
|
|
244
249
|
"'llm' and 'deterministic' force either. Read-only; every insight is provenance-marked.",
|
|
245
|
-
inputSchema: {
|
|
250
|
+
inputSchema: strictSchema({
|
|
246
251
|
transcript: z.string().optional(),
|
|
247
252
|
transcriptPath: z.string().optional(),
|
|
248
253
|
title: z.string().optional(),
|
|
249
254
|
source: z.enum(["gong", "chorus", "fathom", "manual", "csv", "unknown"]).optional(),
|
|
250
255
|
extractor: z.enum(["auto", "llm", "deterministic"]).optional(),
|
|
251
256
|
model: z.string().optional(),
|
|
252
|
-
},
|
|
257
|
+
}),
|
|
253
258
|
},
|
|
254
259
|
handler: async ({ transcript, transcriptPath, title, source, extractor, model }) => {
|
|
255
260
|
const raw =
|
|
@@ -287,7 +292,7 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
287
292
|
"(exists | ambiguous | safe_to_create) with matches and a reason, using the same " +
|
|
288
293
|
"identity keys as the audit/merge engines (account domain, contact email, open-deal " +
|
|
289
294
|
"key). Read-only. Never create on 'exists' or 'ambiguous'.",
|
|
290
|
-
inputSchema: {
|
|
295
|
+
inputSchema: strictSchema({
|
|
291
296
|
objectType: z.enum(["account", "contact", "deal"]),
|
|
292
297
|
name: z.string().optional(),
|
|
293
298
|
domain: z.string().optional(),
|
|
@@ -295,7 +300,7 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
295
300
|
accountId: z.string().optional(),
|
|
296
301
|
provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
|
|
297
302
|
inputPath: z.string().optional(),
|
|
298
|
-
},
|
|
303
|
+
}),
|
|
299
304
|
},
|
|
300
305
|
handler: async ({ objectType, name, domain, email, accountId, provider, inputPath }) => {
|
|
301
306
|
const snapshot = await readSnapshot(provider, inputPath);
|
|
@@ -308,7 +313,7 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
308
313
|
config: {
|
|
309
314
|
title: "List Audit Rules",
|
|
310
315
|
description: "List the built-in deterministic audit rules with ids and descriptions.",
|
|
311
|
-
inputSchema: {},
|
|
316
|
+
inputSchema: strictSchema({}),
|
|
312
317
|
},
|
|
313
318
|
handler: async () => {
|
|
314
319
|
return content(
|
|
@@ -329,13 +334,13 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
329
334
|
"the store's HMAC approval digests — ids not approved with `plans approve` are " +
|
|
330
335
|
"refused — and the run is recorded onto the stored plan so `plans show` and " +
|
|
331
336
|
"`audit-log export` include it.",
|
|
332
|
-
inputSchema: {
|
|
337
|
+
inputSchema: strictSchema({
|
|
333
338
|
provider: z.enum(["hubspot", "salesforce"]),
|
|
334
339
|
planPath: z.string(),
|
|
335
340
|
approvedOperationIds: z.array(z.string()).min(1),
|
|
336
341
|
valueOverrides: z.record(z.string(), z.string()).optional(),
|
|
337
342
|
output: z.enum(["json", "markdown"]).optional(),
|
|
338
|
-
},
|
|
343
|
+
}),
|
|
339
344
|
},
|
|
340
345
|
handler: async ({ provider, planPath, approvedOperationIds, valueOverrides, output }) => {
|
|
341
346
|
// The file may be a raw PatchPlan (audit --out) or a StoredPlan envelope
|
|
@@ -474,11 +479,11 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
474
479
|
"and quote verbatim spans (≤300 chars) for every loud/quiet reading. Submit the full " +
|
|
475
480
|
"ObservationSet via fullstackgtm_market_observe — quotes are verified character-for-" +
|
|
476
481
|
"character against the captures, so never paraphrase.",
|
|
477
|
-
inputSchema: {
|
|
482
|
+
inputSchema: strictSchema({
|
|
478
483
|
vendorId: z.string(),
|
|
479
484
|
configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
|
|
480
485
|
captureRun: z.string().optional(),
|
|
481
|
-
},
|
|
486
|
+
}),
|
|
482
487
|
},
|
|
483
488
|
handler: async ({ vendorId, configPath, captureRun }) => {
|
|
484
489
|
const config = loadMarketConfigOrHint(resolve(process.cwd(), configPath ?? "market.config.json"));
|
|
@@ -495,10 +500,10 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
495
500
|
"Validates coverage, the verbatim-evidence rule, and mechanically verifies every quoted " +
|
|
496
501
|
"span against the stored capture it cites. Returns problems if rejected; nothing is " +
|
|
497
502
|
"stored unless the whole set passes. Observations are append-only — use a new runLabel.",
|
|
498
|
-
inputSchema: {
|
|
503
|
+
inputSchema: strictSchema({
|
|
499
504
|
observationsPath: z.string().describe("Path to the ObservationSet JSON file"),
|
|
500
505
|
configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
|
|
501
|
-
},
|
|
506
|
+
}),
|
|
502
507
|
},
|
|
503
508
|
handler: async ({ observationsPath, configPath }) => {
|
|
504
509
|
const config = loadMarketConfigOrHint(resolve(process.cwd(), configPath ?? "market.config.json"));
|
|
@@ -531,6 +536,7 @@ toolDefinitions.unshift({
|
|
|
531
536
|
"Machine-readable contract for this MCP server: the tool inventory (derived from the " +
|
|
532
537
|
"server's own registration table, with per-tool CRM-write flags), safety defaults, and " +
|
|
533
538
|
"the CLI entrypoints for everything the tools don't cover.",
|
|
539
|
+
inputSchema: strictSchema({}),
|
|
534
540
|
},
|
|
535
541
|
handler: async () =>
|
|
536
542
|
content({
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { normalizeDomain } from "./merge.ts";
|
|
2
|
+
import type { CanonicalAccount, CanonicalContact, CanonicalGtmSnapshot } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export type StakeholderRole = "economic_buyer" | "decision_maker" | "technical_buyer" | "champion" | "influencer" | "unknown";
|
|
5
|
+
export type StakeholderSentiment = "positive" | "neutral" | "negative" | "unknown";
|
|
6
|
+
|
|
7
|
+
export type StakeholderNode = {
|
|
8
|
+
contactId: string;
|
|
9
|
+
name: string;
|
|
10
|
+
title?: string;
|
|
11
|
+
email?: string;
|
|
12
|
+
role: StakeholderRole;
|
|
13
|
+
sentiment: StakeholderSentiment;
|
|
14
|
+
lastActivityAt?: string;
|
|
15
|
+
evidence: string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type RelationshipMap = {
|
|
19
|
+
account: { accountId: string; name: string; domain?: string };
|
|
20
|
+
stakeholders: StakeholderNode[];
|
|
21
|
+
openDeals: Array<{ dealId: string; name: string; stage?: string; amount?: number; ownerId?: string }>;
|
|
22
|
+
gaps: string[];
|
|
23
|
+
counts: { stakeholders: number; openDeals: number; activities: number };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function fullName(contact: CanonicalContact): string {
|
|
27
|
+
return [contact.firstName, contact.lastName].filter(Boolean).join(" ") || contact.email || contact.id;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function inferRole(title: string | undefined): StakeholderRole {
|
|
31
|
+
const t = (title ?? "").toLowerCase();
|
|
32
|
+
if (/\b(cfo|ceo|coo|cro|chief|founder|owner|president)\b/.test(t)) return "economic_buyer";
|
|
33
|
+
if (/\b(vp|vice president|head of|director|gm|general manager)\b/.test(t)) return "decision_maker";
|
|
34
|
+
if (/\b(cto|cio|security|it|engineering|architect|developer|technical|data)\b/.test(t)) return "technical_buyer";
|
|
35
|
+
if (/\b(champion|revops|sales ops|revenue operations|operations)\b/.test(t)) return "champion";
|
|
36
|
+
if (t) return "influencer";
|
|
37
|
+
return "unknown";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function inferSentiment(text: string): StakeholderSentiment {
|
|
41
|
+
const lower = text.toLowerCase();
|
|
42
|
+
if (/\b(blocked|concern|risk|no budget|not interested|detractor|negative|unhappy)\b/.test(lower)) return "negative";
|
|
43
|
+
if (/\b(champion|excited|interested|positive|approved|referred|next step|pilot)\b/.test(lower)) return "positive";
|
|
44
|
+
return "neutral";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function maxSentiment(a: StakeholderSentiment, b: StakeholderSentiment): StakeholderSentiment {
|
|
48
|
+
const rank: Record<StakeholderSentiment, number> = { unknown: 0, neutral: 1, positive: 2, negative: 3 };
|
|
49
|
+
return rank[b] > rank[a] ? b : a;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function findAccount(snapshot: CanonicalGtmSnapshot, selector: { accountId?: string; domain?: string }): CanonicalAccount | null {
|
|
53
|
+
if (selector.accountId) return snapshot.accounts.find((account) => String(account.id) === selector.accountId) ?? null;
|
|
54
|
+
if (selector.domain) {
|
|
55
|
+
const domain = normalizeDomain(selector.domain);
|
|
56
|
+
return snapshot.accounts.find((account) => normalizeDomain(account.domain) === domain) ?? null;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildRelationshipMap(snapshot: CanonicalGtmSnapshot, selector: { accountId?: string; domain?: string }): RelationshipMap {
|
|
62
|
+
const account = findAccount(snapshot, selector);
|
|
63
|
+
if (!account) throw new Error("relationships: account not found (use --account-id or --domain)");
|
|
64
|
+
const contacts = snapshot.contacts.filter((contact) => contact.accountId === account.id);
|
|
65
|
+
const contactIds = new Set(contacts.map((contact) => contact.id));
|
|
66
|
+
const activities = snapshot.activities.filter((activity) => activity.accountId === account.id || (activity.contactId ? contactIds.has(activity.contactId) : false));
|
|
67
|
+
const evidenceByContact = new Map<string, string[]>();
|
|
68
|
+
const sentimentByContact = new Map<string, StakeholderSentiment>();
|
|
69
|
+
for (const activity of activities) {
|
|
70
|
+
if (!activity.contactId || !contactIds.has(activity.contactId)) continue;
|
|
71
|
+
const text = [activity.subject, activity.type, activity.occurredAt].filter(Boolean).join(" — ");
|
|
72
|
+
evidenceByContact.set(activity.contactId, [...(evidenceByContact.get(activity.contactId) ?? []), text]);
|
|
73
|
+
sentimentByContact.set(activity.contactId, maxSentiment(sentimentByContact.get(activity.contactId) ?? "unknown", inferSentiment(text)));
|
|
74
|
+
}
|
|
75
|
+
const stakeholders = contacts.map((contact) => ({
|
|
76
|
+
contactId: String(contact.id),
|
|
77
|
+
name: fullName(contact),
|
|
78
|
+
...(contact.title ? { title: contact.title } : {}),
|
|
79
|
+
...(contact.email ? { email: contact.email } : {}),
|
|
80
|
+
role: inferRole(contact.title),
|
|
81
|
+
sentiment: sentimentByContact.get(contact.id) ?? "unknown",
|
|
82
|
+
...(contact.lastActivityAt ? { lastActivityAt: contact.lastActivityAt } : {}),
|
|
83
|
+
evidence: evidenceByContact.get(contact.id) ?? [],
|
|
84
|
+
})).sort((a, b) => a.role.localeCompare(b.role) || a.name.localeCompare(b.name));
|
|
85
|
+
const openDeals = snapshot.deals.filter((deal) => deal.accountId === account.id && !deal.isClosed).map((deal) => ({
|
|
86
|
+
dealId: String(deal.id),
|
|
87
|
+
name: deal.name,
|
|
88
|
+
...(deal.stage ? { stage: deal.stage } : {}),
|
|
89
|
+
...(deal.amount !== undefined ? { amount: deal.amount } : {}),
|
|
90
|
+
...(deal.ownerId ? { ownerId: deal.ownerId } : {}),
|
|
91
|
+
}));
|
|
92
|
+
const roles = new Set(stakeholders.map((s) => s.role));
|
|
93
|
+
const gaps: string[] = [];
|
|
94
|
+
if (!roles.has("economic_buyer")) gaps.push("No obvious economic buyer found from titles.");
|
|
95
|
+
if (!roles.has("decision_maker")) gaps.push("No obvious decision maker found from titles.");
|
|
96
|
+
if (stakeholders.every((s) => s.sentiment === "unknown")) gaps.push("No stakeholder activity evidence attached to this account.");
|
|
97
|
+
return { account: { accountId: String(account.id), name: account.name, ...(account.domain ? { domain: account.domain } : {}) }, stakeholders, openDeals, gaps, counts: { stakeholders: stakeholders.length, openDeals: openDeals.length, activities: activities.length } };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function relationshipMapToMarkdown(map: RelationshipMap): string {
|
|
101
|
+
const lines = [`# Relationship map: ${map.account.name}`, "", `${map.counts.stakeholders} stakeholder(s), ${map.counts.openDeals} open deal(s), ${map.counts.activities} activity record(s).`, "", "## Stakeholders"];
|
|
102
|
+
for (const s of map.stakeholders) {
|
|
103
|
+
lines.push(`- ${s.name} — ${s.role}, ${s.sentiment}${s.title ? ` (${s.title})` : ""}`);
|
|
104
|
+
for (const evidence of s.evidence.slice(0, 3)) lines.push(` - evidence: ${evidence}`);
|
|
105
|
+
}
|
|
106
|
+
if (map.openDeals.length) {
|
|
107
|
+
lines.push("", "## Open deals");
|
|
108
|
+
for (const deal of map.openDeals) lines.push(`- ${deal.name} (${deal.dealId})${deal.stage ? ` — ${deal.stage}` : ""}${deal.amount !== undefined ? ` — ${deal.amount}` : ""}`);
|
|
109
|
+
}
|
|
110
|
+
if (map.gaps.length) {
|
|
111
|
+
lines.push("", "## Gaps");
|
|
112
|
+
for (const gap of map.gaps) lines.push(`- ${gap}`);
|
|
113
|
+
}
|
|
114
|
+
return lines.join("\n");
|
|
115
|
+
}
|
package/src/route.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { resolveAssignment, type AssignmentContext, type AssignmentPolicy } from "./assign.ts";
|
|
2
|
+
import { FREE_EMAIL_DOMAINS } from "./freeEmailDomains.ts";
|
|
3
|
+
import { normalizeDomain } from "./merge.ts";
|
|
4
|
+
import { stableHash } from "./rules.ts";
|
|
5
|
+
import type { CanonicalAccount, CanonicalContact, CanonicalGtmSnapshot, PatchOperation, PatchPlan } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Lead-to-account matching and routing recipes.
|
|
9
|
+
*
|
|
10
|
+
* This is the first packaged Traction-Complete-style workflow in the OSS CLI:
|
|
11
|
+
* take ownerless/unmatched leads, deterministically match them to accounts, and
|
|
12
|
+
* emit normal approval-gated patch operations. No writes happen here.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type LeadRouteOptions = {
|
|
16
|
+
/** Match contact email domains to account domains. Default true. */
|
|
17
|
+
matchByDomain?: boolean;
|
|
18
|
+
/** Also try raw company/name fields against account names when domain misses. */
|
|
19
|
+
matchByCompanyName?: boolean;
|
|
20
|
+
/** When an ownerless contact matches an owned account, set contact ownerId to the account owner. Default true. */
|
|
21
|
+
inheritAccountOwner?: boolean;
|
|
22
|
+
/** Reassign contacts that already have an owner when the matched account owner differs. Default false. */
|
|
23
|
+
reassignExistingOwner?: boolean;
|
|
24
|
+
/** Optional fallback policy for contacts that still do not have an owner. */
|
|
25
|
+
assignmentPolicy?: AssignmentPolicy;
|
|
26
|
+
/** Refuse to build larger plans unless explicitly raised. Default 500. */
|
|
27
|
+
maxOperations?: number;
|
|
28
|
+
reason?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type LeadRouteCounts = {
|
|
32
|
+
contactsScanned: number;
|
|
33
|
+
/** Contacts that ended with an unambiguous account context (existing link, domain match, or company-name match). */
|
|
34
|
+
matchedContacts: number;
|
|
35
|
+
matchedExistingAccount: number;
|
|
36
|
+
matchedByDomain: number;
|
|
37
|
+
matchedByCompanyName: number;
|
|
38
|
+
ambiguousAccountMatches: number;
|
|
39
|
+
skippedFreeEmail: number;
|
|
40
|
+
linkOperations: number;
|
|
41
|
+
/** Owner set_field operations proposed (account-owner inheritance + policy fallback). */
|
|
42
|
+
ownerOperations: number;
|
|
43
|
+
/** Alias for ownerOperations, named for reports that talk about owner-assigned leads. */
|
|
44
|
+
ownerAssigned: number;
|
|
45
|
+
policyAssigned: number;
|
|
46
|
+
unmatched: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type LeadRouteResult = {
|
|
50
|
+
plan: PatchPlan;
|
|
51
|
+
counts: LeadRouteCounts;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function norm(value: unknown): string | undefined {
|
|
55
|
+
if (value === undefined || value === null) return undefined;
|
|
56
|
+
const out = String(value).trim().toLowerCase();
|
|
57
|
+
return out || undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function contactEmailDomain(contact: CanonicalContact): string | undefined {
|
|
61
|
+
const email = norm(contact.email);
|
|
62
|
+
if (!email || !email.includes("@")) return undefined;
|
|
63
|
+
return normalizeDomain(email.slice(email.lastIndexOf("@") + 1));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function rawString(record: { raw?: unknown }, keys: string[]): string | undefined {
|
|
67
|
+
if (!record.raw || typeof record.raw !== "object") return undefined;
|
|
68
|
+
const raw = record.raw as Record<string, unknown>;
|
|
69
|
+
for (const key of keys) {
|
|
70
|
+
const value = raw[key];
|
|
71
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
72
|
+
if (value && typeof value === "object") {
|
|
73
|
+
const nested = value as Record<string, unknown>;
|
|
74
|
+
for (const nestedKey of ["value", "name", "label"]) {
|
|
75
|
+
if (typeof nested[nestedKey] === "string" && String(nested[nestedKey]).trim()) {
|
|
76
|
+
return String(nested[nestedKey]).trim();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function companyNameFor(contact: CanonicalContact): string | undefined {
|
|
85
|
+
return rawString(contact, ["company", "companyName", "company_name", "account", "accountName", "account_name"]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function uniqueAccount(matches: CanonicalAccount[]): CanonicalAccount | "ambiguous" | null {
|
|
89
|
+
const byId = new Map(matches.map((account) => [String(account.id), account]));
|
|
90
|
+
const unique = [...byId.values()];
|
|
91
|
+
if (unique.length === 0) return null;
|
|
92
|
+
if (unique.length > 1) return "ambiguous";
|
|
93
|
+
return unique[0];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function employeeBand(employeeCount: number | undefined): string | undefined {
|
|
97
|
+
if (employeeCount === undefined) return undefined;
|
|
98
|
+
if (employeeCount <= 10) return "1-10";
|
|
99
|
+
if (employeeCount <= 50) return "11-50";
|
|
100
|
+
if (employeeCount <= 200) return "51-200";
|
|
101
|
+
if (employeeCount <= 1000) return "201-1000";
|
|
102
|
+
return "1001+";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function routeContext(contact: CanonicalContact, account?: CanonicalAccount): AssignmentContext {
|
|
106
|
+
const geo = norm(rawString(contact, ["country", "countryCode", "country_code", "state", "region"])) ??
|
|
107
|
+
norm(rawString(account ?? { raw: undefined }, ["country", "countryCode", "country_code", "state", "region"]));
|
|
108
|
+
const department = norm(rawString(contact, ["department", "jobDepartment", "job_department"]));
|
|
109
|
+
return {
|
|
110
|
+
geo,
|
|
111
|
+
industry: norm(account?.industry),
|
|
112
|
+
employeeBand: employeeBand(account?.employeeCount),
|
|
113
|
+
department,
|
|
114
|
+
title: norm(contact.title),
|
|
115
|
+
accountOwnerId: account?.ownerId,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function activeOwnerIds(snapshot: CanonicalGtmSnapshot): Set<string> {
|
|
120
|
+
const ids = new Set<string>();
|
|
121
|
+
for (const user of snapshot.users) {
|
|
122
|
+
if (user.active === false) continue;
|
|
123
|
+
ids.add(String(user.id));
|
|
124
|
+
if (user.crmId) ids.add(String(user.crmId));
|
|
125
|
+
}
|
|
126
|
+
return ids;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function buildLeadRoutePlan(snapshot: CanonicalGtmSnapshot, options: LeadRouteOptions = {}): LeadRouteResult {
|
|
130
|
+
const matchByDomain = options.matchByDomain ?? true;
|
|
131
|
+
const matchByCompanyName = options.matchByCompanyName ?? false;
|
|
132
|
+
const inheritAccountOwner = options.inheritAccountOwner ?? true;
|
|
133
|
+
const reassignExistingOwner = options.reassignExistingOwner ?? false;
|
|
134
|
+
const maxOperations = options.maxOperations ?? 500;
|
|
135
|
+
|
|
136
|
+
const accountsByDomain = new Map<string, CanonicalAccount[]>();
|
|
137
|
+
const accountsByName = new Map<string, CanonicalAccount[]>();
|
|
138
|
+
for (const account of snapshot.accounts) {
|
|
139
|
+
const domain = normalizeDomain(account.domain);
|
|
140
|
+
if (domain) accountsByDomain.set(domain, [...(accountsByDomain.get(domain) ?? []), account]);
|
|
141
|
+
const name = norm(account.name);
|
|
142
|
+
if (name) accountsByName.set(name, [...(accountsByName.get(name) ?? []), account]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const operations: PatchOperation[] = [];
|
|
146
|
+
const counts: LeadRouteCounts = {
|
|
147
|
+
contactsScanned: 0,
|
|
148
|
+
matchedContacts: 0,
|
|
149
|
+
matchedExistingAccount: 0,
|
|
150
|
+
matchedByDomain: 0,
|
|
151
|
+
matchedByCompanyName: 0,
|
|
152
|
+
ambiguousAccountMatches: 0,
|
|
153
|
+
skippedFreeEmail: 0,
|
|
154
|
+
linkOperations: 0,
|
|
155
|
+
ownerOperations: 0,
|
|
156
|
+
ownerAssigned: 0,
|
|
157
|
+
policyAssigned: 0,
|
|
158
|
+
unmatched: 0,
|
|
159
|
+
};
|
|
160
|
+
const knownOwners = activeOwnerIds(snapshot);
|
|
161
|
+
let policyAssignmentIndex = 0;
|
|
162
|
+
|
|
163
|
+
for (const contact of snapshot.contacts) {
|
|
164
|
+
counts.contactsScanned += 1;
|
|
165
|
+
const currentAccount = contact.accountId ? snapshot.accounts.find((account) => account.id === contact.accountId) : undefined;
|
|
166
|
+
let matchedAccount: CanonicalAccount | undefined = currentAccount;
|
|
167
|
+
let matchRule: "existing" | "domain" | "company-name" | undefined = currentAccount ? "existing" : undefined;
|
|
168
|
+
if (currentAccount) counts.matchedExistingAccount += 1;
|
|
169
|
+
|
|
170
|
+
let skippedFreeEmail = false;
|
|
171
|
+
if (!matchedAccount && matchByDomain) {
|
|
172
|
+
const domain = contactEmailDomain(contact);
|
|
173
|
+
if (domain && FREE_EMAIL_DOMAINS.has(domain)) {
|
|
174
|
+
counts.skippedFreeEmail += 1;
|
|
175
|
+
skippedFreeEmail = true;
|
|
176
|
+
} else if (domain) {
|
|
177
|
+
const match = uniqueAccount(accountsByDomain.get(domain) ?? []);
|
|
178
|
+
if (match === "ambiguous") counts.ambiguousAccountMatches += 1;
|
|
179
|
+
else if (match) {
|
|
180
|
+
matchedAccount = match;
|
|
181
|
+
matchRule = "domain";
|
|
182
|
+
counts.matchedByDomain += 1;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!matchedAccount && matchByCompanyName && !skippedFreeEmail) {
|
|
188
|
+
const company = norm(companyNameFor(contact));
|
|
189
|
+
if (company && (accountsByName.get(company) ?? []).length > 0) {
|
|
190
|
+
// Name-only account matches are not safe enough to auto-link. Keep the
|
|
191
|
+
// contact unmatched and surface the skipped match as ambiguous.
|
|
192
|
+
counts.ambiguousAccountMatches += 1;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (matchedAccount) counts.matchedContacts += 1;
|
|
197
|
+
|
|
198
|
+
const contactOps: PatchOperation[] = [];
|
|
199
|
+
if (matchedAccount && !contact.accountId) {
|
|
200
|
+
contactOps.push({
|
|
201
|
+
id: `op_${stableHash(`route:link:${contact.id}:${matchedAccount.id}`)}`,
|
|
202
|
+
objectType: "contact",
|
|
203
|
+
objectId: String(contact.id),
|
|
204
|
+
operation: "link_record",
|
|
205
|
+
field: "accountId",
|
|
206
|
+
beforeValue: contact.accountId ?? null,
|
|
207
|
+
afterValue: String(matchedAccount.id),
|
|
208
|
+
reason: options.reason ?? `Lead-to-account match by ${matchRule}: link contact to account ${matchedAccount.name} (${matchedAccount.id}).`,
|
|
209
|
+
riskLevel: "medium",
|
|
210
|
+
approvalRequired: true,
|
|
211
|
+
sourceRuleOrPolicy: "route.lead_to_account",
|
|
212
|
+
});
|
|
213
|
+
counts.linkOperations += 1;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let ownerId: string | null = null;
|
|
217
|
+
let ownerRule: string | undefined;
|
|
218
|
+
if (
|
|
219
|
+
inheritAccountOwner &&
|
|
220
|
+
matchedAccount?.ownerId &&
|
|
221
|
+
matchedAccount.ownerId !== contact.ownerId &&
|
|
222
|
+
(!contact.ownerId || reassignExistingOwner) &&
|
|
223
|
+
knownOwners.has(matchedAccount.ownerId)
|
|
224
|
+
) {
|
|
225
|
+
ownerId = matchedAccount.ownerId;
|
|
226
|
+
ownerRule = "account-owner";
|
|
227
|
+
} else if (options.assignmentPolicy && !contact.ownerId) {
|
|
228
|
+
const assigned = resolveAssignment(options.assignmentPolicy, routeContext(contact, matchedAccount), policyAssignmentIndex, knownOwners);
|
|
229
|
+
policyAssignmentIndex += 1;
|
|
230
|
+
ownerId = assigned.ownerId;
|
|
231
|
+
ownerRule = assigned.rule;
|
|
232
|
+
if (ownerId) counts.policyAssigned += 1;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (ownerId && ownerId !== contact.ownerId) {
|
|
236
|
+
contactOps.push({
|
|
237
|
+
id: `op_${stableHash(`route:owner:${contact.id}:${ownerId}`)}`,
|
|
238
|
+
objectType: "contact",
|
|
239
|
+
objectId: String(contact.id),
|
|
240
|
+
operation: "set_field",
|
|
241
|
+
field: "ownerId",
|
|
242
|
+
beforeValue: contact.ownerId ?? null,
|
|
243
|
+
afterValue: ownerId,
|
|
244
|
+
reason: options.reason ?? `Route contact owner by ${ownerRule ?? "policy"}${matchedAccount ? ` after matching account ${matchedAccount.name}` : ""}.`,
|
|
245
|
+
riskLevel: "medium",
|
|
246
|
+
approvalRequired: true,
|
|
247
|
+
sourceRuleOrPolicy: "route.owner_assignment",
|
|
248
|
+
});
|
|
249
|
+
counts.ownerOperations += 1;
|
|
250
|
+
counts.ownerAssigned = counts.ownerOperations;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (contactOps.length > 1) {
|
|
254
|
+
const groupId = `grp_route_${contact.id}`;
|
|
255
|
+
for (const operation of contactOps) operation.groupId = groupId;
|
|
256
|
+
}
|
|
257
|
+
operations.push(...contactOps);
|
|
258
|
+
|
|
259
|
+
if (!matchedAccount) counts.unmatched += 1;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (operations.length > maxOperations) {
|
|
263
|
+
throw new Error(`route would create ${operations.length} operations — above the ${maxOperations}-operation safety cap. Raise --max-operations after reviewing scope.`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const plan: PatchPlan = {
|
|
267
|
+
id: `patch_plan_${stableHash(`route:${snapshot.provider}:${snapshot.generatedAt}:${operations.map((op) => op.id).join(",")}`)}`,
|
|
268
|
+
title: "Route leads: match contacts to accounts and owners",
|
|
269
|
+
createdAt: snapshot.generatedAt,
|
|
270
|
+
status: operations.length > 0 ? "needs_approval" : "draft",
|
|
271
|
+
dryRun: true,
|
|
272
|
+
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.`,
|
|
273
|
+
findings: [],
|
|
274
|
+
operations,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
return { plan, counts };
|
|
278
|
+
}
|