fullstackgtm 0.14.1 → 0.16.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.
@@ -0,0 +1,272 @@
1
+ import type {
2
+ ClaimFront,
3
+ FrontState,
4
+ MarketConfig,
5
+ MarketObservation,
6
+ ObservationSet,
7
+ } from "./market.ts";
8
+ import { computeFrontStates } from "./market.ts";
9
+
10
+ /**
11
+ * Render a market map as a client-ready deliverable: markdown for terminals
12
+ * and PRs, and a self-contained printable HTML "field report" — front
13
+ * summary, claim × vendor intensity matrix, and a verbatim-evidence
14
+ * appendix. Deterministic: same observation set, same bytes. No webfonts,
15
+ * no CDNs — the artifact must stand alone wherever it's sent.
16
+ */
17
+
18
+ const FRONT_ORDER: Record<FrontState, number> = {
19
+ open: 0,
20
+ contested: 1,
21
+ owned: 2,
22
+ saturated: 3,
23
+ vacant: 4,
24
+ };
25
+
26
+ const GLYPH: Record<string, string> = {
27
+ loud: "■",
28
+ quiet: "□",
29
+ absent: "·",
30
+ unobservable: "▨",
31
+ };
32
+
33
+ function escapeHtml(value: string): string {
34
+ return value
35
+ .replace(/&/g, "&amp;")
36
+ .replace(/</g, "&lt;")
37
+ .replace(/>/g, "&gt;")
38
+ .replace(/"/g, "&quot;");
39
+ }
40
+
41
+ type MapModel = {
42
+ config: MarketConfig;
43
+ set: ObservationSet;
44
+ fronts: ClaimFront[];
45
+ /** claims sorted open-first, then by id — the report's reading order. */
46
+ orderedClaimIds: string[];
47
+ cell: (vendorId: string, claimId: string) => MarketObservation | undefined;
48
+ };
49
+
50
+ function buildModel(config: MarketConfig, set: ObservationSet): MapModel {
51
+ const fronts = computeFrontStates(config, set);
52
+ const stateByClaim = new Map(fronts.map((front) => [front.claimId, front.state]));
53
+ const orderedClaimIds = config.claims
54
+ .map((claim) => claim.id)
55
+ .sort((a, b) => {
56
+ const byFront = FRONT_ORDER[stateByClaim.get(a) ?? "vacant"] - FRONT_ORDER[stateByClaim.get(b) ?? "vacant"];
57
+ return byFront !== 0 ? byFront : a.localeCompare(b);
58
+ });
59
+ const byCell = new Map(set.observations.map((obs) => [`${obs.vendorId}|${obs.claimId}`, obs]));
60
+ return {
61
+ config,
62
+ set,
63
+ fronts,
64
+ orderedClaimIds,
65
+ cell: (vendorId, claimId) => byCell.get(`${vendorId}|${claimId}`),
66
+ };
67
+ }
68
+
69
+ export function marketMapToMarkdown(config: MarketConfig, set: ObservationSet): string {
70
+ const model = buildModel(config, set);
71
+ const stateByClaim = new Map(model.fronts.map((front) => [front.claimId, front.state]));
72
+ const counts: Record<FrontState, number> = { open: 0, contested: 0, owned: 0, saturated: 0, vacant: 0 };
73
+ for (const front of model.fronts) counts[front.state] += 1;
74
+
75
+ const lines: string[] = [];
76
+ lines.push(`# Market map — ${config.category} (${set.runLabel})`);
77
+ lines.push("");
78
+ lines.push(
79
+ `Observed ${set.runAt} · ${config.vendors.length} vendors · ${config.claims.length} claims · ` +
80
+ `${set.observations.length} readings · extractor ${set.extractor}`,
81
+ );
82
+ lines.push("");
83
+ lines.push(
84
+ `Fronts: ${counts.open + counts.vacant} open/vacant · ${counts.contested} contested · ` +
85
+ `${counts.owned} owned · ${counts.saturated} saturated`,
86
+ );
87
+ lines.push("");
88
+ lines.push(`Legend: ■ loud · □ quiet · · absent · ▨ unobservable`);
89
+ lines.push("");
90
+ const header = ["claim", ...config.vendors.map((v) => v.id), "front"];
91
+ lines.push(`| ${header.join(" | ")} |`);
92
+ lines.push(`| ${header.map(() => "---").join(" | ")} |`);
93
+ for (const claimId of model.orderedClaimIds) {
94
+ const cells = config.vendors.map((vendor) => GLYPH[model.cell(vendor.id, claimId)?.intensity ?? "unobservable"]);
95
+ lines.push(`| ${claimId} | ${cells.join(" | ")} | ${stateByClaim.get(claimId)?.toUpperCase()} |`);
96
+ }
97
+ lines.push("");
98
+ for (const front of model.fronts.filter((f) => f.state === "owned")) {
99
+ lines.push(`- OWNED: ${front.claimId} → ${front.loudVendorIds[0]}`);
100
+ }
101
+ for (const front of model.fronts.filter((f) => f.state === "open" || f.state === "vacant")) {
102
+ lines.push(`- ${front.state.toUpperCase()}: ${front.claimId} — no vendor is loud here`);
103
+ }
104
+ return `${lines.join("\n")}\n`;
105
+ }
106
+
107
+ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): string {
108
+ const model = buildModel(config, set);
109
+ const stateByClaim = new Map(model.fronts.map((front) => [front.claimId, front.state]));
110
+ const claimsById = new Map(config.claims.map((claim) => [claim.id, claim]));
111
+ const counts: Record<FrontState, number> = { open: 0, contested: 0, owned: 0, saturated: 0, vacant: 0 };
112
+ for (const front of model.fronts) counts[front.state] += 1;
113
+ const unobservable = set.observations.filter((obs) => obs.intensity === "unobservable").length;
114
+ const anchor = config.anchorVendor;
115
+ const e = escapeHtml;
116
+
117
+ const matrixRows = model.orderedClaimIds
118
+ .map((claimId) => {
119
+ const claim = claimsById.get(claimId);
120
+ if (!claim) return "";
121
+ const state = stateByClaim.get(claimId) ?? "vacant";
122
+ const cells = config.vendors
123
+ .map((vendor) => {
124
+ const intensity = model.cell(vendor.id, claimId)?.intensity ?? "unobservable";
125
+ const anchorClass = vendor.id === anchor ? " anchor-col" : "";
126
+ return `<td class="cell${anchorClass}"><span class="g g-${intensity}" title="${e(vendor.name)}: ${intensity}"></span></td>`;
127
+ })
128
+ .join("");
129
+ return (
130
+ `<tr class="front-${state}"><th scope="row"><span class="claim-cap">${e(claim.capability.split(":")[0])}</span>` +
131
+ `<span class="claim-meta">${e(claim.icp)} · ${e(claim.pricingStructure)}</span></th>${cells}` +
132
+ `<td class="front"><span class="chip chip-${state}">${state.toUpperCase()}</span></td></tr>`
133
+ );
134
+ })
135
+ .join("");
136
+
137
+ const openList = model.orderedClaimIds
138
+ .filter((claimId) => {
139
+ const state = stateByClaim.get(claimId);
140
+ return state === "open" || state === "vacant";
141
+ })
142
+ .map((claimId) => {
143
+ const claim = claimsById.get(claimId);
144
+ return `<li><b>${e(claim?.capability.split(":")[0] ?? claimId)}</b> <span class="why">— no vendor is loud here; ${e(claim?.icp ?? "")} cell</span></li>`;
145
+ })
146
+ .join("");
147
+
148
+ const appendix = model.orderedClaimIds
149
+ .flatMap((claimId) =>
150
+ config.vendors.flatMap((vendor) => {
151
+ const obs = model.cell(vendor.id, claimId);
152
+ if (!obs || obs.evidence.length === 0) return [];
153
+ return obs.evidence.map(
154
+ (evidence) =>
155
+ `<div class="ev"><span class="ev-head">${e(vendor.name)} · ${e(claimId)} · ${obs.intensity.toUpperCase()} (${obs.confidence})</span>` +
156
+ `<blockquote>“${e(evidence.text)}”</blockquote>` +
157
+ `<span class="ev-src">${e(String(evidence.metadata?.url ?? ""))} · capture ${e(String(evidence.metadata?.captureHash ?? "").slice(0, 12))}</span></div>`,
158
+ );
159
+ }),
160
+ )
161
+ .join("");
162
+
163
+ const vendorHeads = config.vendors
164
+ .map(
165
+ (vendor) =>
166
+ `<th class="vh${vendor.id === anchor ? " anchor-col" : ""}"><span>${e(vendor.name)}</span></th>`,
167
+ )
168
+ .join("");
169
+
170
+ return `<!doctype html>
171
+ <html lang="en"><head><meta charset="utf-8">
172
+ <meta name="viewport" content="width=device-width, initial-scale=1">
173
+ <title>Market map — ${e(config.category)} — ${e(set.runLabel)}</title>
174
+ <style>
175
+ :root { --paper:#f4efe4; --ink:#211d16; --ink-soft:#5a5244; --line:#c9bfa9; --accent:#b4441b; --green:#2e5339; --quiet:#8a7d63; }
176
+ * { box-sizing:border-box; margin:0; }
177
+ body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background:var(--paper);
178
+ max-width:1080px; margin:0 auto; padding:0 48px 96px;
179
+ background-image:radial-gradient(rgba(33,29,22,.028) 1px, transparent 1.2px); background-size:5px 5px; }
180
+ .chip,.claim-meta,.ev-src,.lg,.stamp,.meta,th.vh span { font-family:"SF Mono",Menlo,Consolas,monospace; }
181
+ header { padding:56px 0 28px; border-bottom:3px double var(--ink); position:relative; }
182
+ .kicker { font-size:11px; letter-spacing:.32em; color:var(--accent); text-transform:uppercase; }
183
+ h1 { font-size:44px; line-height:1.05; font-weight:600; margin:10px 0 6px; }
184
+ h1 em { font-style:italic; color:var(--green); }
185
+ .meta { font-size:11.5px; color:var(--ink-soft); display:flex; gap:24px; flex-wrap:wrap; margin-top:14px; }
186
+ .stamp { position:absolute; right:0; top:58px; border:2px solid var(--accent); color:var(--accent); padding:7px 13px;
187
+ font-size:11px; letter-spacing:.22em; transform:rotate(3.5deg); text-transform:uppercase; }
188
+ section { margin-top:56px; }
189
+ h2 { font-size:13px; letter-spacing:.26em; text-transform:uppercase; color:var(--ink-soft);
190
+ border-bottom:1px solid var(--line); padding-bottom:9px; display:flex; gap:14px; align-items:baseline; }
191
+ h2 .no { color:var(--accent); font-style:italic; font-size:15px; letter-spacing:0; }
192
+ .fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:22px; }
193
+ .fcard { background:var(--paper); padding:18px 18px 14px; }
194
+ .fcard b { display:block; font-size:42px; font-weight:600; line-height:1; }
195
+ .fcard span { font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:var(--ink-soft); }
196
+ .fcard.open b { color:var(--accent); }
197
+ .openlist { margin-top:18px; font-size:15.5px; line-height:1.55; }
198
+ .openlist li { margin:4px 0 4px 20px; }
199
+ .openlist .why { color:var(--ink-soft); font-size:13px; font-style:italic; }
200
+ .legend { display:flex; gap:22px; flex-wrap:wrap; margin:18px 0 10px; font-size:10.5px; color:var(--ink-soft); }
201
+ .lg { display:inline-flex; align-items:center; gap:7px; }
202
+ table { border-collapse:collapse; width:100%; margin-top:6px; }
203
+ thead th { border-bottom:2px solid var(--ink); padding:6px 2px 10px; }
204
+ th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px; letter-spacing:.12em;
205
+ text-transform:uppercase; color:var(--ink-soft); display:inline-block; }
206
+ th.vh.anchor-col span { color:var(--green); font-weight:700; }
207
+ tbody th { text-align:left; font-weight:400; padding:7px 14px 7px 0; border-bottom:1px solid var(--line); max-width:330px; }
208
+ .claim-cap { display:block; font-size:14.5px; }
209
+ .claim-meta { display:block; font-size:9.5px; color:var(--quiet); letter-spacing:.08em; margin-top:2px; }
210
+ td.cell { text-align:center; border-bottom:1px solid var(--line); padding:4px 2px; }
211
+ td.cell.anchor-col { background:rgba(46,83,57,.06); }
212
+ td.front { border-bottom:1px solid var(--line); text-align:right; white-space:nowrap; }
213
+ .g { display:inline-block; width:15px; height:15px; vertical-align:middle; }
214
+ .g-loud { background:var(--ink); }
215
+ .g-quiet { box-shadow:inset 0 0 0 2px var(--quiet); }
216
+ .g-absent { background:radial-gradient(circle at center, var(--line) 0 2.5px, transparent 3px); }
217
+ .g-unobservable { background:repeating-linear-gradient(45deg, var(--line) 0 2px, transparent 2px 5px); }
218
+ tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
219
+ .chip { font-size:9px; letter-spacing:.16em; padding:3px 8px; border:1px solid currentColor; }
220
+ .chip-open { color:var(--accent); } .chip-contested { color:#7a5a12; }
221
+ .chip-owned { color:var(--green); } .chip-saturated { color:var(--ink-soft); } .chip-vacant { color:var(--quiet); }
222
+ .ev { border-bottom:1px solid var(--line); padding:12px 0; }
223
+ .ev-head { font-size:10.5px; letter-spacing:.1em; color:var(--accent); }
224
+ .ev blockquote { font-style:italic; margin:6px 0; font-size:13.5px; line-height:1.5; }
225
+ .ev-src { font-size:10px; color:var(--ink-soft); word-break:break-all; }
226
+ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; font-size:11px; color:var(--ink-soft);
227
+ display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
228
+ @media print { body { max-width:none; padding:0 8mm; background:white; } section { break-inside:avoid-page; } tr { break-inside:avoid; } }
229
+ </style></head><body>
230
+ <header>
231
+ <div class="kicker">Full Stack GTM · Market Map</div>
232
+ <h1>The <em>${e(config.category.replace(/-/g, " "))}</em> front map</h1>
233
+ <div class="meta">
234
+ <span>RUN ${e(set.runLabel.toUpperCase())}</span><span>OBSERVED ${e(set.runAt)}</span>
235
+ <span>${config.vendors.length} VENDORS · ${config.claims.length} CLAIMS · ${set.observations.length} READINGS</span>
236
+ <span>${unobservable} UNOBSERVABLE · EXTRACTOR ${e(set.extractor)}</span>
237
+ </div>
238
+ <div class="stamp">Field Report</div>
239
+ </header>
240
+ <section>
241
+ <h2><span class="no">01</span> Front summary</h2>
242
+ <div class="fronts">
243
+ <div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
244
+ <div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
245
+ <div class="fcard"><b>${counts.owned}</b><span>Owned</span></div>
246
+ <div class="fcard"><b>${counts.saturated}</b><span>Saturated</span></div>
247
+ </div>
248
+ <ul class="openlist">${openList}</ul>
249
+ </section>
250
+ <section>
251
+ <h2><span class="no">02</span> Claim × vendor intensity matrix</h2>
252
+ <div class="legend">
253
+ <span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
254
+ <span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
255
+ <span class="lg"><i class="g g-absent"></i>ABSENT</span>
256
+ <span class="lg"><i class="g g-unobservable"></i>UNOBSERVABLE — capture failed</span>
257
+ </div>
258
+ <table>
259
+ <thead><tr><th></th>${vendorHeads}<th></th></tr></thead>
260
+ <tbody>${matrixRows}</tbody>
261
+ </table>
262
+ </section>
263
+ <section>
264
+ <h2><span class="no">03</span> Evidence appendix</h2>
265
+ ${appendix}
266
+ </section>
267
+ <footer>
268
+ <span>Generated by fullstackgtm market · deterministic render of ${e(set.runLabel)}</span>
269
+ <span>Front rule v1: 0 loud=open · 1=owned · 2–3=contested · ≥4=saturated</span>
270
+ </footer>
271
+ </body></html>`;
272
+ }
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,177 @@
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 nameKey = normalizeName(c.name);
122
+ const open = snapshot.deals.filter((d) => d.isClosed !== true && d.isWon !== true);
123
+ if (c.accountId) {
124
+ const key = `${c.accountId}:${nameKey}`;
125
+ const matches = open
126
+ .filter((d) => `${d.accountId ?? "unlinked"}:${normalizeName(d.name)}` === key)
127
+ .map((d) => match(d.id, d.name, "deal_key", `open deal with the same name on account ${c.accountId}`));
128
+ if (matches.length > 0) {
129
+ return {
130
+ ...base,
131
+ verdict: "exists",
132
+ matches,
133
+ reason: `${matches.length} open deal(s) already match "${c.name}" on account ${c.accountId} — creating another would double-count pipeline. Update the existing deal.`,
134
+ };
135
+ }
136
+ const closedSameName = snapshot.deals.filter(
137
+ (d) =>
138
+ (d.isClosed === true || d.isWon === true) &&
139
+ d.accountId === c.accountId &&
140
+ normalizeName(d.name) === nameKey,
141
+ );
142
+ return {
143
+ ...base,
144
+ verdict: "safe_to_create",
145
+ matches: [],
146
+ reason: closedSameName.length > 0
147
+ ? `No open deal matches on account ${c.accountId}; ${closedSameName.length} closed deal(s) on it share the name (a re-open/renewal may be intended). Safe to create.`
148
+ : `No open deal matches "${c.name}" on account ${c.accountId}. Safe to create.`,
149
+ };
150
+ }
151
+ // No account scope: name-only matches across ALL open deals are ambiguous,
152
+ // never safe — a gate that ignores name collisions protects nobody.
153
+ const sameName = open
154
+ .filter((d) => normalizeName(d.name) === nameKey)
155
+ .map((d) => match(d.id, d.name, "name", `open deal with the same name on ${d.accountId ? `account ${d.accountId}` : "no account"}`));
156
+ if (sameName.length > 0) {
157
+ return {
158
+ ...base,
159
+ verdict: "ambiguous",
160
+ matches: sameName,
161
+ reason: `${sameName.length} open deal(s) named "${c.name}" exist (no --account-id supplied to scope the check). Confirm before creating — supply --account-id to resolve definitively.`,
162
+ };
163
+ }
164
+ return { ...base, verdict: "safe_to_create", matches: [], reason: `No open deal named "${c.name}" anywhere. Safe to create.` };
165
+ }
166
+
167
+ function contactName(row: { firstName?: string; lastName?: string }) {
168
+ return [row.firstName, row.lastName].filter(Boolean).join(" ") || "(unnamed)";
169
+ }
170
+
171
+ function normalizeName(value: string) {
172
+ return value.trim().toLowerCase().replace(/\s+/g, " ");
173
+ }
174
+
175
+ function match(id: string, name: string, matchedBy: ResolveMatch["matchedBy"], detail: string): ResolveMatch {
176
+ return { id, name, matchedBy, detail };
177
+ }
package/src/rules.ts CHANGED
@@ -21,6 +21,27 @@ export function requiresHumanInput(value: unknown): boolean {
21
21
  return typeof value === "string" && value.startsWith(REQUIRES_HUMAN_PREFIX);
22
22
  }
