fullstackgtm 0.14.1 → 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 +22 -0
- package/README.md +14 -0
- package/dist/cli.js +40 -0
- 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 +39 -0
- 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,28 @@ 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
|
+
|
|
8
30
|
## [0.14.1] — 2026-06-11
|
|
9
31
|
|
|
10
32
|
Fixes from the 0.14.0 journey verification (4 agents, 21 checks).
|
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
|
|
@@ -711,6 +715,38 @@ function buildCallPlan(parsed, deal, proposed, current, extraNextSteps) {
|
|
|
711
715
|
operations,
|
|
712
716
|
};
|
|
713
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
|
+
}
|
|
714
750
|
async function suggest(args) {
|
|
715
751
|
const planId = option(args, "--plan-id");
|
|
716
752
|
const planPath = option(args, "--plan");
|
|
@@ -1456,6 +1492,10 @@ export async function runCli(argv) {
|
|
|
1456
1492
|
await callCommand(args);
|
|
1457
1493
|
return;
|
|
1458
1494
|
}
|
|
1495
|
+
if (command === "resolve") {
|
|
1496
|
+
await resolveCommand(args);
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1459
1499
|
if (command === "profiles") {
|
|
1460
1500
|
profilesCommand(args);
|
|
1461
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({
|
package/dist/types.d.ts
CHANGED
|
@@ -83,11 +83,25 @@ export type CanonicalUser = {
|
|
|
83
83
|
title?: string;
|
|
84
84
|
active?: boolean;
|
|
85
85
|
};
|
|
86
|
+
/**
|
|
87
|
+
* Who created a record, per the provider's read-only record-source fields
|
|
88
|
+
* (HubSpot: hs_object_source / _label / _id). Populated on read; used to
|
|
89
|
+
* attribute duplicate findings to the writer that produced them.
|
|
90
|
+
*/
|
|
91
|
+
export type RecordProvenance = {
|
|
92
|
+
/** Provider source code, e.g. INTEGRATION, API, CRM_UI, IMPORT, FORM. */
|
|
93
|
+
source?: string;
|
|
94
|
+
/** Human label, e.g. an integration's name. */
|
|
95
|
+
sourceLabel?: string;
|
|
96
|
+
/** Provider-side id of the source (e.g. app id, import id). */
|
|
97
|
+
sourceId?: string;
|
|
98
|
+
};
|
|
86
99
|
export type CanonicalAccount = {
|
|
87
100
|
id: string;
|
|
88
101
|
provider?: CrmProvider;
|
|
89
102
|
crmId?: string;
|
|
90
103
|
identities?: ProviderIdentity[];
|
|
104
|
+
provenance?: RecordProvenance;
|
|
91
105
|
name: string;
|
|
92
106
|
domain?: string;
|
|
93
107
|
industry?: string;
|
|
@@ -104,6 +118,7 @@ export type CanonicalContact = {
|
|
|
104
118
|
provider?: CrmProvider;
|
|
105
119
|
crmId?: string;
|
|
106
120
|
identities?: ProviderIdentity[];
|
|
121
|
+
provenance?: RecordProvenance;
|
|
107
122
|
accountId?: string;
|
|
108
123
|
firstName?: string;
|
|
109
124
|
lastName?: string;
|
|
@@ -121,6 +136,7 @@ export type CanonicalDeal = {
|
|
|
121
136
|
provider?: CrmProvider;
|
|
122
137
|
crmId?: string;
|
|
123
138
|
identities?: ProviderIdentity[];
|
|
139
|
+
provenance?: RecordProvenance;
|
|
124
140
|
accountId?: string;
|
|
125
141
|
ownerId?: string;
|
|
126
142
|
name: string;
|
|
@@ -41,12 +41,11 @@ fix the faucet instead of mopping the puddle.
|
|
|
41
41
|
a unique match, refuse on ambiguity, create only on a confirmed miss.
|
|
42
42
|
HubSpot's search API is eventually consistent (~5–10s), so same-run
|
|
43
43
|
creations are deduped in memory, not via search.
|
|
44
|
-
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
(account + normalized name).
|
|
44
|
+
- The **`resolve` gate** (shipped 0.15): `fullstackgtm resolve
|
|
45
|
+
<account|contact|deal>` returns exists/ambiguous/safe_to_create with
|
|
46
|
+
matches and reasons; exit 0 = safe, exit 2 = do not create. Same identity
|
|
47
|
+
keys as the audit/merge engines. Also `resolveRecord()` and MCP
|
|
48
|
+
`fullstackgtm_resolve`.
|
|
50
49
|
- **Stamp provenance on our own creates** (HubSpot allows integrations to
|
|
51
50
|
set `hs_object_source_detail_2/3` at create time).
|
|
52
51
|
- Recommend native config in `doctor`/audit: Salesforce duplicate rules
|
|
@@ -61,10 +60,10 @@ fix the faucet instead of mopping the puddle.
|
|
|
61
60
|
(ruleId, objectId).
|
|
62
61
|
- **The nightly watch recipe** ("CRM CI"): scheduled
|
|
63
62
|
`snapshot → audit → diff` against yesterday's snapshot, alert on exit 2.
|
|
64
|
-
- **Attribution** (
|
|
65
|
-
`hs_object_source
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
- **Attribution** (shipped 0.15): snapshots capture HubSpot's read-only
|
|
64
|
+
`hs_object_source*` into `RecordProvenance`; duplicate findings append
|
|
65
|
+
*"Created by: …"* naming the writer(s). The fix for recurring dupes is
|
|
66
|
+
upstream, in the writer.
|
|
68
67
|
- Incremental reads (`snapshot --since`) exist for all three connectors;
|
|
69
68
|
caveats: HubSpot deltas carry no associations and cap at 10k per object,
|
|
70
69
|
Stripe deltas catch creations only.
|
|
@@ -131,5 +130,6 @@ Lessons from auditing our own apply path:
|
|
|
131
130
|
| --- | --- |
|
|
132
131
|
| 0.11.1 | Fix our own faucet: resolve-first `create:` + plan-scoped dedup, HubSpot association-aware CAS for `link_record`, domain normalization in `duplicate-account-domain`, `create_task` idempotency token |
|
|
133
132
|
| 0.12 (shipped) | `merge_records` (HubSpot contacts/companies/deals) + survivor suggestions capped at low confidence; the three duplicate rules emit governed merges instead of review tasks |
|
|
134
|
-
| 0.
|
|
133
|
+
| 0.15 (shipped) | `resolve` gate (CLI/lib/MCP, gate exit codes), provenance capture (`hs_object_source*` → `RecordProvenance`) + attribution in duplicate findings, self-stamped creates |
|
|
134
|
+
| 0.16 | prevention-posture checks (native duplicate rules active? unique-value properties defined?) · live targeted resolve lookups |
|
|
135
135
|
| docs | The nightly watch recipe (existing flags, documented as CRM CI) |
|
package/llms.txt
CHANGED
|
@@ -18,6 +18,10 @@ at/above `--fail-on`.
|
|
|
18
18
|
- [CRM-health lifecycle](https://github.com/fullstackgtm/core/blob/main/docs/crm-health-lifecycle.md): the Prevent → Detect → Remediate → Verify/Attribute model; no-new-dupes design
|
|
19
19
|
- [CHANGELOG](https://github.com/fullstackgtm/core/blob/main/CHANGELOG.md): release history
|
|
20
20
|
|
|
21
|
+
`fullstackgtm resolve <type>` is the create gate: exit 0 = safe to create,
|
|
22
|
+
exit 2 = exists/ambiguous (never blind-create); duplicate findings carry
|
|
23
|
+
"Created by:" writer attribution when the provider exposes record source.
|
|
24
|
+
|
|
21
25
|
## Key invariants (calls)
|
|
22
26
|
|
|
23
27
|
`fullstackgtm call parse` defaults to LLM extraction (BYO Anthropic/OpenAI
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM",
|
package/src/cli.ts
CHANGED
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
type CallScorecard,
|
|
51
51
|
type LlmProvider,
|
|
52
52
|
} from "./llm.ts";
|
|
53
|
+
import { resolveRecord, type ResolveCandidate } from "./resolve.ts";
|
|
53
54
|
import { suggestValues, type ValueSuggestion } from "./suggest.ts";
|
|
54
55
|
import type { FieldMappings } from "./mappings.ts";
|
|
55
56
|
import type {
|
|
@@ -90,6 +91,9 @@ Usage:
|
|
|
90
91
|
ANTHROPIC_API_KEY/OPENAI_API_KEY, or \`login anthropic|openai\`);
|
|
91
92
|
--deterministic uses the free keyword baseline. Then link the call
|
|
92
93
|
to its deal and propose governed next-step writes.
|
|
94
|
+
fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]
|
|
95
|
+
the create gate: exit 0 = safe to create, exit 2 = match
|
|
96
|
+
found (exists/ambiguous) — call before ANY record creation
|
|
93
97
|
fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
|
|
94
98
|
derive values for requires_human_* placeholders
|
|
95
99
|
from snapshot evidence, with confidence + reasons
|
|
@@ -797,6 +801,37 @@ function buildCallPlan(
|
|
|
797
801
|
};
|
|
798
802
|
}
|
|
799
803
|
|
|
804
|
+
/**
|
|
805
|
+
* The resolve gate: exit 0 = safe to create, exit 2 = match found (exists or
|
|
806
|
+
* ambiguous — do NOT blind-create), exit 1 = error. Built for sync jobs and
|
|
807
|
+
* webhook handlers to call before any record creation.
|
|
808
|
+
*/
|
|
809
|
+
async function resolveCommand(args: string[]) {
|
|
810
|
+
const [objectType, ...rest] = args;
|
|
811
|
+
if (!objectType || !["account", "contact", "deal"].includes(objectType)) {
|
|
812
|
+
throw new Error("Usage: fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]");
|
|
813
|
+
}
|
|
814
|
+
const candidate: ResolveCandidate = {
|
|
815
|
+
objectType: objectType as ResolveCandidate["objectType"],
|
|
816
|
+
name: option(rest, "--name") ?? undefined,
|
|
817
|
+
domain: option(rest, "--domain") ?? undefined,
|
|
818
|
+
email: option(rest, "--email") ?? undefined,
|
|
819
|
+
accountId: option(rest, "--account-id") ?? undefined,
|
|
820
|
+
};
|
|
821
|
+
const snapshot = await readSnapshot(rest);
|
|
822
|
+
const result = resolveRecord(snapshot, candidate);
|
|
823
|
+
if (rest.includes("--json")) {
|
|
824
|
+
console.log(JSON.stringify(result, null, 2));
|
|
825
|
+
} else {
|
|
826
|
+
const marker = result.verdict === "safe_to_create" ? "✓" : result.verdict === "exists" ? "=" : "?";
|
|
827
|
+
console.log(`${marker} [${result.verdict}] ${result.reason}`);
|
|
828
|
+
for (const m of result.matches) {
|
|
829
|
+
console.log(` ${m.id} "${m.name}" — matched by ${m.matchedBy}: ${m.detail}`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
if (result.verdict !== "safe_to_create") process.exitCode = 2;
|
|
833
|
+
}
|
|
834
|
+
|
|
800
835
|
async function suggest(args: string[]) {
|
|
801
836
|
const planId = option(args, "--plan-id");
|
|
802
837
|
const planPath = option(args, "--plan");
|
|
@@ -1626,6 +1661,10 @@ export async function runCli(argv: string[]) {
|
|
|
1626
1661
|
await callCommand(args);
|
|
1627
1662
|
return;
|
|
1628
1663
|
}
|
|
1664
|
+
if (command === "resolve") {
|
|
1665
|
+
await resolveCommand(args);
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1629
1668
|
if (command === "profiles") {
|
|
1630
1669
|
profilesCommand(args);
|
|
1631
1670
|
return;
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
CanonicalAccount,
|
|
11
11
|
CanonicalContact,
|
|
12
12
|
CanonicalDeal,
|
|
13
|
+
RecordProvenance,
|
|
13
14
|
CanonicalGtmSnapshot,
|
|
14
15
|
CanonicalUser,
|
|
15
16
|
GtmConnector,
|
|
@@ -125,11 +126,14 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
125
126
|
active: owner.archived !== true,
|
|
126
127
|
}));
|
|
127
128
|
|
|
128
|
-
|
|
129
|
+
// Read-only record-source fields power duplicate-finding attribution
|
|
130
|
+
// ("all five created by integration X") — see RecordProvenance.
|
|
131
|
+
const PROVENANCE_PROPERTIES = "hs_object_source,hs_object_source_label,hs_object_source_id";
|
|
132
|
+
const companyProperties = `${mappedFields(
|
|
129
133
|
mappings,
|
|
130
134
|
"accounts",
|
|
131
135
|
HUBSPOT_DEFAULT_FIELD_MAPPINGS.accounts,
|
|
132
|
-
).join(",")
|
|
136
|
+
).join(",")},${PROVENANCE_PROPERTIES}`;
|
|
133
137
|
const companies = await fetchObjects("companies", companyProperties, false);
|
|
134
138
|
const accounts: CanonicalAccount[] = companies
|
|
135
139
|
.filter((company) => company.id)
|
|
@@ -155,16 +159,17 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
155
159
|
ownerId: stringOrUndefined(
|
|
156
160
|
readMapped(props, "accounts", "ownerId", "hubspot_owner_id"),
|
|
157
161
|
),
|
|
162
|
+
provenance: provenanceFrom(props),
|
|
158
163
|
lastSyncAt: stringOrUndefined(company.updatedAt),
|
|
159
164
|
raw: company,
|
|
160
165
|
};
|
|
161
166
|
});
|
|
162
167
|
|
|
163
|
-
const contactProperties = mappedFields(
|
|
168
|
+
const contactProperties = `${mappedFields(
|
|
164
169
|
mappings,
|
|
165
170
|
"contacts",
|
|
166
171
|
HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts,
|
|
167
|
-
).join(",")
|
|
172
|
+
).join(",")},${PROVENANCE_PROPERTIES}`;
|
|
168
173
|
const hubspotContacts = await fetchObjects("contacts", contactProperties, true);
|
|
169
174
|
const contacts: CanonicalContact[] = hubspotContacts
|
|
170
175
|
.filter((contact) => contact.id)
|
|
@@ -185,16 +190,17 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
185
190
|
ownerId: stringOrUndefined(
|
|
186
191
|
readMapped(props, "contacts", "ownerId", "hubspot_owner_id"),
|
|
187
192
|
),
|
|
193
|
+
provenance: provenanceFrom(props),
|
|
188
194
|
lastSyncAt: stringOrUndefined(contact.updatedAt),
|
|
189
195
|
raw: contact,
|
|
190
196
|
};
|
|
191
197
|
});
|
|
192
198
|
|
|
193
|
-
const dealProperties = mappedFields(
|
|
199
|
+
const dealProperties = `${mappedFields(
|
|
194
200
|
mappings,
|
|
195
201
|
"deals",
|
|
196
202
|
HUBSPOT_DEFAULT_FIELD_MAPPINGS.deals,
|
|
197
|
-
).join(",")
|
|
203
|
+
).join(",")},${PROVENANCE_PROPERTIES}`;
|
|
198
204
|
const hubspotDeals = await fetchObjects("deals", dealProperties, true);
|
|
199
205
|
const deals: CanonicalDeal[] = hubspotDeals
|
|
200
206
|
.filter((deal) => deal.id)
|
|
@@ -220,6 +226,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
220
226
|
identities: [{ provider: "hubspot", externalId: String(deal.id) }],
|
|
221
227
|
accountId: companyId ? String(companyId) : undefined,
|
|
222
228
|
ownerId: stringOrUndefined(readMapped(props, "deals", "ownerId", "hubspot_owner_id")),
|
|
229
|
+
provenance: provenanceFrom(props),
|
|
223
230
|
name: stringOrFallback(readMapped(props, "deals", "name", "dealname"), "Untitled Deal"),
|
|
224
231
|
amount: numberOrUndefined(readMapped(props, "deals", "amount", "amount")),
|
|
225
232
|
stage,
|
|
@@ -419,10 +426,22 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
419
426
|
resolvedExisting = true;
|
|
420
427
|
createdCompaniesByName.set(nameKey, companyId);
|
|
421
428
|
} else {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
429
|
+
let created;
|
|
430
|
+
try {
|
|
431
|
+
created = await request(`/crm/v3/objects/companies`, {
|
|
432
|
+
method: "POST",
|
|
433
|
+
body: JSON.stringify({
|
|
434
|
+
properties: { name, hs_object_source_detail_2: "fullstackgtm create: operation" },
|
|
435
|
+
}),
|
|
436
|
+
});
|
|
437
|
+
} catch {
|
|
438
|
+
// Some portals reject writes to source-detail properties — the
|
|
439
|
+
// provenance stamp is best-effort, the create is not.
|
|
440
|
+
created = await request(`/crm/v3/objects/companies`, {
|
|
441
|
+
method: "POST",
|
|
442
|
+
body: JSON.stringify({ properties: { name } }),
|
|
443
|
+
});
|
|
444
|
+
}
|
|
426
445
|
companyId = String(created.id);
|
|
427
446
|
createdCompanyName = name;
|
|
428
447
|
createdCompaniesByName.set(nameKey, companyId);
|
|
@@ -494,6 +513,37 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
494
513
|
} catch {
|
|
495
514
|
// fall through to create
|
|
496
515
|
}
|
|
516
|
+
// A live CRM often already carries a human-created follow-up for the same
|
|
517
|
+
// record (a previous partial run, or a rep's own task). Creating another
|
|
518
|
+
// on top is duplicate noise — skip when the object already has an open
|
|
519
|
+
// task, regardless of who created it. Fail-open: a lookup hiccup must
|
|
520
|
+
// not block the apply.
|
|
521
|
+
try {
|
|
522
|
+
const objectPath = OBJECT_PATHS[operation.objectType];
|
|
523
|
+
const assoc = await request(
|
|
524
|
+
`/crm/v4/objects/${objectPath}/${encodeURIComponent(operation.objectId)}/associations/tasks?limit=20`,
|
|
525
|
+
);
|
|
526
|
+
const taskIds = ((assoc?.results ?? []) as Array<{ toObjectId?: number | string }>)
|
|
527
|
+
.map((row) => String(row.toObjectId ?? ""))
|
|
528
|
+
.filter(Boolean)
|
|
529
|
+
.slice(0, 10);
|
|
530
|
+
for (const taskId of taskIds) {
|
|
531
|
+
const existingTask = await request(
|
|
532
|
+
`/crm/v3/objects/tasks/${encodeURIComponent(taskId)}?properties=hs_task_status`,
|
|
533
|
+
);
|
|
534
|
+
const status = String(existingTask?.properties?.hs_task_status ?? "");
|
|
535
|
+
if (status !== "COMPLETED" && status !== "DELETED") {
|
|
536
|
+
return {
|
|
537
|
+
operationId: operation.id,
|
|
538
|
+
status: "skipped",
|
|
539
|
+
detail: `An open task (task ${taskId}) already exists on ${operation.objectType}/${operation.objectId}; not creating a duplicate follow-up.`,
|
|
540
|
+
providerData: { id: taskId, existing: true },
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
} catch {
|
|
545
|
+
// fall through to create
|
|
546
|
+
}
|
|
497
547
|
const body = `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`;
|
|
498
548
|
const response = await request(`/crm/v3/objects/tasks`, {
|
|
499
549
|
method: "POST",
|
|
@@ -686,6 +736,14 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
686
736
|
};
|
|
687
737
|
}
|
|
688
738
|
|
|
739
|
+
function provenanceFrom(props: Record<string, unknown>): RecordProvenance | undefined {
|
|
740
|
+
const source = stringOrUndefined(props.hs_object_source);
|
|
741
|
+
const sourceLabel = stringOrUndefined(props.hs_object_source_label);
|
|
742
|
+
const sourceId = stringOrUndefined(props.hs_object_source_id);
|
|
743
|
+
if (!source && !sourceLabel && !sourceId) return undefined;
|
|
744
|
+
return { source, sourceLabel, sourceId };
|
|
745
|
+
}
|
|
746
|
+
|
|
689
747
|
function stringOrUndefined(value: unknown): string | undefined {
|
|
690
748
|
if (value === undefined || value === null || value === "") return undefined;
|
|
691
749
|
return String(value);
|
package/src/diff.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { AuditFinding, CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
// Fields that change on every sync without semantic meaning.
|
|
10
|
-
const IGNORED_FIELDS = new Set(["raw", "lastSyncAt", "identities"]);
|
|
10
|
+
const IGNORED_FIELDS = new Set(["raw", "lastSyncAt", "identities", "provenance"]);
|
|
11
11
|
|
|
12
12
|
export type FieldChange = { field: string; before: unknown; after: unknown };
|
|
13
13
|
|
package/src/index.ts
CHANGED
|
@@ -95,6 +95,7 @@ export {
|
|
|
95
95
|
orphanAccountRule,
|
|
96
96
|
pastCloseDateRule,
|
|
97
97
|
patchOperationId,
|
|
98
|
+
provenanceSummary,
|
|
98
99
|
requiresHumanInput,
|
|
99
100
|
staleDealRule,
|
|
100
101
|
} from "./rules.ts";
|
|
@@ -128,6 +129,7 @@ export {
|
|
|
128
129
|
type Rubric,
|
|
129
130
|
type ScoredDimension,
|
|
130
131
|
} from "./llm.ts";
|
|
132
|
+
export { resolveRecord, type ResolveCandidate, type ResolveMatch, type ResolveResult } from "./resolve.ts";
|
|
131
133
|
export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
|
|
132
134
|
export type {
|
|
133
135
|
ApprovalStatus,
|
package/src/mcp.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { builtinAuditRules } from "./rules.ts";
|
|
|
48
48
|
import { sampleSnapshot } from "./sampleData.ts";
|
|
49
49
|
import { normalizeTranscript, parseCall } from "./calls.ts";
|
|
50
50
|
import { extractInsightsLlm, resolveLlmCredential } from "./llm.ts";
|
|
51
|
+
import { resolveRecord } from "./resolve.ts";
|
|
51
52
|
import { suggestValues } from "./suggest.ts";
|
|
52
53
|
import type { CanonicalGtmSnapshot, GtmConnector, PatchPlan } from "./types.ts";
|
|
53
54
|
|
|
@@ -239,6 +240,31 @@ export async function startMcpServer() {
|
|
|
239
240
|
},
|
|
240
241
|
);
|
|
241
242
|
|
|
243
|
+
server.registerTool(
|
|
244
|
+
"fullstackgtm_resolve",
|
|
245
|
+
{
|
|
246
|
+
title: "Resolve Record (create gate)",
|
|
247
|
+
description:
|
|
248
|
+
"Before creating a CRM record, check whether it already exists. Returns a verdict " +
|
|
249
|
+
"(exists | ambiguous | safe_to_create) with matches and a reason, using the same " +
|
|
250
|
+
"identity keys as the audit/merge engines (account domain, contact email, open-deal " +
|
|
251
|
+
"key). Read-only. Never create on 'exists' or 'ambiguous'.",
|
|
252
|
+
inputSchema: {
|
|
253
|
+
objectType: z.enum(["account", "contact", "deal"]),
|
|
254
|
+
name: z.string().optional(),
|
|
255
|
+
domain: z.string().optional(),
|
|
256
|
+
email: z.string().optional(),
|
|
257
|
+
accountId: z.string().optional(),
|
|
258
|
+
provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
|
|
259
|
+
inputPath: z.string().optional(),
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
async ({ objectType, name, domain, email, accountId, provider, inputPath }) => {
|
|
263
|
+
const snapshot = await readSnapshot(provider, inputPath);
|
|
264
|
+
return content(resolveRecord(snapshot, { objectType, name, domain, email, accountId }));
|
|
265
|
+
},
|
|
266
|
+
);
|
|
267
|
+
|
|
242
268
|
server.registerTool(
|
|
243
269
|
"fullstackgtm_rules",
|
|
244
270
|
{
|
package/src/merge.ts
CHANGED
|
@@ -52,7 +52,7 @@ export type MergeReport = {
|
|
|
52
52
|
};
|
|
53
53
|
|
|
54
54
|
const CONFLICT_IGNORED_FIELDS = new Set([
|
|
55
|
-
"id", "provider", "crmId", "identities", "raw", "lastSyncAt", "lastActivityAt", "ownerId", "accountId",
|
|
55
|
+
"id", "provider", "crmId", "identities", "raw", "lastSyncAt", "lastActivityAt", "ownerId", "accountId", "provenance",
|
|
56
56
|
]);
|
|
57
57
|
|
|
58
58
|
export function normalizeDomain(domain?: string): string | undefined {
|
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { normalizeDomain } from "./merge.ts";
|
|
2
|
+
import type { CanonicalGtmSnapshot } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The resolve gate — the Prevent layer of the CRM-health lifecycle.
|
|
6
|
+
*
|
|
7
|
+
* Before any writer (sync job, webhook handler, agent, this CLI's own
|
|
8
|
+
* `create:` path) creates a record, it should ask: does this record already
|
|
9
|
+
* exist? The gate answers deterministically from a snapshot using the same
|
|
10
|
+
* identity keys the audit and merge engines use:
|
|
11
|
+
*
|
|
12
|
+
* account → normalized domain (exact), then normalized name
|
|
13
|
+
* contact → normalized email (exact), then full name
|
|
14
|
+
* deal → open-deal key: (accountId | "unlinked") + normalized name
|
|
15
|
+
*
|
|
16
|
+
* Verdicts are gate-shaped: `exists` (link to it, don't create),
|
|
17
|
+
* `ambiguous` (a human must pick — do NOT blind-create), `safe_to_create`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type ResolveCandidate = {
|
|
21
|
+
objectType: "account" | "contact" | "deal";
|
|
22
|
+
name?: string;
|
|
23
|
+
domain?: string;
|
|
24
|
+
email?: string;
|
|
25
|
+
/** For deals: scope the duplicate key to an account. */
|
|
26
|
+
accountId?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ResolveMatch = {
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
matchedBy: "domain" | "email" | "name" | "deal_key";
|
|
33
|
+
detail: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type ResolveResult = {
|
|
37
|
+
objectType: ResolveCandidate["objectType"];
|
|
38
|
+
verdict: "exists" | "ambiguous" | "safe_to_create";
|
|
39
|
+
matches: ResolveMatch[];
|
|
40
|
+
reason: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function resolveRecord(
|
|
44
|
+
snapshot: CanonicalGtmSnapshot,
|
|
45
|
+
candidate: ResolveCandidate,
|
|
46
|
+
): ResolveResult {
|
|
47
|
+
if (candidate.objectType === "account") return resolveAccount(snapshot, candidate);
|
|
48
|
+
if (candidate.objectType === "contact") return resolveContact(snapshot, candidate);
|
|
49
|
+
return resolveDeal(snapshot, candidate);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveAccount(snapshot: CanonicalGtmSnapshot, c: ResolveCandidate): ResolveResult {
|
|
53
|
+
const base = { objectType: "account" as const };
|
|
54
|
+
const domain = normalizeDomain(c.domain);
|
|
55
|
+
if (domain) {
|
|
56
|
+
const matches = snapshot.accounts
|
|
57
|
+
.filter((a) => normalizeDomain(a.domain) === domain)
|
|
58
|
+
.map((a) => match(a.id, a.name, "domain", `account domain ${a.domain} normalizes to ${domain}`));
|
|
59
|
+
if (matches.length === 1) {
|
|
60
|
+
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.` };
|
|
61
|
+
}
|
|
62
|
+
if (matches.length > 1) {
|
|
63
|
+
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.` };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (c.name) {
|
|
67
|
+
const key = normalizeName(c.name);
|
|
68
|
+
const matches = snapshot.accounts
|
|
69
|
+
.filter((a) => normalizeName(a.name) === key)
|
|
70
|
+
.map((a) => match(a.id, a.name, "name", `account name matches "${c.name}" (domain: ${a.domain ?? "none"})`));
|
|
71
|
+
if (matches.length > 0) {
|
|
72
|
+
// Name alone is suggestive, not identity — two real companies can share
|
|
73
|
+
// a name (the merge engine treats this the same way).
|
|
74
|
+
return {
|
|
75
|
+
...base,
|
|
76
|
+
verdict: "ambiguous",
|
|
77
|
+
matches,
|
|
78
|
+
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.`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (!domain && !c.name) {
|
|
83
|
+
return { ...base, verdict: "ambiguous", matches: [], reason: "Supply --domain and/or --name to resolve an account." };
|
|
84
|
+
}
|
|
85
|
+
return { ...base, verdict: "safe_to_create", matches: [], reason: `No account matches ${domain ? `domain ${domain}` : `name "${c.name}"`}. Safe to create.` };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveContact(snapshot: CanonicalGtmSnapshot, c: ResolveCandidate): ResolveResult {
|
|
89
|
+
const base = { objectType: "contact" as const };
|
|
90
|
+
const email = c.email?.trim().toLowerCase();
|
|
91
|
+
if (email) {
|
|
92
|
+
const matches = snapshot.contacts
|
|
93
|
+
.filter((row) => row.email?.trim().toLowerCase() === email)
|
|
94
|
+
.map((row) => match(row.id, contactName(row), "email", `contact email matches ${email}`));
|
|
95
|
+
if (matches.length === 1) {
|
|
96
|
+
return { ...base, verdict: "exists", matches, reason: `A contact with email ${email} already exists: "${matches[0].name}" (${matches[0].id}). Update it instead of creating.` };
|
|
97
|
+
}
|
|
98
|
+
if (matches.length > 1) {
|
|
99
|
+
return { ...base, verdict: "ambiguous", matches, reason: `${matches.length} contacts already share ${email} — a duplicate group; merge before adding more. Do not create.` };
|
|
100
|
+
}
|
|
101
|
+
return { ...base, verdict: "safe_to_create", matches: [], reason: `No contact matches ${email}. Safe to create.` };
|
|
102
|
+
}
|
|
103
|
+
if (c.name) {
|
|
104
|
+
const key = normalizeName(c.name);
|
|
105
|
+
const matches = snapshot.contacts
|
|
106
|
+
.filter((row) => normalizeName(contactName(row)) === key)
|
|
107
|
+
.map((row) => match(row.id, contactName(row), "name", `contact name matches "${c.name}" (email: ${row.email ?? "none"})`));
|
|
108
|
+
if (matches.length > 0) {
|
|
109
|
+
return { ...base, verdict: "ambiguous", matches, reason: `${matches.length} contact(s) named "${c.name}" exist; names are not identity. Supply --email to resolve definitively.` };
|
|
110
|
+
}
|
|
111
|
+
return { ...base, verdict: "safe_to_create", matches: [], reason: `No contact named "${c.name}". Safe to create — but prefer resolving by email.` };
|
|
112
|
+
}
|
|
113
|
+
return { ...base, verdict: "ambiguous", matches: [], reason: "Supply --email (preferred) or --name to resolve a contact." };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function resolveDeal(snapshot: CanonicalGtmSnapshot, c: ResolveCandidate): ResolveResult {
|
|
117
|
+
const base = { objectType: "deal" as const };
|
|
118
|
+
if (!c.name) {
|
|
119
|
+
return { ...base, verdict: "ambiguous", matches: [], reason: "Supply --name (and ideally --account-id) to resolve a deal." };
|
|
120
|
+
}
|
|
121
|
+
const key = `${c.accountId ?? "unlinked"}:${normalizeName(c.name)}`;
|
|
122
|
+
const open = snapshot.deals.filter((d) => d.isClosed !== true && d.isWon !== true);
|
|
123
|
+
const matches = open
|
|
124
|
+
.filter((d) => `${d.accountId ?? "unlinked"}:${normalizeName(d.name)}` === key)
|
|
125
|
+
.map((d) => match(d.id, d.name, "deal_key", `open deal with the same name on ${c.accountId ? `account ${c.accountId}` : "no account"}`));
|
|
126
|
+
if (matches.length > 0) {
|
|
127
|
+
return {
|
|
128
|
+
...base,
|
|
129
|
+
verdict: "exists",
|
|
130
|
+
matches,
|
|
131
|
+
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.`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const closedSameName = snapshot.deals.filter(
|
|
135
|
+
(d) => (d.isClosed === true || d.isWon === true) && normalizeName(d.name) === normalizeName(c.name!),
|
|
136
|
+
);
|
|
137
|
+
return {
|
|
138
|
+
...base,
|
|
139
|
+
verdict: "safe_to_create",
|
|
140
|
+
matches: [],
|
|
141
|
+
reason: closedSameName.length > 0
|
|
142
|
+
? `No open deal matches; ${closedSameName.length} closed deal(s) share the name (a re-open/renewal may be intended). Safe to create.`
|
|
143
|
+
: `No open deal matches "${c.name}". Safe to create.`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function contactName(row: { firstName?: string; lastName?: string }) {
|
|
148
|
+
return [row.firstName, row.lastName].filter(Boolean).join(" ") || "(unnamed)";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function normalizeName(value: string) {
|
|
152
|
+
return value.trim().toLowerCase().replace(/\s+/g, " ");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function match(id: string, name: string, matchedBy: ResolveMatch["matchedBy"], detail: string): ResolveMatch {
|
|
156
|
+
return { id, name, matchedBy, detail };
|
|
157
|
+
}
|
package/src/rules.ts
CHANGED
|
@@ -21,6 +21,27 @@ export function requiresHumanInput(value: unknown): boolean {
|
|
|
21
21
|
return typeof value === "string" && value.startsWith(REQUIRES_HUMAN_PREFIX);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Attribution for duplicate groups: when the provider exposes record-source
|
|
26
|
+
* provenance (RecordProvenance), name the writer(s) that created the group —
|
|
27
|
+
* the fix for recurring dupes is upstream in the writer, not in the records.
|
|
28
|
+
*/
|
|
29
|
+
export function provenanceSummary(records: Array<{ provenance?: { source?: string; sourceLabel?: string; sourceId?: string } }>): string {
|
|
30
|
+
const counts = new Map<string, number>();
|
|
31
|
+
for (const record of records) {
|
|
32
|
+
const p = record.provenance;
|
|
33
|
+
if (!p) continue;
|
|
34
|
+
const label = p.sourceLabel ?? p.source ?? "unknown source";
|
|
35
|
+
const key = p.sourceId ? `${label} (${p.sourceId})` : label;
|
|
36
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
37
|
+
}
|
|
38
|
+
if (counts.size === 0) return "";
|
|
39
|
+
const parts = [...counts.entries()]
|
|
40
|
+
.sort((a, b) => b[1] - a[1])
|
|
41
|
+
.map(([key, count]) => (count > 1 ? `${key} ×${count}` : key));
|
|
42
|
+
return ` Created by: ${parts.join(", ")}.`;
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
export function auditFindingId(ruleId: string, objectId: string) {
|
|
25
46
|
return `finding_${stableHash(`${ruleId}:${objectId}`)}`;
|
|
26
47
|
}
|
|
@@ -343,7 +364,7 @@ export const duplicateAccountDomainRule: GtmAuditRule = {
|
|
|
343
364
|
ruleId: "duplicate-account-domain",
|
|
344
365
|
title: "Accounts share the same domain",
|
|
345
366
|
severity: "warning",
|
|
346
|
-
summary: `${accounts.length} accounts share ${domain}: ${accounts.map((account) => account.name).join(", ")}
|
|
367
|
+
summary: `${accounts.length} accounts share ${domain}: ${accounts.map((account) => account.name).join(", ")}.${provenanceSummary(accounts)}`,
|
|
347
368
|
recommendation: "Review the group and merge duplicates so activity and deals roll up once.",
|
|
348
369
|
});
|
|
349
370
|
operations.push({
|
|
@@ -383,7 +404,7 @@ export const duplicateContactEmailRule: GtmAuditRule = {
|
|
|
383
404
|
ruleId: "duplicate-contact-email",
|
|
384
405
|
title: "Contacts share the same email",
|
|
385
406
|
severity: "warning",
|
|
386
|
-
summary: `${contacts.length} contacts share ${email}
|
|
407
|
+
summary: `${contacts.length} contacts share ${email}.${provenanceSummary(contacts)}`,
|
|
387
408
|
recommendation: "Merge the duplicates so engagement history and routing stay coherent.",
|
|
388
409
|
});
|
|
389
410
|
operations.push({
|
|
@@ -433,7 +454,7 @@ export const duplicateOpenDealRule: GtmAuditRule = {
|
|
|
433
454
|
severity: "warning",
|
|
434
455
|
summary: `${deals.length} open deals named "${anchor.name}"${
|
|
435
456
|
anchor.accountId ? " on the same account" : ""
|
|
436
|
-
}: ${deals.map((deal) => deal.id).join(", ")}
|
|
457
|
+
}: ${deals.map((deal) => deal.id).join(", ")}.${provenanceSummary(deals)}`,
|
|
437
458
|
recommendation:
|
|
438
459
|
"Keep one deal, archive the copies, and fix the integration that is re-creating them.",
|
|
439
460
|
});
|
package/src/types.ts
CHANGED
|
@@ -141,11 +141,26 @@ export type CanonicalUser = {
|
|
|
141
141
|
active?: boolean;
|
|
142
142
|
};
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Who created a record, per the provider's read-only record-source fields
|
|
146
|
+
* (HubSpot: hs_object_source / _label / _id). Populated on read; used to
|
|
147
|
+
* attribute duplicate findings to the writer that produced them.
|
|
148
|
+
*/
|
|
149
|
+
export type RecordProvenance = {
|
|
150
|
+
/** Provider source code, e.g. INTEGRATION, API, CRM_UI, IMPORT, FORM. */
|
|
151
|
+
source?: string;
|
|
152
|
+
/** Human label, e.g. an integration's name. */
|
|
153
|
+
sourceLabel?: string;
|
|
154
|
+
/** Provider-side id of the source (e.g. app id, import id). */
|
|
155
|
+
sourceId?: string;
|
|
156
|
+
};
|
|
157
|
+
|
|
144
158
|
export type CanonicalAccount = {
|
|
145
159
|
id: string;
|
|
146
160
|
provider?: CrmProvider;
|
|
147
161
|
crmId?: string;
|
|
148
162
|
identities?: ProviderIdentity[];
|
|
163
|
+
provenance?: RecordProvenance;
|
|
149
164
|
name: string;
|
|
150
165
|
domain?: string;
|
|
151
166
|
industry?: string;
|
|
@@ -163,6 +178,7 @@ export type CanonicalContact = {
|
|
|
163
178
|
provider?: CrmProvider;
|
|
164
179
|
crmId?: string;
|
|
165
180
|
identities?: ProviderIdentity[];
|
|
181
|
+
provenance?: RecordProvenance;
|
|
166
182
|
accountId?: string;
|
|
167
183
|
firstName?: string;
|
|
168
184
|
lastName?: string;
|
|
@@ -181,6 +197,7 @@ export type CanonicalDeal = {
|
|
|
181
197
|
provider?: CrmProvider;
|
|
182
198
|
crmId?: string;
|
|
183
199
|
identities?: ProviderIdentity[];
|
|
200
|
+
provenance?: RecordProvenance;
|
|
184
201
|
accountId?: string;
|
|
185
202
|
ownerId?: string;
|
|
186
203
|
name: string;
|