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.
- package/CHANGELOG.md +70 -0
- package/README.md +14 -0
- package/dist/cli.js +169 -0
- package/dist/connectors/hubspot.js +62 -7
- package/dist/diff.js +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/market.d.ts +147 -0
- package/dist/market.js +319 -0
- package/dist/marketReport.d.ts +3 -0
- package/dist/marketReport.js +233 -0
- package/dist/mcp.js +20 -0
- package/dist/merge.js +1 -1
- package/dist/resolve.d.ts +37 -0
- package/dist/resolve.js +126 -0
- package/dist/rules.d.ts +12 -0
- package/dist/rules.js +25 -3
- package/dist/types.d.ts +17 -1
- package/docs/crm-health-lifecycle.md +11 -11
- package/llms.txt +4 -0
- package/package.json +1 -1
- package/src/cli.ts +183 -0
- package/src/connectors/hubspot.ts +68 -10
- package/src/diff.ts +1 -1
- package/src/index.ts +29 -0
- package/src/market.ts +467 -0
- package/src/marketReport.ts +272 -0
- package/src/mcp.ts +26 -0
- package/src/merge.ts +1 -1
- package/src/resolve.ts +177 -0
- package/src/rules.ts +24 -3
- package/src/types.ts +18 -0
|
@@ -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, "&")
|
|
36
|
+
.replace(/</g, "<")
|
|
37
|
+
.replace(/>/g, ">")
|
|
38
|
+
.replace(/"/g, """);
|
|
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;
|