23
23
 
24
+ /**
25
+ * Attribution for duplicate groups: when the provider exposes record-source
26
+ * provenance (RecordProvenance), name the writer(s) that created the group —
27
+ * the fix for recurring dupes is upstream in the writer, not in the records.
28
+ */
29
+ export function provenanceSummary(records: Array<{ provenance?: { source?: string; sourceLabel?: string; sourceId?: string } }>): string {
30
+ const counts = new Map<string, number>();
31
+ for (const record of records) {
32
+ const p = record.provenance;
33
+ if (!p) continue;
34
+ const label = p.sourceLabel ?? p.source ?? "unknown source";
35
+ const key = p.sourceId ? `${label} (${p.sourceId})` : label;
36
+ counts.set(key, (counts.get(key) ?? 0) + 1);
37
+ }
38
+ if (counts.size === 0) return "";
39
+ const parts = [...counts.entries()]
40
+ .sort((a, b) => b[1] - a[1])
41
+ .map(([key, count]) => (count > 1 ? `${key} ×${count}` : key));
42
+ return ` Created by: ${parts.join(", ")}.`;
43
+ }
44
+
24
45
  export function auditFindingId(ruleId: string, objectId: string) {
25
46
  return `finding_${stableHash(`${ruleId}:${objectId}`)}`;
26
47
  }
