fullstackgtm 0.47.0 → 0.49.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 (100) hide show
  1. package/CHANGELOG.md +76 -6
  2. package/CONTRIBUTING.md +23 -4
  3. package/DATA-FLOWS.md +7 -2
  4. package/INSTALL_FOR_AGENTS.md +15 -6
  5. package/README.md +28 -9
  6. package/SECURITY.md +27 -3
  7. package/dist/audit.js +7 -0
  8. package/dist/backfill.js +2 -16
  9. package/dist/cli/audit.js +10 -7
  10. package/dist/cli/auth.js +8 -4
  11. package/dist/cli/fix.d.ts +3 -0
  12. package/dist/cli/fix.js +90 -8
  13. package/dist/cli/help.d.ts +6 -0
  14. package/dist/cli/help.js +81 -3
  15. package/dist/cli/market.js +180 -2
  16. package/dist/cli/plans.js +56 -5
  17. package/dist/cli/suggest.d.ts +2 -1
  18. package/dist/cli/suggest.js +49 -3
  19. package/dist/cli/ui.js +2 -0
  20. package/dist/cli.js +41 -16
  21. package/dist/config.d.ts +13 -1
  22. package/dist/config.js +23 -2
  23. package/dist/connectors/hubspot.d.ts +2 -1
  24. package/dist/connectors/prospectSources.js +4 -11
  25. package/dist/connectors/salesforce.d.ts +2 -1
  26. package/dist/connectors/salesforce.js +12 -5
  27. package/dist/connectors/salesforceAuth.d.ts +9 -0
  28. package/dist/connectors/salesforceAuth.js +47 -5
  29. package/dist/connectors/signalSources.js +2 -11
  30. package/dist/connectors/theirstack.d.ts +0 -19
  31. package/dist/connectors/theirstack.js +3 -10
  32. package/dist/credentials.js +36 -26
  33. package/dist/format.js +31 -5
  34. package/dist/freeEmailDomains.d.ts +1 -0
  35. package/dist/freeEmailDomains.js +14 -0
  36. package/dist/hierarchy.d.ts +29 -0
  37. package/dist/hierarchy.js +164 -0
  38. package/dist/index.d.ts +9 -4
  39. package/dist/index.js +8 -3
  40. package/dist/market.d.ts +1 -1
  41. package/dist/market.js +7 -127
  42. package/dist/marketClassify.d.ts +10 -0
  43. package/dist/marketClassify.js +52 -1
  44. package/dist/marketSourcing.js +7 -45
  45. package/dist/mcp.js +86 -130
  46. package/dist/planStore.d.ts +28 -1
  47. package/dist/planStore.js +195 -49
  48. package/dist/providerError.d.ts +21 -0
  49. package/dist/providerError.js +30 -0
  50. package/dist/publicHttp.d.ts +28 -0
  51. package/dist/publicHttp.js +143 -0
  52. package/dist/relationships.d.ts +39 -0
  53. package/dist/relationships.js +101 -0
  54. package/dist/route.d.ts +46 -0
  55. package/dist/route.js +228 -0
  56. package/dist/rules.d.ts +1 -0
  57. package/dist/rules.js +172 -12
  58. package/dist/secureFile.d.ts +15 -0
  59. package/dist/secureFile.js +164 -0
  60. package/dist/types.d.ts +16 -1
  61. package/docs/api.md +49 -5
  62. package/docs/architecture.md +18 -4
  63. package/llms.txt +7 -0
  64. package/package.json +5 -3
  65. package/skills/fullstackgtm/SKILL.md +9 -3
  66. package/src/audit.ts +7 -0
  67. package/src/backfill.ts +2 -17
  68. package/src/cli/audit.ts +10 -7
  69. package/src/cli/auth.ts +8 -4
  70. package/src/cli/fix.ts +96 -8
  71. package/src/cli/help.ts +88 -3
  72. package/src/cli/market.ts +181 -2
  73. package/src/cli/plans.ts +66 -5
  74. package/src/cli/suggest.ts +58 -4
  75. package/src/cli/ui.ts +1 -0
  76. package/src/cli.ts +39 -14
  77. package/src/config.ts +39 -1
  78. package/src/connectors/hubspot.ts +3 -1
  79. package/src/connectors/prospectSources.ts +4 -11
  80. package/src/connectors/salesforce.ts +15 -6
  81. package/src/connectors/salesforceAuth.ts +46 -6
  82. package/src/connectors/signalSources.ts +2 -12
  83. package/src/connectors/theirstack.ts +3 -10
  84. package/src/credentials.ts +47 -28
  85. package/src/format.ts +33 -5
  86. package/src/freeEmailDomains.ts +14 -0
  87. package/src/hierarchy.ts +185 -0
  88. package/src/index.ts +29 -0
  89. package/src/market.ts +7 -111
  90. package/src/marketClassify.ts +61 -1
  91. package/src/marketSourcing.ts +7 -43
  92. package/src/mcp.ts +115 -152
  93. package/src/planStore.ts +235 -57
  94. package/src/providerError.ts +37 -0
  95. package/src/publicHttp.ts +147 -0
  96. package/src/relationships.ts +115 -0
  97. package/src/route.ts +278 -0
  98. package/src/rules.ts +192 -12
  99. package/src/secureFile.ts +169 -0
  100. package/src/types.ts +18 -0
