fullstackgtm 0.14.0 → 0.15.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 +42 -0
- package/README.md +14 -0
- package/dist/cli.js +69 -6
- package/dist/connectors/hubspot.js +62 -7
- package/dist/diff.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/mcp.js +20 -0
- package/dist/merge.js +1 -1
- package/dist/resolve.d.ts +37 -0
- package/dist/resolve.js +107 -0
- package/dist/rules.d.ts +12 -0
- package/dist/rules.js +25 -3
- package/dist/types.d.ts +16 -0
- package/docs/crm-health-lifecycle.md +11 -11
- package/llms.txt +4 -0
- package/package.json +1 -1
- package/src/cli.ts +70 -6
- package/src/connectors/hubspot.ts +68 -10
- package/src/diff.ts +1 -1
- package/src/index.ts +2 -0
- package/src/mcp.ts +26 -0
- package/src/merge.ts +1 -1
- package/src/resolve.ts +157 -0
- package/src/rules.ts +24 -3
- package/src/types.ts +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,48 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
5
5
|
and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
|
|
7
7
|
|
|
8
|
+
## [0.15.0] — 2026-06-11
|
|
9
|
+
|
|
10
|
+
The Prevent layer: stop creating duplicates, and name the writer that does.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`fullstackgtm resolve <account|contact|deal>`** — the create gate.
|
|
15
|
+
Deterministic verdicts (exists / ambiguous / safe_to_create) with matches
|
|
16
|
+
and reasons, using the same identity keys as the audit/merge engines:
|
|
17
|
+
normalized account domain, contact email, open-deal key. Names alone are
|
|
18
|
+
never identity (ambiguous, with candidates). Gate-shaped exit codes for
|
|
19
|
+
scripts: 0 = safe to create, 2 = match found, 1 = error. Exposed as
|
|
20
|
+
`resolveRecord()` and MCP `fullstackgtm_resolve`.
|
|
21
|
+
- **Record provenance** (`RecordProvenance` on accounts/contacts/deals):
|
|
22
|
+
HubSpot snapshots capture the read-only `hs_object_source`,
|
|
23
|
+
`hs_object_source_label`, `hs_object_source_id` fields, and the three
|
|
24
|
+
duplicate rules append writer attribution to findings — "Created by:
|
|
25
|
+
Gojiberry (app-123) ×2, CRM_UI" — so recurring dupes are fixed at the
|
|
26
|
+
faucet. Provenance is exempt from merge conflicts and diff drift, and
|
|
27
|
+
records created by the CLI's own `create:` path stamp
|
|
28
|
+
`hs_object_source_detail_2` (best-effort).
|
|
29
|
+
|
|
30
|
+
## [0.14.1] — 2026-06-11
|
|
31
|
+
|
|
32
|
+
Fixes from the 0.14.0 journey verification (4 agents, 21 checks).
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **`call score` no longer suggests a flag it rejects**: the keyless error
|
|
37
|
+
said "pass --deterministic" but score has no non-LLM mode — its error now
|
|
38
|
+
says exactly that. Rubric files are also validated *before* the
|
|
39
|
+
credential check (keyless users see rubric errors), and a non-JSON rubric
|
|
40
|
+
names the file and the expected shape instead of a raw parse error.
|
|
41
|
+
- **NDJSON rows carry `extractor`** — LLM and deterministic rows were
|
|
42
|
+
per-row indistinguishable in warehouse loads, contradicting the
|
|
43
|
+
provenance claim. (Docs wording was right at the parse-result and
|
|
44
|
+
evidence level; now it is true per-row too.)
|
|
45
|
+
- **`call … --help` prints help** instead of being shadowed by argument
|
|
46
|
+
and credential checks.
|
|
47
|
+
- `login anthropic|openai` gets the same explicit argv-secret rejection
|
|
48
|
+
(`--token`/`--key`/`--api-key`) as the CRM providers.
|
|
49
|
+
|
|
8
50
|
## [0.14.0] — 2026-06-11
|
|
9
51
|
|
|
10
52
|
LLM-powered call intelligence, bring-your-own-key. The dry-run replications
|
package/README.md
CHANGED
|
@@ -74,6 +74,20 @@ for t in transcripts/*; do fullstackgtm call parse --transcript "$t" --ndjson --
|
|
|
74
74
|
|
|
75
75
|
The boundary that remains: Slack/Notion/warehouse sinks are *your* pipeline, composed around the JSON — and your rubrics and keys stay yours.
|
|
76
76
|
|
|
77
|
+
## The create gate: no new dupes
|
|
78
|
+
|
|
79
|
+
Detection cleans up yesterday's duplicates; the **resolve gate** prevents tomorrow's. Before any writer — a sync job, a webhook handler, an agent, your own script — creates a record, ask the gate:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
fullstackgtm resolve contact --email jane@acme.com --input snap.json # exit 0 = safe to create
|
|
83
|
+
fullstackgtm resolve account --domain acme.com --provider hubspot # exit 2 = exists/ambiguous: do NOT create
|
|
84
|
+
fullstackgtm resolve deal --name "Acme Expansion" --account-id 123 --input snap.json
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Identity keys match the audit/merge engines exactly: account domain (normalized), contact email, and the open-deal key (account + normalized name). Names alone are never identity — they return `ambiguous` with the candidates, not a guess. Exit codes are gate-shaped for scripts: `0` safe to create, `2` match found, `1` error. For high-volume writers, pair it with a cron-refreshed snapshot file rather than a live `--provider` fetch per call. Also exposed as `resolveRecord()` and the MCP tool `fullstackgtm_resolve`.
|
|
88
|
+
|
|
89
|
+
**Provenance attribution** closes the loop on recurring dupes: snapshots now capture each record's source (HubSpot's read-only `hs_object_source*` fields), and duplicate findings name the writer — `"3 accounts share acme.com … Created by: Gojiberry (app-123) ×2, CRM_UI"` — so you fix the integration, not just the records. Records created by this CLI stamp their own provenance (`hs_object_source_detail_2`, best-effort).
|
|
90
|
+
|
|
77
91
|
## From findings to fixes: the suggest chain
|
|
78
92
|
|
|
79
93
|
Most placeholder answers are already derivable from your own CRM data. `suggest` computes them deterministically — account-name matching cross-checked against contact associations — with a confidence level and a written reason per operation, so you (or an agent) approve evidence, not guesses:
|
package/dist/cli.js
CHANGED
|
@@ -19,6 +19,7 @@ import { builtinAuditRules } from "./rules.js";
|
|
|
19
19
|
import { sampleSnapshot } from "./sampleData.js";
|
|
20
20
|
import { normalizeTranscript, parseCall, suggestCallDeal } from "./calls.js";
|
|
21
21
|
import { DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
|
|
22
|
+
import { resolveRecord } from "./resolve.js";
|
|
22
23
|
import { suggestValues } from "./suggest.js";
|
|
23
24
|
function usage() {
|
|
24
25
|
return `FullStackGTM — audit GTM data across providers, propose reviewable patch plans,
|
|
@@ -51,6 +52,9 @@ Usage:
|
|
|
51
52
|
ANTHROPIC_API_KEY/OPENAI_API_KEY, or \`login anthropic|openai\`);
|
|
52
53
|
--deterministic uses the free keyword baseline. Then link the call
|
|
53
54
|
to its deal and propose governed next-step writes.
|
|
55
|
+
fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]
|
|
56
|
+
the create gate: exit 0 = safe to create, exit 2 = match
|
|
57
|
+
found (exists/ambiguous) — call before ANY record creation
|
|
54
58
|
fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
|
|
55
59
|
derive values for requires_human_* placeholders
|
|
56
60
|
from snapshot evidence, with confidence + reasons
|
|
@@ -418,6 +422,17 @@ function parseValueOverrides(args) {
|
|
|
418
422
|
}
|
|
419
423
|
async function callCommand(args) {
|
|
420
424
|
const [subcommand, ...rest] = args;
|
|
425
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
426
|
+
console.log(`call parse --transcript <file> [--title t] [--source s] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
|
|
427
|
+
call score --transcript <file>|--call <parsed.json> [--rubric <rubric.json>] [--model m] [--json|--out <path>]
|
|
428
|
+
call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
|
|
429
|
+
call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
|
|
430
|
+
|
|
431
|
+
parse/score default to LLM extraction (Anthropic or OpenAI key via env,
|
|
432
|
+
\`login anthropic|openai\`, or a one-time prompt). parse --deterministic is
|
|
433
|
+
the free keyword baseline; score always needs a key (scoring is LLM work).`);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
421
436
|
const loadParsedCall = async () => {
|
|
422
437
|
const callPath = option(rest, "--call");
|
|
423
438
|
if (callPath) {
|
|
@@ -462,6 +477,7 @@ async function callCommand(args) {
|
|
|
462
477
|
call_id: parsed.id,
|
|
463
478
|
call_title: parsed.title ?? null,
|
|
464
479
|
source_system: parsed.sourceSystem,
|
|
480
|
+
extractor: parsed.extractor,
|
|
465
481
|
type: insight.type,
|
|
466
482
|
title: insight.title,
|
|
467
483
|
text: insight.text,
|
|
@@ -534,11 +550,19 @@ async function callCommand(args) {
|
|
|
534
550
|
return;
|
|
535
551
|
}
|
|
536
552
|
if (subcommand === "score") {
|
|
537
|
-
|
|
553
|
+
// Rubric problems surface before any credential or API work.
|
|
538
554
|
const rubricPath = option(rest, "--rubric");
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
555
|
+
let rubric = DEFAULT_RUBRIC;
|
|
556
|
+
if (rubricPath) {
|
|
557
|
+
const rubricRaw = readFileSync(resolve(process.cwd(), rubricPath), "utf8");
|
|
558
|
+
try {
|
|
559
|
+
rubric = parseRubric(rubricRaw);
|
|
560
|
+
}
|
|
561
|
+
catch (error) {
|
|
562
|
+
throw new Error(`${rubricPath} is not a valid rubric: ${error instanceof Error ? error.message : String(error)} Expected JSON like { "scale": 5, "dimensions": [{ "name": "...", "weight": 1, "rubric": "..." }] }.`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const credential = await requireLlmCredential("score");
|
|
542
566
|
const transcriptPath = option(rest, "--transcript");
|
|
543
567
|
let transcriptText;
|
|
544
568
|
let title = option(rest, "--title") ?? undefined;
|
|
@@ -577,12 +601,14 @@ async function callCommand(args) {
|
|
|
577
601
|
* TTY a missing key is captured once (validated, stored 0600 like provider
|
|
578
602
|
* logins). Non-interactive contexts get an actionable error instead.
|
|
579
603
|
*/
|
|
580
|
-
async function requireLlmCredential() {
|
|
604
|
+
async function requireLlmCredential(command = "parse") {
|
|
581
605
|
const resolved = resolveLlmCredential();
|
|
582
606
|
if (resolved)
|
|
583
607
|
return resolved;
|
|
608
|
+
// Scoring is inherently LLM work — there is no keyword fallback to suggest.
|
|
609
|
+
const fallbackHint = command === "parse" ? ", or pass --deterministic for the free keyword baseline" : " (call score has no non-LLM mode)";
|
|
584
610
|
if (!process.stdin.isTTY) {
|
|
585
|
-
throw new Error(
|
|
611
|
+
throw new Error(`LLM ${command === "score" ? "scoring" : "extraction"} needs an API key. Set ANTHROPIC_API_KEY or OPENAI_API_KEY, or run \`echo "$KEY" | fullstackgtm login anthropic\` (or \`login openai\`) once${fallbackHint}.`);
|
|
586
612
|
}
|
|
587
613
|
console.error("LLM parsing needs an API key (Anthropic or OpenAI) — yours, used directly with the provider.");
|
|
588
614
|
console.error(`Paste it once; it is validated and stored at ${credentialsPath()} (file mode 0600), like CRM logins.`);
|
|
@@ -689,6 +715,38 @@ function buildCallPlan(parsed, deal, proposed, current, extraNextSteps) {
|
|
|
689
715
|
operations,
|
|
690
716
|
};
|
|
691
717
|
}
|
|
718
|
+
/**
|
|
719
|
+
* The resolve gate: exit 0 = safe to create, exit 2 = match found (exists or
|
|
720
|
+
* ambiguous — do NOT blind-create), exit 1 = error. Built for sync jobs and
|
|
721
|
+
* webhook handlers to call before any record creation.
|
|
722
|
+
*/
|
|
723
|
+
async function resolveCommand(args) {
|
|
724
|
+
const [objectType, ...rest] = args;
|
|
725
|
+
if (!objectType || !["account", "contact", "deal"].includes(objectType)) {
|
|
726
|
+
throw new Error("Usage: fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]");
|
|
727
|
+
}
|
|
728
|
+
const candidate = {
|
|
729
|
+
objectType: objectType,
|
|
730
|
+
name: option(rest, "--name") ?? undefined,
|
|
731
|
+
domain: option(rest, "--domain") ?? undefined,
|
|
732
|
+
email: option(rest, "--email") ?? undefined,
|
|
733
|
+
accountId: option(rest, "--account-id") ?? undefined,
|
|
734
|
+
};
|
|
735
|
+
const snapshot = await readSnapshot(rest);
|
|
736
|
+
const result = resolveRecord(snapshot, candidate);
|
|
737
|
+
if (rest.includes("--json")) {
|
|
738
|
+
console.log(JSON.stringify(result, null, 2));
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
const marker = result.verdict === "safe_to_create" ? "✓" : result.verdict === "exists" ? "=" : "?";
|
|
742
|
+
console.log(`${marker} [${result.verdict}] ${result.reason}`);
|
|
743
|
+
for (const m of result.matches) {
|
|
744
|
+
console.log(` ${m.id} "${m.name}" — matched by ${m.matchedBy}: ${m.detail}`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (result.verdict !== "safe_to_create")
|
|
748
|
+
process.exitCode = 2;
|
|
749
|
+
}
|
|
692
750
|
async function suggest(args) {
|
|
693
751
|
const planId = option(args, "--plan-id");
|
|
694
752
|
const planPath = option(args, "--plan");
|
|
@@ -1177,6 +1235,7 @@ async function login(args) {
|
|
|
1177
1235
|
return;
|
|
1178
1236
|
}
|
|
1179
1237
|
if (provider === "anthropic" || provider === "openai") {
|
|
1238
|
+
rejectArgvSecret(args, "--token", "--key", "--api-key");
|
|
1180
1239
|
const key = await readSecret(`${provider} API key (${provider === "anthropic" ? "sk-ant-..." : "sk-..."})`);
|
|
1181
1240
|
if (!key)
|
|
1182
1241
|
throw new Error(`No ${provider} key provided.`);
|
|
@@ -1433,6 +1492,10 @@ export async function runCli(argv) {
|
|
|
1433
1492
|
await callCommand(args);
|
|
1434
1493
|
return;
|
|
1435
1494
|
}
|
|
1495
|
+
if (command === "resolve") {
|
|
1496
|
+
await resolveCommand(args);
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1436
1499
|
if (command === "profiles") {
|
|
1437
1500
|
profilesCommand(args);
|
|
1438
1501
|
return;
|
|
@@ -84,7 +84,10 @@ export function createHubspotConnector(options) {
|
|
|
84
84
|
email: stringOrUndefined(owner.email),
|
|
85
85
|
active: owner.archived !== true,
|
|
86
86
|
}));
|
|
87
|
-
|
|
87
|
+
// Read-only record-source fields power duplicate-finding attribution
|
|
88
|
+
// ("all five created by integration X") — see RecordProvenance.
|
|
89
|
+
const PROVENANCE_PROPERTIES = "hs_object_source,hs_object_source_label,hs_object_source_id";
|
|
90
|
+
const companyProperties = `${mappedFields(mappings, "accounts", HUBSPOT_DEFAULT_FIELD_MAPPINGS.accounts).join(",")},${PROVENANCE_PROPERTIES}`;
|
|
88
91
|
const companies = await fetchObjects("companies", companyProperties, false);
|
|
89
92
|
const accounts = companies
|
|
90
93
|
.filter((company) => company.id)
|
|
@@ -101,11 +104,12 @@ export function createHubspotConnector(options) {
|
|
|
101
104
|
employeeCount: numberOrUndefined(readMapped(props, "accounts", "employeeCount", "numberofemployees")),
|
|
102
105
|
annualRevenue: numberOrUndefined(readMapped(props, "accounts", "annualRevenue", "annualrevenue")),
|
|
103
106
|
ownerId: stringOrUndefined(readMapped(props, "accounts", "ownerId", "hubspot_owner_id")),
|
|
107
|
+
provenance: provenanceFrom(props),
|
|
104
108
|
lastSyncAt: stringOrUndefined(company.updatedAt),
|
|
105
109
|
raw: company,
|
|
106
110
|
};
|
|
107
111
|
});
|
|
108
|
-
const contactProperties = mappedFields(mappings, "contacts", HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts).join(",")
|
|
112
|
+
const contactProperties = `${mappedFields(mappings, "contacts", HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts).join(",")},${PROVENANCE_PROPERTIES}`;
|
|
109
113
|
const hubspotContacts = await fetchObjects("contacts", contactProperties, true);
|
|
110
114
|
const contacts = hubspotContacts
|
|
111
115
|
.filter((contact) => contact.id)
|
|
@@ -124,11 +128,12 @@ export function createHubspotConnector(options) {
|
|
|
124
128
|
phone: stringOrUndefined(readMapped(props, "contacts", "phone", "phone")),
|
|
125
129
|
title: stringOrUndefined(readMapped(props, "contacts", "title", "jobtitle")),
|
|
126
130
|
ownerId: stringOrUndefined(readMapped(props, "contacts", "ownerId", "hubspot_owner_id")),
|
|
131
|
+
provenance: provenanceFrom(props),
|
|
127
132
|
lastSyncAt: stringOrUndefined(contact.updatedAt),
|
|
128
133
|
raw: contact,
|
|
129
134
|
};
|
|
130
135
|
});
|
|
131
|
-
const dealProperties = mappedFields(mappings, "deals", HUBSPOT_DEFAULT_FIELD_MAPPINGS.deals).join(",")
|
|
136
|
+
const dealProperties = `${mappedFields(mappings, "deals", HUBSPOT_DEFAULT_FIELD_MAPPINGS.deals).join(",")},${PROVENANCE_PROPERTIES}`;
|
|
132
137
|
const hubspotDeals = await fetchObjects("deals", dealProperties, true);
|
|
133
138
|
const deals = hubspotDeals
|
|
134
139
|
.filter((deal) => deal.id)
|
|
@@ -152,6 +157,7 @@ export function createHubspotConnector(options) {
|
|
|
152
157
|
identities: [{ provider: "hubspot", externalId: String(deal.id) }],
|
|
153
158
|
accountId: companyId ? String(companyId) : undefined,
|
|
154
159
|
ownerId: stringOrUndefined(readMapped(props, "deals", "ownerId", "hubspot_owner_id")),
|
|
160
|
+
provenance: provenanceFrom(props),
|
|
155
161
|
name: stringOrFallback(readMapped(props, "deals", "name", "dealname"), "Untitled Deal"),
|
|
156
162
|
amount: numberOrUndefined(readMapped(props, "deals", "amount", "amount")),
|
|
157
163
|
stage,
|
|
@@ -323,10 +329,23 @@ export function createHubspotConnector(options) {
|
|
|
323
329
|
createdCompaniesByName.set(nameKey, companyId);
|
|
324
330
|
}
|
|
325
331
|
else {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
332
|
+
let created;
|
|
333
|
+
try {
|
|
334
|
+
created = await request(`/crm/v3/objects/companies`, {
|
|
335
|
+
method: "POST",
|
|
336
|
+
body: JSON.stringify({
|
|
337
|
+
properties: { name, hs_object_source_detail_2: "fullstackgtm create: operation" },
|
|
338
|
+
}),
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// Some portals reject writes to source-detail properties — the
|
|
343
|
+
// provenance stamp is best-effort, the create is not.
|
|
344
|
+
created = await request(`/crm/v3/objects/companies`, {
|
|
345
|
+
method: "POST",
|
|
346
|
+
body: JSON.stringify({ properties: { name } }),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
330
349
|
companyId = String(created.id);
|
|
331
350
|
createdCompanyName = name;
|
|
332
351
|
createdCompaniesByName.set(nameKey, companyId);
|
|
@@ -394,6 +413,34 @@ export function createHubspotConnector(options) {
|
|
|
394
413
|
catch {
|
|
395
414
|
// fall through to create
|
|
396
415
|
}
|
|
416
|
+
// A live CRM often already carries a human-created follow-up for the same
|
|
417
|
+
// record (a previous partial run, or a rep's own task). Creating another
|
|
418
|
+
// on top is duplicate noise — skip when the object already has an open
|
|
419
|
+
// task, regardless of who created it. Fail-open: a lookup hiccup must
|
|
420
|
+
// not block the apply.
|
|
421
|
+
try {
|
|
422
|
+
const objectPath = OBJECT_PATHS[operation.objectType];
|
|
423
|
+
const assoc = await request(`/crm/v4/objects/${objectPath}/${encodeURIComponent(operation.objectId)}/associations/tasks?limit=20`);
|
|
424
|
+
const taskIds = (assoc?.results ?? [])
|
|
425
|
+
.map((row) => String(row.toObjectId ?? ""))
|
|
426
|
+
.filter(Boolean)
|
|
427
|
+
.slice(0, 10);
|
|
428
|
+
for (const taskId of taskIds) {
|
|
429
|
+
const existingTask = await request(`/crm/v3/objects/tasks/${encodeURIComponent(taskId)}?properties=hs_task_status`);
|
|
430
|
+
const status = String(existingTask?.properties?.hs_task_status ?? "");
|
|
431
|
+
if (status !== "COMPLETED" && status !== "DELETED") {
|
|
432
|
+
return {
|
|
433
|
+
operationId: operation.id,
|
|
434
|
+
status: "skipped",
|
|
435
|
+
detail: `An open task (task ${taskId}) already exists on ${operation.objectType}/${operation.objectId}; not creating a duplicate follow-up.`,
|
|
436
|
+
providerData: { id: taskId, existing: true },
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
// fall through to create
|
|
443
|
+
}
|
|
397
444
|
const body = `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`;
|
|
398
445
|
const response = await request(`/crm/v3/objects/tasks`, {
|
|
399
446
|
method: "POST",
|
|
@@ -567,6 +614,14 @@ export function createHubspotConnector(options) {
|
|
|
567
614
|
readField,
|
|
568
615
|
};
|
|
569
616
|
}
|
|
617
|
+
function provenanceFrom(props) {
|
|
618
|
+
const source = stringOrUndefined(props.hs_object_source);
|
|
619
|
+
const sourceLabel = stringOrUndefined(props.hs_object_source_label);
|
|
620
|
+
const sourceId = stringOrUndefined(props.hs_object_source_id);
|
|
621
|
+
if (!source && !sourceLabel && !sourceId)
|
|
622
|
+
return undefined;
|
|
623
|
+
return { source, sourceLabel, sourceId };
|
|
624
|
+
}
|
|
570
625
|
function stringOrUndefined(value) {
|
|
571
626
|
if (value === undefined || value === null || value === "")
|
|
572
627
|
return undefined;
|
package/dist/diff.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* appeared, what disappeared, what changed — and whether hygiene regressed.
|
|
5
5
|
*/
|
|
6
6
|
// Fields that change on every sync without semantic meaning.
|
|
7
|
-
const IGNORED_FIELDS = new Set(["raw", "lastSyncAt", "identities"]);
|
|
7
|
+
const IGNORED_FIELDS = new Set(["raw", "lastSyncAt", "identities", "provenance"]);
|
|
8
8
|
function labelOf(record) {
|
|
9
9
|
return record.name ?? record.email ?? record.id;
|
|
10
10
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -14,9 +14,10 @@ export { createFilePlanStore, type PlanStore, type StoredPlan } from "./planStor
|
|
|
14
14
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
15
15
|
export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
|
|
16
16
|
export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, type CrmObjectType, type FieldMappings, } from "./mappings.ts";
|
|
17
|
-
export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.ts";
|
|
17
|
+
export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, provenanceSummary, requiresHumanInput, staleDealRule, } from "./rules.ts";
|
|
18
18
|
export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, type CallDealSuggestion, type CallInsightType, type ExtractedCallInsight, type ParsedCall, type ParsedTranscriptSegment, } from "./calls.ts";
|
|
19
19
|
export { sampleSnapshot } from "./sampleData.ts";
|
|
20
20
|
export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, type CallScorecard, type LlmCredential, type LlmExtractedInsight, type LlmProvider, type Rubric, type ScoredDimension, } from "./llm.ts";
|
|
21
|
+
export { resolveRecord, type ResolveCandidate, type ResolveMatch, type ResolveResult } from "./resolve.ts";
|
|
21
22
|
export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
|
|
22
23
|
export type { ApprovalStatus, AuditFinding, AuditFindingSeverity, CanonicalAccount, CanonicalActivity, CanonicalContact, CanonicalDeal, CanonicalGtmSnapshot, CanonicalUser, CrmProvider, GtmAuditRule, GtmConnector, GtmEvidence, GtmEvidenceSourceSystem, GtmObjectType, GtmPolicy, GtmRuleContext, GtmRuleResult, GtmSnapshotIndex, PatchOperation, PatchOperationResult, PatchOperationType, PatchPlan, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
|
package/dist/index.js
CHANGED
|
@@ -14,8 +14,9 @@ export { createFilePlanStore } from "./planStore.js";
|
|
|
14
14
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
|
|
15
15
|
export { auditReportToHtml, auditReportToMarkdown } from "./report.js";
|
|
16
16
|
export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, } from "./mappings.js";
|
|
17
|
-
export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.js";
|
|
17
|
+
export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, provenanceSummary, requiresHumanInput, staleDealRule, } from "./rules.js";
|
|
18
18
|
export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, } from "./calls.js";
|
|
19
19
|
export { sampleSnapshot } from "./sampleData.js";
|
|
20
20
|
export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
|
|
21
|
+
export { resolveRecord } from "./resolve.js";
|
|
21
22
|
export { suggestValues } from "./suggest.js";
|
package/dist/mcp.js
CHANGED
|
@@ -47,6 +47,7 @@ import { builtinAuditRules } from "./rules.js";
|
|
|
47
47
|
import { sampleSnapshot } from "./sampleData.js";
|
|
48
48
|
import { normalizeTranscript, parseCall } from "./calls.js";
|
|
49
49
|
import { extractInsightsLlm, resolveLlmCredential } from "./llm.js";
|
|
50
|
+
import { resolveRecord } from "./resolve.js";
|
|
50
51
|
import { suggestValues } from "./suggest.js";
|
|
51
52
|
function content(value) {
|
|
52
53
|
return {
|
|
@@ -197,6 +198,25 @@ export async function startMcpServer() {
|
|
|
197
198
|
}
|
|
198
199
|
return content(parseCall(raw, { title, sourceSystem: source }));
|
|
199
200
|
});
|
|
201
|
+
server.registerTool("fullstackgtm_resolve", {
|
|
202
|
+
title: "Resolve Record (create gate)",
|
|
203
|
+
description: "Before creating a CRM record, check whether it already exists. Returns a verdict " +
|
|
204
|
+
"(exists | ambiguous | safe_to_create) with matches and a reason, using the same " +
|
|
205
|
+
"identity keys as the audit/merge engines (account domain, contact email, open-deal " +
|
|
206
|
+
"key). Read-only. Never create on 'exists' or 'ambiguous'.",
|
|
207
|
+
inputSchema: {
|
|
208
|
+
objectType: z.enum(["account", "contact", "deal"]),
|
|
209
|
+
name: z.string().optional(),
|
|
210
|
+
domain: z.string().optional(),
|
|
211
|
+
email: z.string().optional(),
|
|
212
|
+
accountId: z.string().optional(),
|
|
213
|
+
provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
|
|
214
|
+
inputPath: z.string().optional(),
|
|
215
|
+
},
|
|
216
|
+
}, async ({ objectType, name, domain, email, accountId, provider, inputPath }) => {
|
|
217
|
+
const snapshot = await readSnapshot(provider, inputPath);
|
|
218
|
+
return content(resolveRecord(snapshot, { objectType, name, domain, email, accountId }));
|
|
219
|
+
});
|
|
200
220
|
server.registerTool("fullstackgtm_rules", {
|
|
201
221
|
title: "List Audit Rules",
|
|
202
222
|
description: "List the built-in deterministic audit rules with ids and descriptions.",
|
package/dist/merge.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const CONFLICT_IGNORED_FIELDS = new Set([
|
|
2
|
-
"id", "provider", "crmId", "identities", "raw", "lastSyncAt", "lastActivityAt", "ownerId", "accountId",
|
|
2
|
+
"id", "provider", "crmId", "identities", "raw", "lastSyncAt", "lastActivityAt", "ownerId", "accountId", "provenance",
|
|
3
3
|
]);
|
|
4
4
|
export function normalizeDomain(domain) {
|
|
5
5
|
if (!domain)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CanonicalGtmSnapshot } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* The resolve gate — the Prevent layer of the CRM-health lifecycle.
|
|
4
|
+
*
|
|
5
|
+
* Before any writer (sync job, webhook handler, agent, this CLI's own
|
|
6
|
+
* `create:` path) creates a record, it should ask: does this record already
|
|
7
|
+
* exist? The gate answers deterministically from a snapshot using the same
|
|
8
|
+
* identity keys the audit and merge engines use:
|
|
9
|
+
*
|
|
10
|
+
* account → normalized domain (exact), then normalized name
|
|
11
|
+
* contact → normalized email (exact), then full name
|
|
12
|
+
* deal → open-deal key: (accountId | "unlinked") + normalized name
|
|
13
|
+
*
|
|
14
|
+
* Verdicts are gate-shaped: `exists` (link to it, don't create),
|
|
15
|
+
* `ambiguous` (a human must pick — do NOT blind-create), `safe_to_create`.
|
|
16
|
+
*/
|
|
17
|
+
export type ResolveCandidate = {
|
|
18
|
+
objectType: "account" | "contact" | "deal";
|
|
19
|
+
name?: string;
|
|
20
|
+
domain?: string;
|
|
21
|
+
email?: string;
|
|
22
|
+
/** For deals: scope the duplicate key to an account. */
|
|
23
|
+
accountId?: string;
|
|
24
|
+
};
|
|
25
|
+
export type ResolveMatch = {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
matchedBy: "domain" | "email" | "name" | "deal_key";
|
|
29
|
+
detail: string;
|
|
30
|
+
};
|
|
31
|
+
export type ResolveResult = {
|
|
32
|
+
objectType: ResolveCandidate["objectType"];
|
|
33
|
+
verdict: "exists" | "ambiguous" | "safe_to_create";
|
|
34
|
+
matches: ResolveMatch[];
|
|
35
|
+
reason: string;
|
|
36
|
+
};
|
|
37
|
+
export declare function resolveRecord(snapshot: CanonicalGtmSnapshot, candidate: ResolveCandidate): ResolveResult;
|
package/dist/resolve.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { normalizeDomain } from "./merge.js";
|
|
2
|
+
export function resolveRecord(snapshot, candidate) {
|
|
3
|
+
if (candidate.objectType === "account")
|
|
4
|
+
return resolveAccount(snapshot, candidate);
|
|
5
|
+
if (candidate.objectType === "contact")
|
|
6
|
+
return resolveContact(snapshot, candidate);
|
|
7
|
+
return resolveDeal(snapshot, candidate);
|
|
8
|
+
}
|
|
9
|
+
function resolveAccount(snapshot, c) {
|
|
10
|
+
const base = { objectType: "account" };
|
|
11
|
+
const domain = normalizeDomain(c.domain);
|
|
12
|
+
if (domain) {
|
|
13
|
+
const matches = snapshot.accounts
|
|
14
|
+
.filter((a) => normalizeDomain(a.domain) === domain)
|
|
15
|
+
.map((a) => match(a.id, a.name, "domain", `account domain ${a.domain} normalizes to ${domain}`));
|
|
16
|
+
if (matches.length === 1) {
|
|
17
|
+
return { ...base, verdict: "exists", matches, reason: `An account with domain ${domain} already exists: "${matches[0].name}" (${matches[0].id}). Link to it instead of creating.` };
|
|
18
|
+
}
|
|
19
|
+
if (matches.length > 1) {
|
|
20
|
+
return { ...base, verdict: "ambiguous", matches, reason: `${matches.length} accounts already share domain ${domain} — that's a duplicate group; merge it before adding more. Do not create.` };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (c.name) {
|
|
24
|
+
const key = normalizeName(c.name);
|
|
25
|
+
const matches = snapshot.accounts
|
|
26
|
+
.filter((a) => normalizeName(a.name) === key)
|
|
27
|
+
.map((a) => match(a.id, a.name, "name", `account name matches "${c.name}" (domain: ${a.domain ?? "none"})`));
|
|
28
|
+
if (matches.length > 0) {
|
|
29
|
+
// Name alone is suggestive, not identity — two real companies can share
|
|
30
|
+
// a name (the merge engine treats this the same way).
|
|
31
|
+
return {
|
|
32
|
+
...base,
|
|
33
|
+
verdict: "ambiguous",
|
|
34
|
+
matches,
|
|
35
|
+
reason: `${matches.length} account(s) named "${c.name}" exist but ${domain ? `none share domain ${domain}` : "no domain was supplied to confirm identity"}. Confirm before creating — supply a domain to disambiguate.`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!domain && !c.name) {
|
|
40
|
+
return { ...base, verdict: "ambiguous", matches: [], reason: "Supply --domain and/or --name to resolve an account." };
|
|
41
|
+
}
|
|
42
|
+
return { ...base, verdict: "safe_to_create", matches: [], reason: `No account matches ${domain ? `domain ${domain}` : `name "${c.name}"`}. Safe to create.` };
|
|
43
|
+
}
|
|
44
|
+
function resolveContact(snapshot, c) {
|
|
45
|
+
const base = { objectType: "contact" };
|
|
46
|
+
const email = c.email?.trim().toLowerCase();
|
|
47
|
+
if (email) {
|
|
48
|
+
const matches = snapshot.contacts
|
|
49
|
+
.filter((row) => row.email?.trim().toLowerCase() === email)
|
|
50
|
+
.map((row) => match(row.id, contactName(row), "email", `contact email matches ${email}`));
|
|
51
|
+
if (matches.length === 1) {
|
|
52
|
+
return { ...base, verdict: "exists", matches, reason: `A contact with email ${email} already exists: "${matches[0].name}" (${matches[0].id}). Update it instead of creating.` };
|
|
53
|
+
}
|
|
54
|
+
if (matches.length > 1) {
|
|
55
|
+
return { ...base, verdict: "ambiguous", matches, reason: `${matches.length} contacts already share ${email} — a duplicate group; merge before adding more. Do not create.` };
|
|
56
|
+
}
|
|
57
|
+
return { ...base, verdict: "safe_to_create", matches: [], reason: `No contact matches ${email}. Safe to create.` };
|
|
58
|
+
}
|
|
59
|
+
if (c.name) {
|
|
60
|
+
const key = normalizeName(c.name);
|
|
61
|
+
const matches = snapshot.contacts
|
|
62
|
+
.filter((row) => normalizeName(contactName(row)) === key)
|
|
63
|
+
.map((row) => match(row.id, contactName(row), "name", `contact name matches "${c.name}" (email: ${row.email ?? "none"})`));
|
|
64
|
+
if (matches.length > 0) {
|
|
65
|
+
return { ...base, verdict: "ambiguous", matches, reason: `${matches.length} contact(s) named "${c.name}" exist; names are not identity. Supply --email to resolve definitively.` };
|
|
66
|
+
}
|
|
67
|
+
return { ...base, verdict: "safe_to_create", matches: [], reason: `No contact named "${c.name}". Safe to create — but prefer resolving by email.` };
|
|
68
|
+
}
|
|
69
|
+
return { ...base, verdict: "ambiguous", matches: [], reason: "Supply --email (preferred) or --name to resolve a contact." };
|
|
70
|
+
}
|
|
71
|
+
function resolveDeal(snapshot, c) {
|
|
72
|
+
const base = { objectType: "deal" };
|
|
73
|
+
if (!c.name) {
|
|
74
|
+
return { ...base, verdict: "ambiguous", matches: [], reason: "Supply --name (and ideally --account-id) to resolve a deal." };
|
|
75
|
+
}
|
|
76
|
+
const key = `${c.accountId ?? "unlinked"}:${normalizeName(c.name)}`;
|
|
77
|
+
const open = snapshot.deals.filter((d) => d.isClosed !== true && d.isWon !== true);
|
|
78
|
+
const matches = open
|
|
79
|
+
.filter((d) => `${d.accountId ?? "unlinked"}:${normalizeName(d.name)}` === key)
|
|
80
|
+
.map((d) => match(d.id, d.name, "deal_key", `open deal with the same name on ${c.accountId ? `account ${c.accountId}` : "no account"}`));
|
|
81
|
+
if (matches.length > 0) {
|
|
82
|
+
return {
|
|
83
|
+
...base,
|
|
84
|
+
verdict: "exists",
|
|
85
|
+
matches,
|
|
86
|
+
reason: `${matches.length} open deal(s) already match "${c.name}" on ${c.accountId ? `account ${c.accountId}` : "unlinked"} — creating another would double-count pipeline. Update the existing deal.`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const closedSameName = snapshot.deals.filter((d) => (d.isClosed === true || d.isWon === true) && normalizeName(d.name) === normalizeName(c.name));
|
|
90
|
+
return {
|
|
91
|
+
...base,
|
|
92
|
+
verdict: "safe_to_create",
|
|
93
|
+
matches: [],
|
|
94
|
+
reason: closedSameName.length > 0
|
|
95
|
+
? `No open deal matches; ${closedSameName.length} closed deal(s) share the name (a re-open/renewal may be intended). Safe to create.`
|
|
96
|
+
: `No open deal matches "${c.name}". Safe to create.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function contactName(row) {
|
|
100
|
+
return [row.firstName, row.lastName].filter(Boolean).join(" ") || "(unnamed)";
|
|
101
|
+
}
|
|
102
|
+
function normalizeName(value) {
|
|
103
|
+
return value.trim().toLowerCase().replace(/\s+/g, " ");
|
|
104
|
+
}
|
|
105
|
+
function match(id, name, matchedBy, detail) {
|
|
106
|
+
return { id, name, matchedBy, detail };
|
|
107
|
+
}
|
package/dist/rules.d.ts
CHANGED
|
@@ -6,6 +6,18 @@ import type { CanonicalGtmSnapshot, GtmAuditRule, GtmSnapshotIndex } from "./typ
|
|
|
6
6
|
*/
|
|
7
7
|
export declare const REQUIRES_HUMAN_PREFIX = "requires_human_";
|
|
8
8
|
export declare function requiresHumanInput(value: unknown): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Attribution for duplicate groups: when the provider exposes record-source
|
|
11
|
+
* provenance (RecordProvenance), name the writer(s) that created the group —
|
|
12
|
+
* the fix for recurring dupes is upstream in the writer, not in the records.
|
|
13
|
+
*/
|
|
14
|
+
export declare function provenanceSummary(records: Array<{
|
|
15
|
+
provenance?: {
|
|
16
|
+
source?: string;
|
|
17
|
+
sourceLabel?: string;
|
|
18
|
+
sourceId?: string;
|
|
19
|
+
};
|
|
20
|
+
}>): string;
|
|
9
21
|
export declare function auditFindingId(ruleId: string, objectId: string): string;
|
|
10
22
|
export declare function patchOperationId(ruleId: string, objectId: string): string;
|
|
11
23
|
export declare function stableHash(value: string): string;
|
package/dist/rules.js
CHANGED
|
@@ -8,6 +8,28 @@ export const REQUIRES_HUMAN_PREFIX = "requires_human_";
|
|
|
8
8
|
export function requiresHumanInput(value) {
|
|
9
9
|
return typeof value === "string" && value.startsWith(REQUIRES_HUMAN_PREFIX);
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Attribution for duplicate groups: when the provider exposes record-source
|
|
13
|
+
* provenance (RecordProvenance), name the writer(s) that created the group —
|
|
14
|
+
* the fix for recurring dupes is upstream in the writer, not in the records.
|
|
15
|
+
*/
|
|
16
|
+
export function provenanceSummary(records) {
|
|
17
|
+
const counts = new Map();
|
|
18
|
+
for (const record of records) {
|
|
19
|
+
const p = record.provenance;
|
|
20
|
+
if (!p)
|
|
21
|
+
continue;
|
|
22
|
+
const label = p.sourceLabel ?? p.source ?? "unknown source";
|
|
23
|
+
const key = p.sourceId ? `${label} (${p.sourceId})` : label;
|
|
24
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
25
|
+
}
|
|
26
|
+
if (counts.size === 0)
|
|
27
|
+
return "";
|
|
28
|
+
const parts = [...counts.entries()]
|
|
29
|
+
.sort((a, b) => b[1] - a[1])
|
|
30
|
+
.map(([key, count]) => (count > 1 ? `${key} ×${count}` : key));
|
|
31
|
+
return ` Created by: ${parts.join(", ")}.`;
|
|
32
|
+
}
|
|
11
33
|
export function auditFindingId(ruleId, objectId) {
|
|
12
34
|
return `finding_${stableHash(`${ruleId}:${objectId}`)}`;
|
|
13
35
|
}
|
|
@@ -326,7 +348,7 @@ export const duplicateAccountDomainRule = {
|
|
|
326
348
|
ruleId: "duplicate-account-domain",
|
|
327
349
|
title: "Accounts share the same domain",
|
|
328
350
|
severity: "warning",
|
|
329
|
-
summary: `${accounts.length} accounts share ${domain}: ${accounts.map((account) => account.name).join(", ")}
|
|
351
|
+
summary: `${accounts.length} accounts share ${domain}: ${accounts.map((account) => account.name).join(", ")}.${provenanceSummary(accounts)}`,
|
|
330
352
|
recommendation: "Review the group and merge duplicates so activity and deals roll up once.",
|
|
331
353
|
});
|
|
332
354
|
operations.push({
|
|
@@ -363,7 +385,7 @@ export const duplicateContactEmailRule = {
|
|
|
363
385
|
ruleId: "duplicate-contact-email",
|
|
364
386
|
title: "Contacts share the same email",
|
|
365
387
|
severity: "warning",
|
|
366
|
-
summary: `${contacts.length} contacts share ${email}
|
|
388
|
+
summary: `${contacts.length} contacts share ${email}.${provenanceSummary(contacts)}`,
|
|
367
389
|
recommendation: "Merge the duplicates so engagement history and routing stay coherent.",
|
|
368
390
|
});
|
|
369
391
|
operations.push({
|
|
@@ -410,7 +432,7 @@ export const duplicateOpenDealRule = {
|
|
|
410
432
|
ruleId: "duplicate-open-deal",
|
|
411
433
|
title: "Open deals duplicate the same opportunity",
|
|
412
434
|
severity: "warning",
|
|
413
|
-
summary: `${deals.length} open deals named "${anchor.name}"${anchor.accountId ? " on the same account" : ""}: ${deals.map((deal) => deal.id).join(", ")}
|
|
435
|
+
summary: `${deals.length} open deals named "${anchor.name}"${anchor.accountId ? " on the same account" : ""}: ${deals.map((deal) => deal.id).join(", ")}.${provenanceSummary(deals)}`,
|
|
414
436
|
recommendation: "Keep one deal, archive the copies, and fix the integration that is re-creating them.",
|
|
415
437
|
});
|
|
416
438
|
operations.push({
|