@@ -343,7 +364,7 @@ export const duplicateAccountDomainRule: GtmAuditRule = {
343
364
  ruleId: "duplicate-account-domain",
344
365
  title: "Accounts share the same domain",
345
366
  severity: "warning",
346
- summary: `${accounts.length} accounts share ${domain}: ${accounts.map((account) => account.name).join(", ")}.`,
367
+ summary: `${accounts.length} accounts share ${domain}: ${accounts.map((account) => account.name).join(", ")}.${provenanceSummary(accounts)}`,
347
368
  recommendation: "Review the group and merge duplicates so activity and deals roll up once.",
348
369
  });
349
370
  operations.push({
@@ -383,7 +404,7 @@ export const duplicateContactEmailRule: GtmAuditRule = {
383
404
  ruleId: "duplicate-contact-email",
384
405
  title: "Contacts share the same email",
385
406
  severity: "warning",
386
- summary: `${contacts.length} contacts share ${email}.`,
407
+ summary: `${contacts.length} contacts share ${email}.${provenanceSummary(contacts)}`,
387
408
  recommendation: "Merge the duplicates so engagement history and routing stay coherent.",
388
409
  });
389
410
  operations.push({
@@ -433,7 +454,7 @@ export const duplicateOpenDealRule: GtmAuditRule = {
433
454
  severity: "warning",
434
455
  summary: `${deals.length} open deals named "${anchor.name}"${
435
456
  anchor.accountId ? " on the same account" : ""
436
- }: ${deals.map((deal) => deal.id).join(", ")}.`,
457
+ }: ${deals.map((deal) => deal.id).join(", ")}.${provenanceSummary(deals)}`,
437
458
  recommendation:
438
459
  "Keep one deal, archive the copies, and fix the integration that is re-creating them.",
439
460
  });
package/src/types.ts CHANGED
@@ -34,6 +34,7 @@ export type GtmEvidenceSourceSystem =
34
34
  | "manual"
35
35
  | "csv"
36
36
  | "mock"
37
+ | "web"
37
38
  | "unknown";
38
39
 
39
40
  export type PatchOperationType =
@@ -141,11 +142,26 @@ export type CanonicalUser = {
141
142
  active?: boolean;
142
143
  };
143
144
 
145
+ /**
146
+ * Who created a record, per the provider's read-only record-source fields
147
+ * (HubSpot: hs_object_source / _label / _id). Populated on read; used to
148
+ * attribute duplicate findings to the writer that produced them.
149
+ */
150
+ export type RecordProvenance = {
151
+ /** Provider source code, e.g. INTEGRATION, API, CRM_UI, IMPORT, FORM. */
152
+ source?: string;
153
+ /** Human label, e.g. an integration's name. */
154
+ sourceLabel?: string;
155
+ /** Provider-side id of the source (e.g. app id, import id). */
156
+ sourceId?: string;
157
+ };
158
+
144
159
  export type CanonicalAccount = {
145
160
  id: string;
146
161
  provider?: CrmProvider;
147
162
  crmId?: string;
148
163
  identities?: ProviderIdentity[];
164
+ provenance?: RecordProvenance;
149
165
  name: string;
150
166
  domain?: string;
151
167
  industry?: string;
@@ -163,6 +179,7 @@ export type CanonicalContact = {
163
179
  provider?: CrmProvider;
164
180
  crmId?: string;
165
181
  identities?: ProviderIdentity[];
182
+ provenance?: RecordProvenance;
166
183
  accountId?: string;
167
184
  firstName?: string;
168
185
  lastName?: string;
@@ -181,6 +198,7 @@ export type CanonicalDeal = {
181
198
  provider?: CrmProvider;
182
199
  crmId?: string;
183
200
  identities?: ProviderIdentity[];
201
+ provenance?: RecordProvenance;
184
202
  accountId?: string;
185
203
  ownerId?: string;
186
204
  name: string;