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.
Files changed (52) hide show
  1. package/CHANGELOG.md +38 -6
  2. package/INSTALL_FOR_AGENTS.md +3 -3
  3. package/README.md +16 -8
  4. package/dist/audit.js +7 -0
  5. package/dist/backfill.js +2 -16
  6. package/dist/cli/fix.d.ts +3 -0
  7. package/dist/cli/fix.js +70 -1
  8. package/dist/cli/help.d.ts +6 -0
  9. package/dist/cli/help.js +76 -2
  10. package/dist/cli/suggest.d.ts +2 -1
  11. package/dist/cli/suggest.js +49 -3
  12. package/dist/cli.js +40 -15
  13. package/dist/connectors/hubspot.d.ts +2 -1
  14. package/dist/connectors/salesforce.d.ts +2 -1
  15. package/dist/connectors/signalSources.js +2 -11
  16. package/dist/format.js +31 -5
  17. package/dist/freeEmailDomains.d.ts +1 -0
  18. package/dist/freeEmailDomains.js +14 -0
  19. package/dist/hierarchy.d.ts +29 -0
  20. package/dist/hierarchy.js +164 -0
  21. package/dist/index.d.ts +6 -2
  22. package/dist/index.js +5 -1
  23. package/dist/mcp.js +19 -15
  24. package/dist/relationships.d.ts +39 -0
  25. package/dist/relationships.js +101 -0
  26. package/dist/route.d.ts +46 -0
  27. package/dist/route.js +228 -0
  28. package/dist/rules.d.ts +1 -0
  29. package/dist/rules.js +172 -12
  30. package/dist/types.d.ts +15 -0
  31. package/docs/api.md +20 -3
  32. package/docs/architecture.md +5 -2
  33. package/package.json +1 -1
  34. package/skills/fullstackgtm/SKILL.md +2 -2
  35. package/src/audit.ts +7 -0
  36. package/src/backfill.ts +2 -17
  37. package/src/cli/fix.ts +68 -1
  38. package/src/cli/help.ts +83 -2
  39. package/src/cli/suggest.ts +58 -4
  40. package/src/cli.ts +38 -13
  41. package/src/connectors/hubspot.ts +3 -1
  42. package/src/connectors/salesforce.ts +3 -1
  43. package/src/connectors/signalSources.ts +2 -12
  44. package/src/format.ts +33 -5
  45. package/src/freeEmailDomains.ts +14 -0
  46. package/src/hierarchy.ts +185 -0
  47. package/src/index.ts +25 -0
  48. package/src/mcp.ts +22 -16
  49. package/src/relationships.ts +115 -0
  50. package/src/route.ts +278 -0
  51. package/src/rules.ts +192 -12
  52. 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?: Record<string, unknown> };
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
+ }