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 CHANGED
@@ -5,6 +5,48 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
  The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
7
7
 
8
+ ## [0.15.0] — 2026-06-11
9
+
10
+ The Prevent layer: stop creating duplicates, and name the writer that does.
11
+
12
+ ### Added
13
+
14
+ - **`fullstackgtm resolve <account|contact|deal>`** — the create gate.
15
+ Deterministic verdicts (exists / ambiguous / safe_to_create) with matches
16
+ and reasons, using the same identity keys as the audit/merge engines:
17
+ normalized account domain, contact email, open-deal key. Names alone are
18
+ never identity (ambiguous, with candidates). Gate-shaped exit codes for
19
+ scripts: 0 = safe to create, 2 = match found, 1 = error. Exposed as
20
+ `resolveRecord()` and MCP `fullstackgtm_resolve`.
21
+ - **Record provenance** (`RecordProvenance` on accounts/contacts/deals):
22
+ HubSpot snapshots capture the read-only `hs_object_source`,
23
+ `hs_object_source_label`, `hs_object_source_id` fields, and the three
24
+ duplicate rules append writer attribution to findings — "Created by:
25
+ Gojiberry (app-123) ×2, CRM_UI" — so recurring dupes are fixed at the
26
+ faucet. Provenance is exempt from merge conflicts and diff drift, and
27
+ records created by the CLI's own `create:` path stamp
28
+ `hs_object_source_detail_2` (best-effort).
29
+
30
+ ## [0.14.1] — 2026-06-11
31
+
32
+ Fixes from the 0.14.0 journey verification (4 agents, 21 checks).
33
+
34
+ ### Fixed
35
+
36
+ - **`call score` no longer suggests a flag it rejects**: the keyless error
37
+ said "pass --deterministic" but score has no non-LLM mode — its error now
38
+ says exactly that. Rubric files are also validated *before* the
39
+ credential check (keyless users see rubric errors), and a non-JSON rubric
40
+ names the file and the expected shape instead of a raw parse error.
41
+ - **NDJSON rows carry `extractor`** — LLM and deterministic rows were
42
+ per-row indistinguishable in warehouse loads, contradicting the
43
+ provenance claim. (Docs wording was right at the parse-result and
44
+ evidence level; now it is true per-row too.)
45
+ - **`call … --help` prints help** instead of being shadowed by argument
46
+ and credential checks.
47
+ - `login anthropic|openai` gets the same explicit argv-secret rejection
48
+ (`--token`/`--key`/`--api-key`) as the CRM providers.
49
+
8
50
  ## [0.14.0] — 2026-06-11
9
51
 
10
52
  LLM-powered call intelligence, bring-your-own-key. The dry-run replications
