fullstackgtm 0.10.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 (77) hide show
  1. package/CHANGELOG.md +381 -0
  2. package/INSTALL_FOR_AGENTS.md +87 -0
  3. package/LICENSE +202 -0
  4. package/README.md +230 -0
  5. package/dist/audit.d.ts +7 -0
  6. package/dist/audit.js +202 -0
  7. package/dist/bin.d.ts +2 -0
  8. package/dist/bin.js +6 -0
  9. package/dist/cli.d.ts +38 -0
  10. package/dist/cli.js +915 -0
  11. package/dist/config.d.ts +36 -0
  12. package/dist/config.js +85 -0
  13. package/dist/connector.d.ts +30 -0
  14. package/dist/connector.js +94 -0
  15. package/dist/connectors/hubspot.d.ts +20 -0
  16. package/dist/connectors/hubspot.js +409 -0
  17. package/dist/connectors/hubspotAuth.d.ts +42 -0
  18. package/dist/connectors/hubspotAuth.js +189 -0
  19. package/dist/connectors/salesforce.d.ts +26 -0
  20. package/dist/connectors/salesforce.js +318 -0
  21. package/dist/connectors/salesforceAuth.d.ts +44 -0
  22. package/dist/connectors/salesforceAuth.js +120 -0
  23. package/dist/connectors/stripe.d.ts +27 -0
  24. package/dist/connectors/stripe.js +176 -0
  25. package/dist/credentials.d.ts +75 -0
  26. package/dist/credentials.js +197 -0
  27. package/dist/demo.d.ts +20 -0
  28. package/dist/demo.js +169 -0
  29. package/dist/diff.d.ts +46 -0
  30. package/dist/diff.js +107 -0
  31. package/dist/format.d.ts +3 -0
  32. package/dist/format.js +109 -0
  33. package/dist/index.d.ts +18 -0
  34. package/dist/index.js +17 -0
  35. package/dist/mappings.d.ts +8 -0
  36. package/dist/mappings.js +123 -0
  37. package/dist/mcp-bin.d.ts +2 -0
  38. package/dist/mcp-bin.js +33 -0
  39. package/dist/mcp.d.ts +1 -0
  40. package/dist/mcp.js +140 -0
  41. package/dist/merge.d.ts +48 -0
  42. package/dist/merge.js +145 -0
  43. package/dist/planStore.d.ts +31 -0
  44. package/dist/planStore.js +116 -0
  45. package/dist/rules.d.ts +24 -0
  46. package/dist/rules.js +512 -0
  47. package/dist/sampleData.d.ts +2 -0
  48. package/dist/sampleData.js +115 -0
  49. package/dist/types.d.ts +294 -0
  50. package/dist/types.js +8 -0
  51. package/docs/api.md +72 -0
  52. package/docs/roadmap-to-1.0.md +121 -0
  53. package/llms.txt +25 -0
  54. package/package.json +76 -0
  55. package/src/audit.ts +242 -0
  56. package/src/bin.ts +7 -0
  57. package/src/cli.ts +1042 -0
  58. package/src/config.ts +113 -0
  59. package/src/connector.ts +140 -0
  60. package/src/connectors/hubspot.ts +528 -0
  61. package/src/connectors/hubspotAuth.ts +246 -0
  62. package/src/connectors/salesforce.ts +420 -0
  63. package/src/connectors/salesforceAuth.ts +167 -0
  64. package/src/connectors/stripe.ts +215 -0
  65. package/src/credentials.ts +282 -0
  66. package/src/demo.ts +200 -0
  67. package/src/diff.ts +158 -0
  68. package/src/format.ts +162 -0
  69. package/src/index.ts +129 -0
  70. package/src/mappings.ts +157 -0
  71. package/src/mcp-bin.ts +32 -0
  72. package/src/mcp.ts +185 -0
  73. package/src/merge.ts +235 -0
  74. package/src/planStore.ts +155 -0
  75. package/src/rules.ts +539 -0
  76. package/src/sampleData.ts +117 -0
  77. package/src/types.ts +372 -0