@@ -0,0 +1,101 @@
1
+ import { normalizeDomain } from "./merge.js";
2
+ function fullName(contact) {
3
+ return [contact.firstName, contact.lastName].filter(Boolean).join(" ") || contact.email || contact.id;
4
+ }
5
+ function inferRole(title) {
6
+ const t = (title ?? "").toLowerCase();
7
+ if (/\b(cfo|ceo|coo|cro|chief|founder|owner|president)\b/.test(t))
8
+ return "economic_buyer";
9
+ if (/\b(vp|vice president|head of|director|gm|general manager)\b/.test(t))
10
+ return "decision_maker";
11
+ if (/\b(cto|cio|security|it|engineering|architect|developer|technical|data)\b/.test(t))
12
+ return "technical_buyer";
13
+ if (/\b(champion|revops|sales ops|revenue operations|operations)\b/.test(t))
14
+ return "champion";
15
+ if (t)
16
+ return "influencer";
17
+ return "unknown";
18
+ }
19
+ function inferSentiment(text) {
20
+ const lower = text.toLowerCase();
21
+ if (/\b(blocked|concern|risk|no budget|not interested|detractor|negative|unhappy)\b/.test(lower))
22
+ return "negative";
23
+ if (/\b(champion|excited|interested|positive|approved|referred|next step|pilot)\b/.test(lower))
24
+ return "positive";
25
+ return "neutral";
26
+ }
27
+ function maxSentiment(a, b) {
28
+ const rank = { unknown: 0, neutral: 1, positive: 2, negative: 3 };
29
+ return rank[b] > rank[a] ? b : a;
30
+ }
31
+ function findAccount(snapshot, selector) {
32
+ if (selector.accountId)
33
+ return snapshot.accounts.find((account) => String(account.id) === selector.accountId) ?? null;
34
+ if (selector.domain) {
35
+ const domain = normalizeDomain(selector.domain);
36
+ return snapshot.accounts.find((account) => normalizeDomain(account.domain) === domain) ?? null;
37
+ }
38
+ return null;
39
+ }
40
+ export function buildRelationshipMap(snapshot, selector) {
41
+ const account = findAccount(snapshot, selector);
42
+ if (!account)
43
+ throw new Error("relationships: account not found (use --account-id or --domain)");
44
+ const contacts = snapshot.contacts.filter((contact) => contact.accountId === account.id);
45
+ const contactIds = new Set(contacts.map((contact) => contact.id));
46
+ const activities = snapshot.activities.filter((activity) => activity.accountId === account.id || (activity.contactId ? contactIds.has(activity.contactId) : false));
47
+ const evidenceByContact = new Map();
48
+ const sentimentByContact = new Map();
49
+ for (const activity of activities) {
50
+ if (!activity.contactId || !contactIds.has(activity.contactId))
51
+ continue;
52
+ const text = [activity.subject, activity.type, activity.occurredAt].filter(Boolean).join(" — ");
53
+ evidenceByContact.set(activity.contactId, [...(evidenceByContact.get(activity.contactId) ?? []), text]);
54
+ sentimentByContact.set(activity.contactId, maxSentiment(sentimentByContact.get(activity.contactId) ?? "unknown", inferSentiment(text)));
55
+ }
56
+ const stakeholders = contacts.map((contact) => ({
57
+ contactId: String(contact.id),
58
+ name: fullName(contact),
59
+ ...(contact.title ? { title: contact.title } : {}),
60
+ ...(contact.email ? { email: contact.email } : {}),
61
+ role: inferRole(contact.title),
62
+ sentiment: sentimentByContact.get(contact.id) ?? "unknown",
63
+ ...(contact.lastActivityAt ? { lastActivityAt: contact.lastActivityAt } : {}),
64
+ evidence: evidenceByContact.get(contact.id) ?? [],
65
+ })).sort((a, b) => a.role.localeCompare(b.role) || a.name.localeCompare(b.name));
66
+ const openDeals = snapshot.deals.filter((deal) => deal.accountId === account.id && !deal.isClosed).map((deal) => ({
67
+ dealId: String(deal.id),
68
+ name: deal.name,
69
+ ...(deal.stage ? { stage: deal.stage } : {}),
70
+ ...(deal.amount !== undefined ? { amount: deal.amount } : {}),
71
+ ...(deal.ownerId ? { ownerId: deal.ownerId } : {}),
72
+ }));
73
+ const roles = new Set(stakeholders.map((s) => s.role));
74
+ const gaps = [];
75
+ if (!roles.has("economic_buyer"))
76
+ gaps.push("No obvious economic buyer found from titles.");
77
+ if (!roles.has("decision_maker"))
78
+ gaps.push("No obvious decision maker found from titles.");
79
+ if (stakeholders.every((s) => s.sentiment === "unknown"))
80
+ gaps.push("No stakeholder activity evidence attached to this account.");
81
+ 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 } };
82
+ }
83
+ export function relationshipMapToMarkdown(map) {
84
+ 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"];
85
+ for (const s of map.stakeholders) {
86
+ lines.push(`- ${s.name} — ${s.role}, ${s.sentiment}${s.title ? ` (${s.title})` : ""}`);
87
+ for (const evidence of s.evidence.slice(0, 3))
88
+ lines.push(` - evidence: ${evidence}`);
89
+ }
90
+ if (map.openDeals.length) {
91
+ lines.push("", "## Open deals");
92
+ for (const deal of map.openDeals)
93
+ lines.push(`- ${deal.name} (${deal.dealId})${deal.stage ? ` — ${deal.stage}` : ""}${deal.amount !== undefined ? ` — ${deal.amount}` : ""}`);
94
+ }
95
+ if (map.gaps.length) {
96
+ lines.push("", "## Gaps");
97
+ for (const gap of map.gaps)
98
+ lines.push(`- ${gap}`);
99
+ }
100
+ return lines.join("\n");
101
+ }
@@ -0,0 +1,46 @@
1
+ import { type AssignmentPolicy } from "./assign.ts";
2
+ import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
3
+ /**
4
+ * Lead-to-account matching and routing recipes.
5
+ *
6
+ * This is the first packaged Traction-Complete-style workflow in the OSS CLI:
7
+ * take ownerless/unmatched leads, deterministically match them to accounts, and
8
+ * emit normal approval-gated patch operations. No writes happen here.
9
+ */
10
+ export type LeadRouteOptions = {
11
+ /** Match contact email domains to account domains. Default true. */
12
+ matchByDomain?: boolean;
13
+ /** Also try raw company/name fields against account names when domain misses. */
14
+ matchByCompanyName?: boolean;
15
+ /** When an ownerless contact matches an owned account, set contact ownerId to the account owner. Default true. */
16
+ inheritAccountOwner?: boolean;
17
+ /** Reassign contacts that already have an owner when the matched account owner differs. Default false. */
18
+ reassignExistingOwner?: boolean;
19
+ /** Optional fallback policy for contacts that still do not have an owner. */
20
+ assignmentPolicy?: AssignmentPolicy;
21
+ /** Refuse to build larger plans unless explicitly raised. Default 500. */
22
+ maxOperations?: number;
23
+ reason?: string;
24
+ };
25
+ export type LeadRouteCounts = {
26
+ contactsScanned: number;
27
+ /** Contacts that ended with an unambiguous account context (existing link, domain match, or company-name match). */
28
+ matchedContacts: number;
29
+ matchedExistingAccount: number;
30
+ matchedByDomain: number;
31
+ matchedByCompanyName: number;
32
+ ambiguousAccountMatches: number;
33
+ skippedFreeEmail: number;
34
+ linkOperations: number;
35
+ /** Owner set_field operations proposed (account-owner inheritance + policy fallback). */
36
+ ownerOperations: number;
37
+ /** Alias for ownerOperations, named for reports that talk about owner-assigned leads. */
38
+ ownerAssigned: number;
39
+ policyAssigned: number;
40
+ unmatched: number;
41
+ };
42
+ export type LeadRouteResult = {
43
+ plan: PatchPlan;
44
+ counts: LeadRouteCounts;
45
+ };
46
+ export declare function buildLeadRoutePlan(snapshot: CanonicalGtmSnapshot, options?: LeadRouteOptions): LeadRouteResult;
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 { findings, operations };
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 { findings, operations };
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 { findings, operations };
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 { findings, operations };
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 { findings, operations };
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 { findings, operations };
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 { findings, operations };
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 { findings, operations };
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 { findings, operations };
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 { findings, operations };
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 { findings, operations };
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 { findings, operations: [] };
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,
@@ -0,0 +1,15 @@
1
+ export declare class UnsafeManagedPathError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ export declare function assertNoSymlinkComponents(path: string): void;
5
+ /** Read a managed file through a no-follow descriptor and require a regular file. */
6
+ export declare function readSecureRegularFile(path: string, options?: {
7
+ tightenMode?: number;
8
+ onModeTightened?: (previousMode: number) => void;
9
+ }): string;
10
+ /**
11
+ * Atomically replace a private file without ever opening the destination.
12
+ * Existing destination and parent symlinks are rejected. The temporary file
13
+ * is created exclusively beside the destination so rename remains atomic.
14
+ */
15
+ export declare function writeSecureFileAtomic(path: string, contents: string | Uint8Array): void;