package/README.md CHANGED
@@ -74,6 +74,20 @@ for t in transcripts/*; do fullstackgtm call parse --transcript "$t" --ndjson --
74
74
 
75
75
  The boundary that remains: Slack/Notion/warehouse sinks are *your* pipeline, composed around the JSON — and your rubrics and keys stay yours.
76
76
 
77
+ ## The create gate: no new dupes
78
+
79
+ Detection cleans up yesterday's duplicates; the **resolve gate** prevents tomorrow's. Before any writer — a sync job, a webhook handler, an agent, your own script — creates a record, ask the gate:
80
+
81
+ ```bash
82
+ fullstackgtm resolve contact --email jane@acme.com --input snap.json # exit 0 = safe to create
83
+ fullstackgtm resolve account --domain acme.com --provider hubspot # exit 2 = exists/ambiguous: do NOT create
84
+ fullstackgtm resolve deal --name "Acme Expansion" --account-id 123 --input snap.json
85
+ ```
86
+
87
+ Identity keys match the audit/merge engines exactly: account domain (normalized), contact email, and the open-deal key (account + normalized name). Names alone are never identity — they return `ambiguous` with the candidates, not a guess. Exit codes are gate-shaped for scripts: `0` safe to create, `2` match found, `1` error. For high-volume writers, pair it with a cron-refreshed snapshot file rather than a live `--provider` fetch per call. Also exposed as `resolveRecord()` and the MCP tool `fullstackgtm_resolve`.
88
+
89
+ **Provenance attribution** closes the loop on recurring dupes: snapshots now capture each record's source (HubSpot's read-only `hs_object_source*` fields), and duplicate findings name the writer — `"3 accounts share acme.com … Created by: Gojiberry (app-123) ×2, CRM_UI"` — so you fix the integration, not just the records. Records created by this CLI stamp their own provenance (`hs_object_source_detail_2`, best-effort).
90
+
77
91
  ## From findings to fixes: the suggest chain
78
92
 
79
93
  Most placeholder answers are already derivable from your own CRM data. `suggest` computes them deterministically — account-name matching cross-checked against contact associations — with a confidence level and a written reason per operation, so you (or an agent) approve evidence, not guesses:
package/dist/cli.js CHANGED
@@ -19,6 +19,7 @@ import { builtinAuditRules } from "./rules.js";
19
19
  import { sampleSnapshot } from "./sampleData.js";
20
20
  import { normalizeTranscript, parseCall, suggestCallDeal } from "./calls.js";
21
21
  import { DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
22
+ import { resolveRecord } from "./resolve.js";
22
23
  import { suggestValues } from "./suggest.js";
23
24
  function usage() {
24
25
  return `FullStackGTM — audit GTM data across providers, propose reviewable patch plans,
@@ -51,6 +52,9 @@ Usage:
51
52
  ANTHROPIC_API_KEY/OPENAI_API_KEY, or \`login anthropic|openai\`);
52
53
  --deterministic uses the free keyword baseline. Then link the call
53
54
  to its deal and propose governed next-step writes.
55
+ fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]
56
+ the create gate: exit 0 = safe to create, exit 2 = match
57
+ found (exists/ambiguous) — call before ANY record creation
54
58
  fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
55
59
  derive values for requires_human_* placeholders
56
60
  from snapshot evidence, with confidence + reasons
@@ -418,6 +422,17 @@ function parseValueOverrides(args) {
418
422
  }
419
423
  async function callCommand(args) {
420
424
  const [subcommand, ...rest] = args;
425
+ if (args.includes("--help") || args.includes("-h")) {
426
+ console.log(`call parse --transcript <file> [--title t] [--source s] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
427
+ call score --transcript <file>|--call <parsed.json> [--rubric <rubric.json>] [--model m] [--json|--out <path>]
428
+ call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
429
+ call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
430
+
431
+ parse/score default to LLM extraction (Anthropic or OpenAI key via env,
432
+ \`login anthropic|openai\`, or a one-time prompt). parse --deterministic is
433
+ the free keyword baseline; score always needs a key (scoring is LLM work).`);
434
+ return;
435
+ }
421
436
  const loadParsedCall = async () => {
422
437
  const callPath = option(rest, "--call");
423
438
  if (callPath) {
@@ -462,6 +477,7 @@ async function callCommand(args) {
462
477
  call_id: parsed.id,
463
478
  call_title: parsed.title ?? null,
464
479
  source_system: parsed.sourceSystem,
480
+ extractor: parsed.extractor,
465
481
  type: insight.type,
466
482
  title: insight.title,
467
483
  text: insight.text,
@@ -534,11 +550,19 @@ async function callCommand(args) {
534
550
  return;
535
551
  }
536
552
  if (subcommand === "score") {
537
- const credential = await requireLlmCredential();
553
+ // Rubric problems surface before any credential or API work.
538
554
  const rubricPath = option(rest, "--rubric");
539
- const rubric = rubricPath
540
- ? parseRubric(readFileSync(resolve(process.cwd(), rubricPath), "utf8"))
541
- : DEFAULT_RUBRIC;
555
+ let rubric = DEFAULT_RUBRIC;
556
+ if (rubricPath) {
557
+ const rubricRaw = readFileSync(resolve(process.cwd(), rubricPath), "utf8");
558
+ try {
559
+ rubric = parseRubric(rubricRaw);
560
+ }
561
+ catch (error) {
562
+ throw new Error(`${rubricPath} is not a valid rubric: ${error instanceof Error ? error.message : String(error)} Expected JSON like { "scale": 5, "dimensions": [{ "name": "...", "weight": 1, "rubric": "..." }] }.`);
563
+ }
564
+ }
565
+ const credential = await requireLlmCredential("score");
542
566
  const transcriptPath = option(rest, "--transcript");
543
567
  let transcriptText;
544
568
  let title = option(rest, "--title") ?? undefined;
@@ -577,12 +601,14 @@ async function callCommand(args) {
577
601
  * TTY a missing key is captured once (validated, stored 0600 like provider
578
602
  * logins). Non-interactive contexts get an actionable error instead.
579
603
  */
