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/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
|
|
@@ -490,6 +494,17 @@ function parseValueOverrides(args: string[]) {
|
|
|
490
494
|
|
|
491
495
|
async function callCommand(args: string[]) {
|
|
492
496
|
const [subcommand, ...rest] = args;
|
|
497
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
498
|
+
console.log(`call parse --transcript <file> [--title t] [--source s] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
|
|
499
|
+
call score --transcript <file>|--call <parsed.json> [--rubric <rubric.json>] [--model m] [--json|--out <path>]
|
|
500
|
+
call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
|
|
501
|
+
call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
|
|
502
|
+
|
|
503
|
+
parse/score default to LLM extraction (Anthropic or OpenAI key via env,
|
|
504
|
+
\`login anthropic|openai\`, or a one-time prompt). parse --deterministic is
|
|
505
|
+
the free keyword baseline; score always needs a key (scoring is LLM work).`);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
493
508
|
|
|
494
509
|
const loadParsedCall = async (): Promise<ParsedCall> => {
|
|
495
510
|
const callPath = option(rest, "--call");
|
|
@@ -535,6 +550,7 @@ async function callCommand(args: string[]) {
|
|
|
535
550
|
call_id: parsed.id,
|
|
536
551
|
call_title: parsed.title ?? null,
|
|
537
552
|
source_system: parsed.sourceSystem,
|
|
553
|
+
extractor: parsed.extractor,
|
|
538
554
|
type: insight.type,
|
|
539
555
|
title: insight.title,
|
|
540
556
|
text: insight.text,
|
|
@@ -609,11 +625,20 @@ async function callCommand(args: string[]) {
|
|
|
609
625
|
}
|
|
610
626
|
|
|
611
627
|
if (subcommand === "score") {
|
|
612
|
-
|
|
628
|
+
// Rubric problems surface before any credential or API work.
|
|
613
629
|
const rubricPath = option(rest, "--rubric");
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
630
|
+
let rubric = DEFAULT_RUBRIC;
|
|
631
|
+
if (rubricPath) {
|
|
632
|
+
const rubricRaw = readFileSync(resolve(process.cwd(), rubricPath), "utf8");
|
|
633
|
+
try {
|
|
634
|
+
rubric = parseRubric(rubricRaw);
|
|
635
|
+
} catch (error) {
|
|
636
|
+
throw new Error(
|
|
637
|
+
`${rubricPath} is not a valid rubric: ${error instanceof Error ? error.message : String(error)} Expected JSON like { "scale": 5, "dimensions": [{ "name": "...", "weight": 1, "rubric": "..." }] }.`,
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const credential = await requireLlmCredential("score");
|
|
617
642
|
const transcriptPath = option(rest, "--transcript");
|
|
618
643
|
let transcriptText: string;
|
|
619
644
|
let title = option(rest, "--title") ?? undefined;
|
|
@@ -651,12 +676,15 @@ async function callCommand(args: string[]) {
|
|
|
651
676
|
* TTY a missing key is captured once (validated, stored 0600 like provider
|
|
652
677
|
* logins). Non-interactive contexts get an actionable error instead.
|
|
653
678
|
*/
|
|
654
|
-
async function requireLlmCredential(): Promise<{ provider: LlmProvider; apiKey: string }> {
|
|
679
|
+
async function requireLlmCredential(command: "parse" | "score" = "parse"): Promise<{ provider: LlmProvider; apiKey: string }> {
|
|
655
680
|
const resolved = resolveLlmCredential();
|
|
656
681
|
if (resolved) return resolved;
|
|
682
|
+
// Scoring is inherently LLM work — there is no keyword fallback to suggest.
|
|
683
|
+
const fallbackHint =
|
|
684
|
+
command === "parse" ? ", or pass --deterministic for the free keyword baseline" : " (call score has no non-LLM mode)";
|
|
657
685
|
if (!process.stdin.isTTY) {
|
|
658
686
|
throw new Error(
|
|
659
|
-
|
|
687
|
+
`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}.`,
|
|
660
688
|
);
|
|
661
689
|
}
|
|
662
690
|
console.error("LLM parsing needs an API key (Anthropic or OpenAI) — yours, used directly with the provider.");
|
|
@@ -773,6 +801,37 @@ function buildCallPlan(
|
|
|
773
801
|
};
|
|
774
802
|
}
|
|
775
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
|
+
|
|
776
835
|
async function suggest(args: string[]) {
|
|
777
836
|
const planId = option(args, "--plan-id");
|
|
778
837
|
const planPath = option(args, "--plan");
|
|
@@ -1325,6 +1384,7 @@ async function login(args: string[]) {
|
|
|
1325
1384
|
return;
|
|
1326
1385
|
}
|
|
1327
1386
|
if (provider === "anthropic" || provider === "openai") {
|
|
1387
|
+
rejectArgvSecret(args, "--token", "--key", "--api-key");
|
|
1328
1388
|
const key = await readSecret(`${provider} API key (${provider === "anthropic" ? "sk-ant-..." : "sk-..."})`);
|
|
1329
1389
|
if (!key) throw new Error(`No ${provider} key provided.`);
|
|
1330
1390
|
if (!args.includes("--no-validate")) {
|
|
@@ -1601,6 +1661,10 @@ export async function runCli(argv: string[]) {
|
|
|
1601
1661
|
await callCommand(args);
|
|
1602
1662
|
return;
|
|
1603
1663
|
}
|
|
1664
|
+
if (command === "resolve") {
|
|
1665
|
+
await resolveCommand(args);
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1604
1668
|
if (command === "profiles") {
|
|
1605
1669
|
profilesCommand(args);
|
|
1606
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
|
+
}
|