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/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
- - A standalone **`resolve` gate** (planned, 0.13): given a candidate
45
- record, return existing match(es) or "safe to create" — for the CLI, the
46
- library, MCP, and any external writer (sync jobs, agents, webhook
47
- handlers). Identity keys are the ones the package already uses:
48
- contact email, normalized account domain, and the open-deal key
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** (planned, 0.13): capture HubSpot's read-only
65
- `hs_object_source`, `hs_object_source_label`, `hs_object_source_id` into
66
- the canonical model so duplicate findings can say *"all five created by
67
- integration X"*. The fix for recurring dupes is upstream, in the writer.
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.13 | `resolve` gate (CLI/lib/MCP), provenance capture + attribution in findings, prevention-posture checks |
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.14.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
- const credential = await requireLlmCredential();
628
+ // Rubric problems surface before any credential or API work.
613
629
  const rubricPath = option(rest, "--rubric");
614
- const rubric = rubricPath
615
- ? parseRubric(readFileSync(resolve(process.cwd(), rubricPath), "utf8"))
616
- : DEFAULT_RUBRIC;
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
- "LLM extraction needs an API key. Set ANTHROPIC_API_KEY or OPENAI_API_KEY, run `echo \"$KEY\" | fullstackgtm login anthropic` (or `login openai`) once, or pass --deterministic for the free keyword baseline.",
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
- const companyProperties = mappedFields(
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
- const created = await request(`/crm/v3/objects/companies`, {
423
- method: "POST",
424
- body: JSON.stringify({ properties: { name } }),
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
+ }