580
- async function requireLlmCredential() {
604
+ async function requireLlmCredential(command = "parse") {
581
605
  const resolved = resolveLlmCredential();
582
606
  if (resolved)
583
607
  return resolved;
608
+ // Scoring is inherently LLM work — there is no keyword fallback to suggest.
609
+ const fallbackHint = command === "parse" ? ", or pass --deterministic for the free keyword baseline" : " (call score has no non-LLM mode)";
584
610
  if (!process.stdin.isTTY) {
585
- throw new Error("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.");
611
+ throw new Error(`LLM ${command === "score" ? "scoring" : "extraction"} needs an API key. Set ANTHROPIC_API_KEY or OPENAI_API_KEY, or run \`echo "$KEY" | fullstackgtm login anthropic\` (or \`login openai\`) once${fallbackHint}.`);
586
612
  }
587
613
  console.error("LLM parsing needs an API key (Anthropic or OpenAI) — yours, used directly with the provider.");
588
614
  console.error(`Paste it once; it is validated and stored at ${credentialsPath()} (file mode 0600), like CRM logins.`);
@@ -689,6 +715,38 @@ function buildCallPlan(parsed, deal, proposed, current, extraNextSteps) {
689
715
  operations,
690
716
  };
691
717
  }
718
+ /**
719
+ * The resolve gate: exit 0 = safe to create, exit 2 = match found (exists or
720
+ * ambiguous — do NOT blind-create), exit 1 = error. Built for sync jobs and
721
+ * webhook handlers to call before any record creation.
722
+ */
723
+ async function resolveCommand(args) {
724
+ const [objectType, ...rest] = args;
725
+ if (!objectType || !["account", "contact", "deal"].includes(objectType)) {
726
+ throw new Error("Usage: fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]");
727
+ }
728
+ const candidate = {
729
+ objectType: objectType,
730
+ name: option(rest, "--name") ?? undefined,
731
+ domain: option(rest, "--domain") ?? undefined,
732
+ email: option(rest, "--email") ?? undefined,
733
+ accountId: option(rest, "--account-id") ?? undefined,
734
+ };
735
+ const snapshot = await readSnapshot(rest);
736
+ const result = resolveRecord(snapshot, candidate);
737
+ if (rest.includes("--json")) {
738
+ console.log(JSON.stringify(result, null, 2));
739
+ }
740
+ else {
741
+ const marker = result.verdict === "safe_to_create" ? "✓" : result.verdict === "exists" ? "=" : "?";
742
+ console.log(`${marker} [${result.verdict}] ${result.reason}`);
743
+ for (const m of result.matches) {
744
+ console.log(` ${m.id} "${m.name}" — matched by ${m.matchedBy}: ${m.detail}`);
745
+ }
746
+ }
747
+ if (result.verdict !== "safe_to_create")
748
+ process.exitCode = 2;
749
+ }
692
750
  async function suggest(args) {
693
751
  const planId = option(args, "--plan-id");
694
752
  const planPath = option(args, "--plan");
@@ -1177,6 +1235,7 @@ async function login(args) {
1177
1235
  return;
1178
1236
  }
1179
1237
  if (provider === "anthropic" || provider === "openai") {
1238
+ rejectArgvSecret(args, "--token", "--key", "--api-key");
1180
1239
  const key = await readSecret(`${provider} API key (${provider === "anthropic" ? "sk-ant-..." : "sk-..."})`);
1181
1240
  if (!key)
1182
1241
  throw new Error(`No ${provider} key provided.`);
@@ -1433,6 +1492,10 @@ export async function runCli(argv) {
1433
1492
  await callCommand(args);
1434
1493
  return;
1435
1494
  }
1495
+ if (command === "resolve") {
1496
+ await resolveCommand(args);
1497
+ return;
1498
+ }
1436
1499
  if (command === "profiles") {
1437
1500
  profilesCommand(args);
1438
1501
  return;
@@ -84,7 +84,10 @@ export function createHubspotConnector(options) {
84
84
  email: stringOrUndefined(owner.email),
85
85
  active: owner.archived !== true,
86
86
  }));
87
- const companyProperties = mappedFields(mappings, "accounts", HUBSPOT_DEFAULT_FIELD_MAPPINGS.accounts).join(",");
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
- const created = await request(`/crm/v3/objects/companies`, {
327
- method: "POST",
328
- body: JSON.stringify({ properties: { name } }),
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;
@@ -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({