package/dist/rules.js ADDED
@@ -0,0 +1,512 @@
1
+ /**
2
+ * Placeholder used as `afterValue` when the right value is a human decision
3
+ * (e.g. which owner to assign). Apply orchestration refuses to write these
4
+ * unless an explicit override value is supplied at approval time.
5
+ */
6
+ export const REQUIRES_HUMAN_PREFIX = "requires_human_";
7
+ export function requiresHumanInput(value) {
8
+ return typeof value === "string" && value.startsWith(REQUIRES_HUMAN_PREFIX);
9
+ }
10
+ export function auditFindingId(ruleId, objectId) {
11
+ return `finding_${stableHash(`${ruleId}:${objectId}`)}`;
12
+ }
13
+ export function patchOperationId(ruleId, objectId) {
14
+ return `op_${stableHash(`${ruleId}:${objectId}`)}`;
15
+ }
16
+ export function stableHash(value) {
17
+ let hash = 0;
18
+ for (let index = 0; index < value.length; index += 1) {
19
+ hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
20
+ }
21
+ return hash.toString(36);
22
+ }
23
+ export function buildSnapshotIndex(snapshot) {
24
+ const contactsByAccountId = new Map();
25
+ const dealsByAccountId = new Map();
26
+ const activitiesByDealId = new Map();
27
+ for (const contact of snapshot.contacts) {
28
+ if (!contact.accountId)
29
+ continue;
30
+ const existing = contactsByAccountId.get(contact.accountId) ?? [];
31
+ existing.push(contact);
32
+ contactsByAccountId.set(contact.accountId, existing);
33
+ }
34
+ for (const deal of snapshot.deals) {
35
+ if (!deal.accountId)
36
+ continue;
37
+ const existing = dealsByAccountId.get(deal.accountId) ?? [];
38
+ existing.push(deal);
39
+ dealsByAccountId.set(deal.accountId, existing);
40
+ }
41
+ for (const activity of snapshot.activities) {
42
+ if (!activity.dealId)
43
+ continue;
44
+ const existing = activitiesByDealId.get(activity.dealId) ?? [];
45
+ existing.push(activity);
46
+ activitiesByDealId.set(activity.dealId, existing);
47
+ }
48
+ return {
49
+ usersById: new Map(snapshot.users.map((user) => [user.id, user])),
50
+ accountsById: new Map(snapshot.accounts.map((account) => [account.id, account])),
51
+ contactsByAccountId,
52
+ dealsByAccountId,
53
+ activitiesByDealId,
54
+ };
55
+ }
56
+ function isOpen(deal) {
57
+ return deal.isClosed !== true && deal.isWon !== true;
58
+ }
59
+ function dealFinding(deal, ruleId, title) {
60
+ return {
61
+ id: auditFindingId(ruleId, deal.id),
62
+ objectType: "deal",
63
+ objectId: deal.id,
64
+ ruleId,
65
+ title,
66
+ severity: "warning",
67
+ summary: `${deal.name} violates ${ruleId.replace(/-/g, " ")} policy.`,
68
+ recommendation: "Review the proposed patch operation and approve an explicit CRM update.",
69
+ };
70
+ }
71
+ function staleDays(deal, today) {
72
+ const reference = deal.lastActivityAt ?? deal.lastSyncAt ?? deal.closeDate;
73
+ if (!reference)
74
+ return 0;
75
+ return Math.max(0, daysBetween(reference, today));
76
+ }
77
+ function daysBetween(start, end) {
78
+ const startMs = Date.parse(start);
79
+ const endMs = Date.parse(end);
80
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs))
81
+ return 0;
82
+ return Math.floor((endMs - startMs) / 86_400_000);
83
+ }
84
+ function compareDate(left, right) {
85
+ return Date.parse(left) - Date.parse(right);
86
+ }
87
+ function riskForStaleness(days) {
88
+ if (days >= 90)
89
+ return "high";
90
+ if (days >= 45)
91
+ return "medium";
92
+ return "low";
93
+ }
94
+ export const orphanAccountRule = {
95
+ id: "orphan-account",
96
+ title: "Account has no contacts or deals",
97
+ description: "Flags accounts not connected to any contact or opportunity so someone owns the next action.",
98
+ category: "hygiene",
99
+ evaluate: ({ snapshot, index }) => {
100
+ const findings = [];
101
+ const operations = [];
102
+ for (const account of snapshot.accounts) {
103
+ const contacts = index.contactsByAccountId.get(account.id) ?? [];
104
+ const deals = index.dealsByAccountId.get(account.id) ?? [];
105
+ if (contacts.length > 0 || deals.length > 0)
106
+ continue;
107
+ findings.push({
108
+ id: auditFindingId("orphan-account", account.id),
109
+ objectType: "account",
110
+ objectId: account.id,
111
+ ruleId: "orphan-account",
112
+ title: "Account has no contacts or deals",
113
+ severity: "warning",
114
+ summary: `${account.name} is not connected to any contact or open opportunity.`,
115
+ recommendation: "Review whether the account should be enriched, assigned for research, or archived.",
116
+ });
117
+ operations.push({
118
+ id: patchOperationId("orphan-account", account.id),
119
+ objectType: "account",
120
+ objectId: account.id,
121
+ operation: "create_task",
122
+ field: "follow_up_task",
123
+ beforeValue: null,
124
+ afterValue: "Research account fit and decide whether to archive or enrich",
125
+ reason: "Orphan accounts add CRM noise unless someone owns the next action.",
126
+ riskLevel: "low",
127
+ approvalRequired: true,
128
+ });
129
+ }
130
+ return { findings, operations };
131
+ },
132
+ };
133
+ export const missingDealOwnerRule = {
134
+ id: "missing-deal-owner",
135
+ title: "Deal has no valid owner",
136
+ description: "Flags deals whose owner is missing or not a known user.",
137
+ category: "hygiene",
138
+ evaluate: ({ snapshot, policy, index }) => {
139
+ if (!policy.requireDealOwner)
140
+ return { findings: [], operations: [] };
141
+ const findings = [];
142
+ const operations = [];
143
+ for (const deal of snapshot.deals) {
144
+ if (deal.ownerId && index.usersById.has(deal.ownerId))
145
+ continue;
146
+ findings.push(dealFinding(deal, "missing-deal-owner", "Deal has no valid owner"));
147
+ operations.push({
148
+ id: patchOperationId("missing-deal-owner", deal.id),
149
+ objectType: "deal",
150
+ objectId: deal.id,
151
+ operation: "set_field",
152
+ field: "ownerId",
153
+ beforeValue: deal.ownerId ?? null,
154
+ afterValue: "requires_human_owner_selection",
155
+ reason: "Every open opportunity needs a human owner before the agent can suggest routing.",
156
+ riskLevel: "medium",
157
+ approvalRequired: true,
158
+ });
159
+ }
160
+ return { findings, operations };
161
+ },
162
+ };
163
+ export const missingDealAccountRule = {
164
+ id: "missing-deal-account",
165
+ title: "Deal is not linked to an account",
166
+ description: "Flags deals that are not associated with a known account.",
167
+ category: "hygiene",
168
+ evaluate: ({ snapshot, policy, index }) => {
169
+ if (!policy.requireAccountForDeal)
170
+ return { findings: [], operations: [] };
171
+ const findings = [];
172
+ const operations = [];
173
+ for (const deal of snapshot.deals) {
174
+ if (deal.accountId && index.accountsById.has(deal.accountId))
175
+ continue;
176
+ findings.push(dealFinding(deal, "missing-deal-account", "Deal is not linked to an account"));
177
+ operations.push({
178
+ id: patchOperationId("missing-deal-account", deal.id),
179
+ objectType: "deal",
180
+ objectId: deal.id,
181
+ operation: "link_record",
182
+ field: "accountId",
183
+ beforeValue: deal.accountId ?? null,
184
+ afterValue: "requires_human_account_selection",
185
+ reason: "Opportunity reporting, ABM, attribution, and forecasting need account context.",
186
+ riskLevel: "medium",
187
+ approvalRequired: true,
188
+ });
189
+ }
190
+ return { findings, operations };
191
+ },
192
+ };
193
+ export const pastCloseDateRule = {
194
+ id: "past-close-date",
195
+ title: "Open deal has a past close date",
196
+ description: "Flags open deals whose close date is already behind today.",
197
+ category: "hygiene",
198
+ evaluate: ({ snapshot, policy }) => {
199
+ const findings = [];
200
+ const operations = [];
201
+ for (const deal of snapshot.deals) {
202
+ if (!isOpen(deal) || !deal.closeDate)
203
+ continue;
204
+ if (compareDate(deal.closeDate, policy.today) >= 0)
205
+ continue;
206
+ findings.push(dealFinding(deal, "past-close-date", "Open deal has a past close date"));
207
+ operations.push({
208
+ id: patchOperationId("past-close-date", deal.id),
209
+ objectType: "deal",
210
+ objectId: deal.id,
211
+ operation: "set_field",
212
+ field: "closeDate",
213
+ beforeValue: deal.closeDate,
214
+ afterValue: "requires_human_close_date_selection",
215
+ reason: "Past close dates make forecast and pipeline aging unreliable.",
216
+ riskLevel: "medium",
217
+ approvalRequired: true,
218
+ });
219
+ }
220
+ return { findings, operations };
221
+ },
222
+ };
223
+ export const staleDealRule = {
224
+ id: "stale-deal",
225
+ title: "Deal has stale activity",
226
+ description: "Flags open deals with no recorded activity for longer than the policy's stale window.",
227
+ category: "hygiene",
228
+ evaluate: ({ snapshot, policy }) => {
229
+ const findings = [];
230
+ const operations = [];
231
+ for (const deal of snapshot.deals) {
232
+ if (!isOpen(deal))
233
+ continue;
234
+ const days = staleDays(deal, policy.today);
235
+ if (days <= policy.staleDealDays)
236
+ continue;
237
+ findings.push({
238
+ id: auditFindingId("stale-deal", deal.id),
239
+ objectType: "deal",
240
+ objectId: deal.id,
241
+ ruleId: "stale-deal",
242
+ title: "Deal has stale activity",
243
+ severity: "warning",
244
+ summary: `${deal.name} has had no recorded activity for ${days} days.`,
245
+ recommendation: "Ask the owner to confirm next step, close lost, or update the forecast category.",
246
+ });
247
+ operations.push({
248
+ id: patchOperationId("stale-deal", deal.id),
249
+ objectType: "deal",
250
+ objectId: deal.id,
251
+ operation: "create_task",
252
+ field: "next_step_task",
253
+ beforeValue: null,
254
+ afterValue: "Confirm next step or close out stale opportunity",
255
+ reason: "Stale open pipeline inflates forecast coverage and hides execution risk.",
256
+ riskLevel: riskForStaleness(days),
257
+ approvalRequired: true,
258
+ });
259
+ }
260
+ return { findings, operations };
261
+ },
262
+ };
263
+ export const missingDealAmountRule = {
264
+ id: "missing-deal-amount",
265
+ title: "Open deal has no amount",
266
+ description: "Flags open deals without an amount; they make forecast coverage meaningless.",
267
+ category: "forecast",
268
+ evaluate: ({ snapshot, policy }) => {
269
+ if (policy.requireDealAmount === false)
270
+ return { findings: [], operations: [] };
271
+ const findings = [];
272
+ const operations = [];
273
+ for (const deal of snapshot.deals) {
274
+ if (!isOpen(deal))
275
+ continue;
276
+ if (deal.amount !== undefined && deal.amount !== 0)
277
+ continue;
278
+ findings.push(dealFinding(deal, "missing-deal-amount", "Open deal has no amount"));
279
+ operations.push({
280
+ id: patchOperationId("missing-deal-amount", deal.id),
281
+ objectType: "deal",
282
+ objectId: deal.id,
283
+ operation: "set_field",
284
+ field: "amount",
285
+ beforeValue: deal.amount ?? null,
286
+ afterValue: "requires_human_amount_selection",
287
+ reason: "Amountless open pipeline understates coverage and breaks weighted forecasts.",
288
+ riskLevel: "medium",
289
+ approvalRequired: true,
290
+ });
291
+ }
292
+ return { findings, operations };
293
+ },
294
+ };
295
+ function duplicateGroups(items, keyOf) {
296
+ const groups = new Map();
297
+ for (const item of items) {
298
+ const key = keyOf(item)?.trim().toLowerCase();
299
+ if (!key)
300
+ continue;
301
+ const existing = groups.get(key) ?? [];
302
+ existing.push(item);
303
+ groups.set(key, existing);
304
+ }
305
+ for (const [key, members] of Array.from(groups.entries())) {
306
+ if (members.length < 2)
307
+ groups.delete(key);
308
+ }
309
+ return groups;
310
+ }
311
+ export const duplicateAccountDomainRule = {
312
+ id: "duplicate-account-domain",
313
+ title: "Accounts share the same domain",
314
+ description: "Flags accounts with identical domains — usually duplicates splitting activity, deals, and attribution.",
315
+ category: "data-quality",
316
+ evaluate: ({ snapshot }) => {
317
+ const findings = [];
318
+ const operations = [];
319
+ for (const [domain, accounts] of duplicateGroups(snapshot.accounts, (account) => account.domain)) {
320
+ const anchor = accounts[0];
321
+ findings.push({
322
+ id: auditFindingId("duplicate-account-domain", anchor.id),
323
+ objectType: "account",
324
+ objectId: anchor.id,
325
+ ruleId: "duplicate-account-domain",
326
+ title: "Accounts share the same domain",
327
+ severity: "warning",
328
+ summary: `${accounts.length} accounts share ${domain}: ${accounts.map((account) => account.name).join(", ")}.`,
329
+ recommendation: "Review the group and merge duplicates so activity and deals roll up once.",
330
+ });
331
+ operations.push({
332
+ id: patchOperationId("duplicate-account-domain", anchor.id),
333
+ objectType: "account",
334
+ objectId: anchor.id,
335
+ operation: "create_task",
336
+ field: "merge_review_task",
337
+ beforeValue: null,
338
+ afterValue: `Review ${accounts.length} accounts sharing ${domain} and merge duplicates`,
339
+ reason: "Duplicate accounts split pipeline, attribution, and ownership.",
340
+ riskLevel: "medium",
341
+ approvalRequired: true,
342
+ });
343
+ }
344
+ return { findings, operations };
345
+ },
346
+ };
347
+ export const duplicateContactEmailRule = {
348
+ id: "duplicate-contact-email",
349
+ title: "Contacts share the same email",
350
+ description: "Flags contacts with identical emails — duplicates that fragment engagement history and routing.",
351
+ category: "data-quality",
352
+ evaluate: ({ snapshot }) => {
353
+ const findings = [];
354
+ const operations = [];
355
+ for (const [email, contacts] of duplicateGroups(snapshot.contacts, (contact) => contact.email)) {
356
+ const anchor = contacts[0];
357
+ findings.push({
358
+ id: auditFindingId("duplicate-contact-email", anchor.id),
359
+ objectType: "contact",
360
+ objectId: anchor.id,
361
+ ruleId: "duplicate-contact-email",
362
+ title: "Contacts share the same email",
363
+ severity: "warning",
364
+ summary: `${contacts.length} contacts share ${email}.`,
365
+ recommendation: "Merge the duplicates so engagement history and routing stay coherent.",
366
+ });
367
+ operations.push({
368
+ id: patchOperationId("duplicate-contact-email", anchor.id),
369
+ objectType: "contact",
370
+ objectId: anchor.id,
371
+ operation: "create_task",
372
+ field: "merge_review_task",
373
+ beforeValue: null,
374
+ afterValue: `Review ${contacts.length} contacts sharing ${email} and merge duplicates`,
375
+ reason: "Duplicate contacts fragment engagement history and double-route outreach.",
376
+ riskLevel: "low",
377
+ approvalRequired: true,
378
+ });
379
+ }
380
+ return { findings, operations };
381
+ },
382
+ };
383
+ export const activeDealAccountWithoutContactsRule = {
384
+ id: "active-deal-account-without-contacts",
385
+ title: "Account with open pipeline has no contacts",
386
+ description: "Flags accounts carrying open deals without a single contact — pipeline with nobody to talk to.",
387
+ category: "coverage",
388
+ evaluate: ({ index }) => {
389
+ const findings = [];
390
+ const operations = [];
391
+ for (const [accountId, deals] of index.dealsByAccountId) {
392
+ if (!deals.some(isOpen))
393
+ continue;
394
+ if ((index.contactsByAccountId.get(accountId) ?? []).length > 0)
395
+ continue;
396
+ const account = index.accountsById.get(accountId);
397
+ if (!account)
398
+ continue;
399
+ findings.push({
400
+ id: auditFindingId("active-deal-account-without-contacts", account.id),
401
+ objectType: "account",
402
+ objectId: account.id,
403
+ ruleId: "active-deal-account-without-contacts",
404
+ title: "Account with open pipeline has no contacts",
405
+ severity: "warning",
406
+ summary: `${account.name} has open deals but no contacts on record.`,
407
+ recommendation: "Add the champion and buying-committee contacts before the deal advances.",
408
+ });
409
+ operations.push({
410
+ id: patchOperationId("active-deal-account-without-contacts", account.id),
411
+ objectType: "account",
412
+ objectId: account.id,
413
+ operation: "create_task",
414
+ field: "add_contacts_task",
415
+ beforeValue: null,
416
+ afterValue: "Add champion and buying-committee contacts for the open opportunities",
417
+ reason: "Open pipeline without contacts cannot be advanced, routed, or marketed to.",
418
+ riskLevel: "low",
419
+ approvalRequired: true,
420
+ });
421
+ }
422
+ return { findings, operations };
423
+ },
424
+ };
425
+ export const closingSoonInactiveRule = {
426
+ id: "closing-soon-inactive",
427
+ title: "Deal closing soon with no recent activity",
428
+ description: "Flags open deals inside the closing-soon window whose last activity is older than the idle threshold.",
429
+ category: "forecast",
430
+ evaluate: ({ snapshot, policy }) => {
431
+ const windowDays = policy.closingSoonDays ?? 14;
432
+ const idleDays = policy.closingSoonIdleDays ?? 7;
433
+ const findings = [];
434
+ const operations = [];
435
+ for (const deal of snapshot.deals) {
436
+ if (!isOpen(deal) || !deal.closeDate)
437
+ continue;
438
+ const daysToClose = daysBetween(policy.today, deal.closeDate);
439
+ if (daysToClose < 0 || daysToClose > windowDays)
440
+ continue;
441
+ const idle = staleDays(deal, policy.today);
442
+ if (idle <= idleDays)
443
+ continue;
444
+ findings.push({
445
+ id: auditFindingId("closing-soon-inactive", deal.id),
446
+ objectType: "deal",
447
+ objectId: deal.id,
448
+ ruleId: "closing-soon-inactive",
449
+ title: "Deal closing soon with no recent activity",
450
+ severity: "critical",
451
+ summary: `${deal.name} closes in ${daysToClose} days but has had no activity for ${idle} days.`,
452
+ recommendation: "Confirm the close plan with the owner today, or move the close date and forecast category.",
453
+ });
454
+ operations.push({
455
+ id: patchOperationId("closing-soon-inactive", deal.id),
456
+ objectType: "deal",
457
+ objectId: deal.id,
458
+ operation: "create_task",
459
+ field: "close_plan_task",
460
+ beforeValue: null,
461
+ afterValue: "Confirm close plan or update close date and forecast category",
462
+ reason: "Deals forecast to close imminently without engagement are silent slip risk.",
463
+ riskLevel: "high",
464
+ approvalRequired: true,
465
+ });
466
+ }
467
+ return { findings, operations };
468
+ },
469
+ };
470
+ export const accountSingleSourceRule = {
471
+ id: "account-single-source",
472
+ title: "Account exists in only one connected system",
473
+ description: "On merged multi-system snapshots, flags accounts known to just one source — the seams where GTM systems disagree.",
474
+ category: "cross-system",
475
+ evaluate: ({ snapshot }) => {
476
+ const providersSeen = new Set(snapshot.accounts.flatMap((account) => (account.identities ?? []).map((identity) => String(identity.provider))));
477
+ // Only meaningful when the snapshot actually spans systems.
478
+ if (providersSeen.size < 2)
479
+ return { findings: [], operations: [] };
480
+ const findings = [];
481
+ for (const account of snapshot.accounts) {
482
+ const sources = new Set((account.identities ?? []).map((identity) => String(identity.provider)));
483
+ if (sources.size !== 1)
484
+ continue;
485
+ const source = Array.from(sources)[0];
486
+ findings.push({
487
+ id: auditFindingId("account-single-source", account.id),
488
+ objectType: "account",
489
+ objectId: account.id,
490
+ ruleId: "account-single-source",
491
+ title: "Account exists in only one connected system",
492
+ severity: "info",
493
+ summary: `${account.name} exists only in ${source} (${providersSeen.size} systems connected).`,
494
+ recommendation: "Check whether the account is missing from the other systems or matched under a different domain/name.",
495
+ });
496
+ }
497
+ return { findings, operations: [] };
498
+ },
499
+ };
500
+ export const builtinAuditRules = [
501
+ orphanAccountRule,
502
+ missingDealOwnerRule,
503
+ missingDealAccountRule,
504
+ pastCloseDateRule,
505
+ staleDealRule,
506
+ missingDealAmountRule,
507
+ duplicateAccountDomainRule,
508
+ duplicateContactEmailRule,
509
+ activeDealAccountWithoutContactsRule,
510
+ closingSoonInactiveRule,
511
+ accountSingleSourceRule,
512
+ ];
@@ -0,0 +1,2 @@
1
+ import type { CanonicalGtmSnapshot } from "./types.ts";
2
+ export declare const sampleSnapshot: CanonicalGtmSnapshot;
@@ -0,0 +1,115 @@
1
+ export const sampleSnapshot = {
2
+ generatedAt: "2026-05-03T12:00:00.000Z",
3
+ provider: "mock",
4
+ users: [
5
+ {
6
+ id: "user_ada",
7
+ provider: "mock",
8
+ crmId: "005-ada",
9
+ name: "Ada Lovelace",
10
+ email: "ada@example.com",
11
+ title: "Account Executive",
12
+ active: true,
13
+ },
14
+ ],
15
+ accounts: [
16
+ {
17
+ id: "acct_acme",
18
+ provider: "mock",
19
+ crmId: "001-acme",
20
+ name: "Acme Corp",
21
+ domain: "acme.example",
22
+ ownerId: "user_ada",
23
+ lastActivityAt: "2026-04-28",
24
+ lastSyncAt: "2026-05-03",
25
+ },
26
+ {
27
+ id: "acct_orphan",
28
+ provider: "mock",
29
+ crmId: "001-orphan",
30
+ name: "Orphan Industries",
31
+ domain: "orphan.example",
32
+ lastSyncAt: "2026-05-03",
33
+ },
34
+ ],
35
+ contacts: [
36
+ {
37
+ id: "contact_acme_cfo",
38
+ provider: "mock",
39
+ crmId: "003-acme-cfo",
40
+ accountId: "acct_acme",
41
+ firstName: "Grace",
42
+ lastName: "Hopper",
43
+ email: "grace@acme.example",
44
+ title: "CFO",
45
+ ownerId: "user_ada",
46
+ lastActivityAt: "2026-04-28",
47
+ lastSyncAt: "2026-05-03",
48
+ },
49
+ ],
50
+ deals: [
51
+ {
52
+ id: "deal_missing_owner",
53
+ provider: "mock",
54
+ crmId: "006-missing-owner",
55
+ accountId: "acct_acme",
56
+ name: "Acme Expansion",
57
+ amount: 12500000,
58
+ currency: "USD",
59
+ stage: "Proposal",
60
+ closeDate: "2026-06-30",
61
+ forecastCategory: "best_case",
62
+ probability: 0.6,
63
+ isClosed: false,
64
+ lastActivityAt: "2026-04-15",
65
+ lastSyncAt: "2026-05-03",
66
+ },
67
+ {
68
+ id: "deal_past_close",
69
+ provider: "mock",
70
+ crmId: "006-past-close",
71
+ accountId: "acct_acme",
72
+ ownerId: "user_ada",
73
+ name: "Acme New Division",
74
+ amount: 22000000,
75
+ currency: "USD",
76
+ stage: "Discovery",
77
+ closeDate: "2026-04-01",
78
+ forecastCategory: "pipeline",
79
+ probability: 0.3,
80
+ isClosed: false,
81
+ lastActivityAt: "2026-04-29",
82
+ lastSyncAt: "2026-05-03",
83
+ },
84
+ {
85
+ id: "deal_stale",
86
+ provider: "mock",
87
+ crmId: "006-stale",
88
+ accountId: "acct_acme",
89
+ ownerId: "user_ada",
90
+ name: "Acme Services",
91
+ amount: 4500000,
92
+ currency: "USD",
93
+ stage: "Demo",
94
+ closeDate: "2026-09-30",
95
+ forecastCategory: "pipeline",
96
+ probability: 0.2,
97
+ isClosed: false,
98
+ lastActivityAt: "2026-02-01",
99
+ lastSyncAt: "2026-05-03",
100
+ },
101
+ ],
102
+ activities: [
103
+ {
104
+ id: "act_acme_meeting",
105
+ provider: "mock",
106
+ crmId: "00T-acme",
107
+ accountId: "acct_acme",
108
+ dealId: "deal_past_close",
109
+ ownerId: "user_ada",
110
+ type: "meeting",
111
+ occurredAt: "2026-04-29",
112
+ subject: "Discovery follow-up",
113
+ },
114
+ ],
115
+ };