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
package/dist/market.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { credentialsDir } from "./credentials.js";
|
|
5
|
+
const INTENSITY_RANK = {
|
|
6
|
+
loud: 3,
|
|
7
|
+
quiet: 2,
|
|
8
|
+
absent: 1,
|
|
9
|
+
unobservable: 0,
|
|
10
|
+
};
|
|
11
|
+
// Mirrors stableHash in rules.ts (FNV-1a); duplicated to keep market.ts
|
|
12
|
+
// importable without pulling the audit engine.
|
|
13
|
+
function fnv1a(value) {
|
|
14
|
+
let hash = 0x811c9dc5;
|
|
15
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
16
|
+
hash ^= value.charCodeAt(i);
|
|
17
|
+
hash = Math.imul(hash, 0x01000193);
|
|
18
|
+
}
|
|
19
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
20
|
+
}
|
|
21
|
+
export function observationId(category, runLabel, vendorId, claimId) {
|
|
22
|
+
return `obs_${fnv1a(`${category}|${runLabel}|${vendorId}|${claimId}`)}`;
|
|
23
|
+
}
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Config
|
|
26
|
+
export function parseMarketConfig(raw) {
|
|
27
|
+
const config = JSON.parse(raw);
|
|
28
|
+
if (!config.category)
|
|
29
|
+
throw new Error("market config: missing category");
|
|
30
|
+
if (!Array.isArray(config.vendors) || config.vendors.length === 0) {
|
|
31
|
+
throw new Error("market config: at least one vendor is required");
|
|
32
|
+
}
|
|
33
|
+
if (!Array.isArray(config.claims) || config.claims.length === 0) {
|
|
34
|
+
throw new Error("market config: at least one claim is required");
|
|
35
|
+
}
|
|
36
|
+
for (const [label, items] of [
|
|
37
|
+
["vendor", config.vendors],
|
|
38
|
+
["claim", config.claims],
|
|
39
|
+
]) {
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
for (const item of items) {
|
|
42
|
+
if (!item.id)
|
|
43
|
+
throw new Error(`market config: ${label} missing id`);
|
|
44
|
+
if (seen.has(item.id))
|
|
45
|
+
throw new Error(`market config: duplicate ${label} id "${item.id}"`);
|
|
46
|
+
seen.add(item.id);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (config.anchorVendor && !config.vendors.some((v) => v.id === config.anchorVendor)) {
|
|
50
|
+
throw new Error(`market config: anchorVendor "${config.anchorVendor}" is not in vendors`);
|
|
51
|
+
}
|
|
52
|
+
return config;
|
|
53
|
+
}
|
|
54
|
+
export function loadMarketConfig(path) {
|
|
55
|
+
return parseMarketConfig(readFileSync(path, "utf8"));
|
|
56
|
+
}
|
|
57
|
+
export function starterMarketConfig(category) {
|
|
58
|
+
return {
|
|
59
|
+
category,
|
|
60
|
+
anchorVendor: "your-company",
|
|
61
|
+
vendors: [
|
|
62
|
+
{
|
|
63
|
+
id: "your-company",
|
|
64
|
+
name: "Your Company",
|
|
65
|
+
urls: { home: "https://example.com/", pricing: null, product: [] },
|
|
66
|
+
notes: "Replace with the real vendor set (≤10 works well). pricing: null records 'no public pricing page'.",
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
claims: [
|
|
70
|
+
{
|
|
71
|
+
id: "example-claim",
|
|
72
|
+
capability: "Example capability: what is being claimed, stated precisely",
|
|
73
|
+
icp: "who-buys-it",
|
|
74
|
+
pricingStructure: "how-it-is-priced",
|
|
75
|
+
definition: "LOUD if the claim is hero copy or a top-nav named product with a dedicated page; QUIET if it appears only on pages below that; ABSENT if nowhere. Write the definition so a human could judge any vendor's page against it.",
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
surfaceRule: "LOUD = hero copy OR top-level-nav named product with dedicated page; QUIET = present on any indexed page below that; ABSENT = nowhere observed (explicit disavowals score ABSENT with the disavowal quoted in reason); UNOBSERVABLE = capture empty/failed — never score ABSENT from a failed capture.",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Profile-scoped market home: captures and observations live with credentials
|
|
83
|
+
// so --profile isolation covers category intel too.
|
|
84
|
+
export function marketHome(category, baseDir) {
|
|
85
|
+
return join(baseDir ?? credentialsDir(), "market", category);
|
|
86
|
+
}
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Capture: fetch vendor pages, strip to readable text, store content-addressed.
|
|
89
|
+
// The hash cache is the change detector (unchanged page = same hash = no new
|
|
90
|
+
// classification needed), the replay buffer (re-judge a revised taxonomy
|
|
91
|
+
// without re-scraping), and the evidence chain (quoted spans stay resolvable).
|
|
92
|
+
const STRIP_BLOCKS = /<(script|style|noscript|svg|head)\b[\s\S]*?<\/\1\s*>/gi;
|
|
93
|
+
const ENTITIES = {
|
|
94
|
+
"&": "&",
|
|
95
|
+
"<": "<",
|
|
96
|
+
">": ">",
|
|
97
|
+
""": '"',
|
|
98
|
+
"'": "'",
|
|
99
|
+
"'": "'",
|
|
100
|
+
" ": " ",
|
|
101
|
+
"—": "—",
|
|
102
|
+
"–": "–",
|
|
103
|
+
};
|
|
104
|
+
export function extractReadableText(html) {
|
|
105
|
+
const withoutBlocks = html.replace(STRIP_BLOCKS, " ");
|
|
106
|
+
const withBreaks = withoutBlocks.replace(/<(\/p|\/div|\/li|\/h[1-6]|br\s*\/?)>/gi, "\n");
|
|
107
|
+
const withoutTags = withBreaks.replace(/<[^>]+>/g, " ");
|
|
108
|
+
const decoded = withoutTags
|
|
109
|
+
.replace(/&[a-z#0-9]+;/gi, (entity) => ENTITIES[entity.toLowerCase()] ?? " ")
|
|
110
|
+
.replace(/[ \t]+/g, " ");
|
|
111
|
+
return decoded
|
|
112
|
+
.split("\n")
|
|
113
|
+
.map((line) => line.trim())
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.join("\n");
|
|
116
|
+
}
|
|
117
|
+
const defaultFetchPage = async (url) => {
|
|
118
|
+
const response = await fetch(url, {
|
|
119
|
+
headers: {
|
|
120
|
+
"User-Agent": "fullstackgtm-market/0 (+https://github.com/fullstackgtm/core)",
|
|
121
|
+
"Accept-Language": "en-US",
|
|
122
|
+
},
|
|
123
|
+
redirect: "follow",
|
|
124
|
+
});
|
|
125
|
+
return { status: response.status, body: await response.text() };
|
|
126
|
+
};
|
|
127
|
+
export async function captureMarket(config, options = {}) {
|
|
128
|
+
const dir = options.dir ?? join(marketHome(config.category), "captures");
|
|
129
|
+
const runLabel = options.runLabel ?? "run-1";
|
|
130
|
+
const fetchPage = options.fetchPage ?? defaultFetchPage;
|
|
131
|
+
const fetchedAt = (options.now ?? (() => new Date()))().toISOString();
|
|
132
|
+
mkdirSync(dir, { recursive: true });
|
|
133
|
+
const manifestPath = join(dir, "manifest.json");
|
|
134
|
+
const manifest = existsSync(manifestPath)
|
|
135
|
+
? JSON.parse(readFileSync(manifestPath, "utf8"))
|
|
136
|
+
: [];
|
|
137
|
+
const entries = [];
|
|
138
|
+
for (const vendor of config.vendors) {
|
|
139
|
+
const targets = [
|
|
140
|
+
{ kind: "home", url: vendor.urls.home },
|
|
141
|
+
];
|
|
142
|
+
if (vendor.urls.pricing)
|
|
143
|
+
targets.push({ kind: "pricing", url: vendor.urls.pricing });
|
|
144
|
+
for (const url of vendor.urls.product)
|
|
145
|
+
targets.push({ kind: "product", url });
|
|
146
|
+
for (const target of targets) {
|
|
147
|
+
let status = null;
|
|
148
|
+
let text = "";
|
|
149
|
+
try {
|
|
150
|
+
const page = await fetchPage(target.url);
|
|
151
|
+
status = page.status;
|
|
152
|
+
if (page.status === 200)
|
|
153
|
+
text = extractReadableText(page.body);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
status = null;
|
|
157
|
+
}
|
|
158
|
+
let captureHash = null;
|
|
159
|
+
if (text) {
|
|
160
|
+
captureHash = createHash("sha256").update(text).digest("hex");
|
|
161
|
+
// Content-addressed: an unchanged page dedupes to the same file.
|
|
162
|
+
writeFileSync(join(dir, `${captureHash}.txt`), text);
|
|
163
|
+
}
|
|
164
|
+
const entry = {
|
|
165
|
+
runLabel,
|
|
166
|
+
vendorId: vendor.id,
|
|
167
|
+
kind: target.kind,
|
|
168
|
+
url: target.url,
|
|
169
|
+
fetchedAt,
|
|
170
|
+
httpStatus: status,
|
|
171
|
+
captureHash,
|
|
172
|
+
textChars: text.length,
|
|
173
|
+
};
|
|
174
|
+
manifest.push(entry);
|
|
175
|
+
entries.push(entry);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
179
|
+
return { entries, manifestPath };
|
|
180
|
+
}
|
|
181
|
+
export function createFileObservationStore(category, directory) {
|
|
182
|
+
const dir = directory ?? join(marketHome(category), "observations");
|
|
183
|
+
function fileFor(runLabel) {
|
|
184
|
+
if (!/^[\w.-]+$/.test(runLabel))
|
|
185
|
+
throw new Error(`Invalid run label: ${runLabel}`);
|
|
186
|
+
return join(dir, `${runLabel}.json`);
|
|
187
|
+
}
|
|
188
|
+
function read(runLabel) {
|
|
189
|
+
try {
|
|
190
|
+
return JSON.parse(readFileSync(fileFor(runLabel), "utf8"));
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function listSets() {
|
|
197
|
+
let names = [];
|
|
198
|
+
try {
|
|
199
|
+
names = readdirSync(dir).filter((name) => name.endsWith(".json"));
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
return names
|
|
205
|
+
.map((name) => read(name.replace(/\.json$/, "")))
|
|
206
|
+
.filter((set) => set !== null)
|
|
207
|
+
.sort((a, b) => a.runAt.localeCompare(b.runAt));
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
async append(set) {
|
|
211
|
+
if (set.category !== category) {
|
|
212
|
+
throw new Error(`Observation set category "${set.category}" does not match store "${category}"`);
|
|
213
|
+
}
|
|
214
|
+
if (read(set.runLabel)) {
|
|
215
|
+
throw new Error(`Run "${set.runLabel}" already exists — observations are append-only; use a new run label`);
|
|
216
|
+
}
|
|
217
|
+
mkdirSync(dir, { recursive: true });
|
|
218
|
+
writeFileSync(fileFor(set.runLabel), `${JSON.stringify(set, null, 2)}\n`);
|
|
219
|
+
return set;
|
|
220
|
+
},
|
|
221
|
+
async get(runLabel) {
|
|
222
|
+
return read(runLabel);
|
|
223
|
+
},
|
|
224
|
+
async list() {
|
|
225
|
+
return listSets().map((set) => ({
|
|
226
|
+
runLabel: set.runLabel,
|
|
227
|
+
runAt: set.runAt,
|
|
228
|
+
observations: set.observations.length,
|
|
229
|
+
}));
|
|
230
|
+
},
|
|
231
|
+
async latest() {
|
|
232
|
+
const sets = listSets();
|
|
233
|
+
return sets.length ? sets[sets.length - 1] : null;
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Validate a proposed observation set against the config before it enters
|
|
239
|
+
* the store: known vendors/claims, full coverage, legal readings, and the
|
|
240
|
+
* verbatim-evidence rule (non-absent readings must quote something).
|
|
241
|
+
* Returns problems; an empty array means accept.
|
|
242
|
+
*/
|
|
243
|
+
export function validateObservationSet(config, set) {
|
|
244
|
+
const problems = [];
|
|
245
|
+
const vendorIds = new Set(config.vendors.map((v) => v.id));
|
|
246
|
+
const claimIds = new Set(config.claims.map((c) => c.id));
|
|
247
|
+
const seen = new Set();
|
|
248
|
+
for (const obs of set.observations) {
|
|
249
|
+
const cell = `${obs.vendorId} × ${obs.claimId}`;
|
|
250
|
+
if (!vendorIds.has(obs.vendorId))
|
|
251
|
+
problems.push(`unknown vendor "${obs.vendorId}"`);
|
|
252
|
+
if (!claimIds.has(obs.claimId))
|
|
253
|
+
problems.push(`unknown claim "${obs.claimId}"`);
|
|
254
|
+
if (seen.has(cell))
|
|
255
|
+
problems.push(`duplicate observation for ${cell}`);
|
|
256
|
+
seen.add(cell);
|
|
257
|
+
if (!INTENSITY_RANK[obs.intensity] && obs.intensity !== "unobservable") {
|
|
258
|
+
problems.push(`${cell}: invalid intensity "${obs.intensity}"`);
|
|
259
|
+
}
|
|
260
|
+
if ((obs.intensity === "loud" || obs.intensity === "quiet") && obs.evidence.length === 0) {
|
|
261
|
+
problems.push(`${cell}: ${obs.intensity} reading with no quoted evidence`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
for (const vendor of config.vendors) {
|
|
265
|
+
for (const claim of config.claims) {
|
|
266
|
+
if (!seen.has(`${vendor.id} × ${claim.id}`)) {
|
|
267
|
+
problems.push(`missing observation for ${vendor.id} × ${claim.id}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return problems;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Front rule v1: 0 loud → open (if anyone is quiet) or vacant; 1 loud →
|
|
275
|
+
* owned; 2–3 loud → contested; ≥4 loud → saturated. Unobservable cells are
|
|
276
|
+
* excluded — a failed capture never reads as absence.
|
|
277
|
+
*/
|
|
278
|
+
export function computeFrontStates(config, set) {
|
|
279
|
+
const byCell = new Map();
|
|
280
|
+
for (const obs of set.observations) {
|
|
281
|
+
const key = `${obs.vendorId}|${obs.claimId}`;
|
|
282
|
+
const existing = byCell.get(key);
|
|
283
|
+
if (!existing || INTENSITY_RANK[obs.intensity] > INTENSITY_RANK[existing.intensity]) {
|
|
284
|
+
byCell.set(key, obs);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return config.claims.map((claim) => {
|
|
288
|
+
const loud = [];
|
|
289
|
+
const quiet = [];
|
|
290
|
+
for (const vendor of config.vendors) {
|
|
291
|
+
const obs = byCell.get(`${vendor.id}|${claim.id}`);
|
|
292
|
+
if (obs?.intensity === "loud")
|
|
293
|
+
loud.push(vendor.id);
|
|
294
|
+
if (obs?.intensity === "quiet")
|
|
295
|
+
quiet.push(vendor.id);
|
|
296
|
+
}
|
|
297
|
+
let state;
|
|
298
|
+
if (loud.length === 0)
|
|
299
|
+
state = quiet.length >= 1 ? "open" : "vacant";
|
|
300
|
+
else if (loud.length === 1)
|
|
301
|
+
state = "owned";
|
|
302
|
+
else if (loud.length <= 3)
|
|
303
|
+
state = "contested";
|
|
304
|
+
else
|
|
305
|
+
state = "saturated";
|
|
306
|
+
return { claimId: claim.id, state, loudVendorIds: loud, quietVendorIds: quiet };
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
/** What changed in the category between two runs — the refresh's whole point. */
|
|
310
|
+
export function diffFrontStates(before, after) {
|
|
311
|
+
const prior = new Map(before.map((front) => [front.claimId, front.state]));
|
|
312
|
+
const drift = [];
|
|
313
|
+
for (const front of after) {
|
|
314
|
+
const was = prior.get(front.claimId);
|
|
315
|
+
if (was && was !== front.state)
|
|
316
|
+
drift.push({ claimId: front.claimId, before: was, after: front.state });
|
|
317
|
+
}
|
|
318
|
+
return drift;
|
|
319
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { computeFrontStates } from "./market.js";
|
|
2
|
+
/**
|
|
3
|
+
* Render a market map as a client-ready deliverable: markdown for terminals
|
|
4
|
+
* and PRs, and a self-contained printable HTML "field report" — front
|
|
5
|
+
* summary, claim × vendor intensity matrix, and a verbatim-evidence
|
|
6
|
+
* appendix. Deterministic: same observation set, same bytes. No webfonts,
|
|
7
|
+
* no CDNs — the artifact must stand alone wherever it's sent.
|
|
8
|
+
*/
|
|
9
|
+
const FRONT_ORDER = {
|
|
10
|
+
open: 0,
|
|
11
|
+
contested: 1,
|
|
12
|
+
owned: 2,
|
|
13
|
+
saturated: 3,
|
|
14
|
+
vacant: 4,
|
|
15
|
+
};
|
|
16
|
+
const GLYPH = {
|
|
17
|
+
loud: "■",
|
|
18
|
+
quiet: "□",
|
|
19
|
+
absent: "·",
|
|
20
|
+
unobservable: "▨",
|
|
21
|
+
};
|
|
22
|
+
function escapeHtml(value) {
|
|
23
|
+
return value
|
|
24
|
+
.replace(/&/g, "&")
|
|
25
|
+
.replace(/</g, "<")
|
|
26
|
+
.replace(/>/g, ">")
|
|
27
|
+
.replace(/"/g, """);
|
|
28
|
+
}
|
|
29
|
+
function buildModel(config, set) {
|
|
30
|
+
const fronts = computeFrontStates(config, set);
|
|
31
|
+
const stateByClaim = new Map(fronts.map((front) => [front.claimId, front.state]));
|
|
32
|
+
const orderedClaimIds = config.claims
|
|
33
|
+
.map((claim) => claim.id)
|
|
34
|
+
.sort((a, b) => {
|
|
35
|
+
const byFront = FRONT_ORDER[stateByClaim.get(a) ?? "vacant"] - FRONT_ORDER[stateByClaim.get(b) ?? "vacant"];
|
|
36
|
+
return byFront !== 0 ? byFront : a.localeCompare(b);
|
|
37
|
+
});
|
|
38
|
+
const byCell = new Map(set.observations.map((obs) => [`${obs.vendorId}|${obs.claimId}`, obs]));
|
|
39
|
+
return {
|
|
40
|
+
config,
|
|
41
|
+
set,
|
|
42
|
+
fronts,
|
|
43
|
+
orderedClaimIds,
|
|
44
|
+
cell: (vendorId, claimId) => byCell.get(`${vendorId}|${claimId}`),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function marketMapToMarkdown(config, set) {
|
|
48
|
+
const model = buildModel(config, set);
|
|
49
|
+
const stateByClaim = new Map(model.fronts.map((front) => [front.claimId, front.state]));
|
|
50
|
+
const counts = { open: 0, contested: 0, owned: 0, saturated: 0, vacant: 0 };
|
|
51
|
+
for (const front of model.fronts)
|
|
52
|
+
counts[front.state] += 1;
|
|
53
|
+
const lines = [];
|
|
54
|
+
lines.push(`# Market map — ${config.category} (${set.runLabel})`);
|
|
55
|
+
lines.push("");
|
|
56
|
+
lines.push(`Observed ${set.runAt} · ${config.vendors.length} vendors · ${config.claims.length} claims · ` +
|
|
57
|
+
`${set.observations.length} readings · extractor ${set.extractor}`);
|
|
58
|
+
lines.push("");
|
|
59
|
+
lines.push(`Fronts: ${counts.open + counts.vacant} open/vacant · ${counts.contested} contested · ` +
|
|
60
|
+
`${counts.owned} owned · ${counts.saturated} saturated`);
|
|
61
|
+
lines.push("");
|
|
62
|
+
lines.push(`Legend: ■ loud · □ quiet · · absent · ▨ unobservable`);
|
|
63
|
+
lines.push("");
|
|
64
|
+
const header = ["claim", ...config.vendors.map((v) => v.id), "front"];
|
|
65
|
+
lines.push(`| ${header.join(" | ")} |`);
|
|
66
|
+
lines.push(`| ${header.map(() => "---").join(" | ")} |`);
|
|
67
|
+
for (const claimId of model.orderedClaimIds) {
|
|
68
|
+
const cells = config.vendors.map((vendor) => GLYPH[model.cell(vendor.id, claimId)?.intensity ?? "unobservable"]);
|
|
69
|
+
lines.push(`| ${claimId} | ${cells.join(" | ")} | ${stateByClaim.get(claimId)?.toUpperCase()} |`);
|
|
70
|
+
}
|
|
71
|
+
lines.push("");
|
|
72
|
+
for (const front of model.fronts.filter((f) => f.state === "owned")) {
|
|
73
|
+
lines.push(`- OWNED: ${front.claimId} → ${front.loudVendorIds[0]}`);
|
|
74
|
+
}
|
|
75
|
+
for (const front of model.fronts.filter((f) => f.state === "open" || f.state === "vacant")) {
|
|
76
|
+
lines.push(`- ${front.state.toUpperCase()}: ${front.claimId} — no vendor is loud here`);
|
|
77
|
+
}
|
|
78
|
+
return `${lines.join("\n")}\n`;
|
|
79
|
+
}
|
|
80
|
+
export function marketMapToHtml(config, set) {
|
|
81
|
+
const model = buildModel(config, set);
|
|
82
|
+
const stateByClaim = new Map(model.fronts.map((front) => [front.claimId, front.state]));
|
|
83
|
+
const claimsById = new Map(config.claims.map((claim) => [claim.id, claim]));
|
|
84
|
+
const counts = { open: 0, contested: 0, owned: 0, saturated: 0, vacant: 0 };
|
|
85
|
+
for (const front of model.fronts)
|
|
86
|
+
counts[front.state] += 1;
|
|
87
|
+
const unobservable = set.observations.filter((obs) => obs.intensity === "unobservable").length;
|
|
88
|
+
const anchor = config.anchorVendor;
|
|
89
|
+
const e = escapeHtml;
|
|
90
|
+
const matrixRows = model.orderedClaimIds
|
|
91
|
+
.map((claimId) => {
|
|
92
|
+
const claim = claimsById.get(claimId);
|
|
93
|
+
if (!claim)
|
|
94
|
+
return "";
|
|
95
|
+
const state = stateByClaim.get(claimId) ?? "vacant";
|
|
96
|
+
const cells = config.vendors
|
|
97
|
+
.map((vendor) => {
|
|
98
|
+
const intensity = model.cell(vendor.id, claimId)?.intensity ?? "unobservable";
|
|
99
|
+
const anchorClass = vendor.id === anchor ? " anchor-col" : "";
|
|
100
|
+
return `<td class="cell${anchorClass}"><span class="g g-${intensity}" title="${e(vendor.name)}: ${intensity}"></span></td>`;
|
|
101
|
+
})
|
|
102
|
+
.join("");
|
|
103
|
+
return (`<tr class="front-${state}"><th scope="row"><span class="claim-cap">${e(claim.capability.split(":")[0])}</span>` +
|
|
104
|
+
`<span class="claim-meta">${e(claim.icp)} · ${e(claim.pricingStructure)}</span></th>${cells}` +
|
|
105
|
+
`<td class="front"><span class="chip chip-${state}">${state.toUpperCase()}</span></td></tr>`);
|
|
106
|
+
})
|
|
107
|
+
.join("");
|
|
108
|
+
const openList = model.orderedClaimIds
|
|
109
|
+
.filter((claimId) => {
|
|
110
|
+
const state = stateByClaim.get(claimId);
|
|
111
|
+
return state === "open" || state === "vacant";
|
|
112
|
+
})
|
|
113
|
+
.map((claimId) => {
|
|
114
|
+
const claim = claimsById.get(claimId);
|
|
115
|
+
return `<li><b>${e(claim?.capability.split(":")[0] ?? claimId)}</b> <span class="why">— no vendor is loud here; ${e(claim?.icp ?? "")} cell</span></li>`;
|
|
116
|
+
})
|
|
117
|
+
.join("");
|
|
118
|
+
const appendix = model.orderedClaimIds
|
|
119
|
+
.flatMap((claimId) => config.vendors.flatMap((vendor) => {
|
|
120
|
+
const obs = model.cell(vendor.id, claimId);
|
|
121
|
+
if (!obs || obs.evidence.length === 0)
|
|
122
|
+
return [];
|
|
123
|
+
return obs.evidence.map((evidence) => `<div class="ev"><span class="ev-head">${e(vendor.name)} · ${e(claimId)} · ${obs.intensity.toUpperCase()} (${obs.confidence})</span>` +
|
|
124
|
+
`<blockquote>“${e(evidence.text)}”</blockquote>` +
|
|
125
|
+
`<span class="ev-src">${e(String(evidence.metadata?.url ?? ""))} · capture ${e(String(evidence.metadata?.captureHash ?? "").slice(0, 12))}</span></div>`);
|
|
126
|
+
}))
|
|
127
|
+
.join("");
|
|
128
|
+
const vendorHeads = config.vendors
|
|
129
|
+
.map((vendor) => `<th class="vh${vendor.id === anchor ? " anchor-col" : ""}"><span>${e(vendor.name)}</span></th>`)
|
|
130
|
+
.join("");
|
|
131
|
+
return `<!doctype html>
|
|
132
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
133
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
134
|
+
<title>Market map — ${e(config.category)} — ${e(set.runLabel)}</title>
|
|
135
|
+
<style>
|
|
136
|
+
:root { --paper:#f4efe4; --ink:#211d16; --ink-soft:#5a5244; --line:#c9bfa9; --accent:#b4441b; --green:#2e5339; --quiet:#8a7d63; }
|
|
137
|
+
* { box-sizing:border-box; margin:0; }
|
|
138
|
+
body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background:var(--paper);
|
|
139
|
+
max-width:1080px; margin:0 auto; padding:0 48px 96px;
|
|
140
|
+
background-image:radial-gradient(rgba(33,29,22,.028) 1px, transparent 1.2px); background-size:5px 5px; }
|
|
141
|
+
.chip,.claim-meta,.ev-src,.lg,.stamp,.meta,th.vh span { font-family:"SF Mono",Menlo,Consolas,monospace; }
|
|
142
|
+
header { padding:56px 0 28px; border-bottom:3px double var(--ink); position:relative; }
|
|
143
|
+
.kicker { font-size:11px; letter-spacing:.32em; color:var(--accent); text-transform:uppercase; }
|
|
144
|
+
h1 { font-size:44px; line-height:1.05; font-weight:600; margin:10px 0 6px; }
|
|
145
|
+
h1 em { font-style:italic; color:var(--green); }
|
|
146
|
+
.meta { font-size:11.5px; color:var(--ink-soft); display:flex; gap:24px; flex-wrap:wrap; margin-top:14px; }
|
|
147
|
+
.stamp { position:absolute; right:0; top:58px; border:2px solid var(--accent); color:var(--accent); padding:7px 13px;
|
|
148
|
+
font-size:11px; letter-spacing:.22em; transform:rotate(3.5deg); text-transform:uppercase; }
|
|
149
|
+
section { margin-top:56px; }
|
|
150
|
+
h2 { font-size:13px; letter-spacing:.26em; text-transform:uppercase; color:var(--ink-soft);
|
|
151
|
+
border-bottom:1px solid var(--line); padding-bottom:9px; display:flex; gap:14px; align-items:baseline; }
|
|
152
|
+
h2 .no { color:var(--accent); font-style:italic; font-size:15px; letter-spacing:0; }
|
|
153
|
+
.fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:22px; }
|
|
154
|
+
.fcard { background:var(--paper); padding:18px 18px 14px; }
|
|
155
|
+
.fcard b { display:block; font-size:42px; font-weight:600; line-height:1; }
|
|
156
|
+
.fcard span { font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:var(--ink-soft); }
|
|
157
|
+
.fcard.open b { color:var(--accent); }
|
|
158
|
+
.openlist { margin-top:18px; font-size:15.5px; line-height:1.55; }
|
|
159
|
+
.openlist li { margin:4px 0 4px 20px; }
|
|
160
|
+
.openlist .why { color:var(--ink-soft); font-size:13px; font-style:italic; }
|
|
161
|
+
.legend { display:flex; gap:22px; flex-wrap:wrap; margin:18px 0 10px; font-size:10.5px; color:var(--ink-soft); }
|
|
162
|
+
.lg { display:inline-flex; align-items:center; gap:7px; }
|
|
163
|
+
table { border-collapse:collapse; width:100%; margin-top:6px; }
|
|
164
|
+
thead th { border-bottom:2px solid var(--ink); padding:6px 2px 10px; }
|
|
165
|
+
th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px; letter-spacing:.12em;
|
|
166
|
+
text-transform:uppercase; color:var(--ink-soft); display:inline-block; }
|
|
167
|
+
th.vh.anchor-col span { color:var(--green); font-weight:700; }
|
|
168
|
+
tbody th { text-align:left; font-weight:400; padding:7px 14px 7px 0; border-bottom:1px solid var(--line); max-width:330px; }
|
|
169
|
+
.claim-cap { display:block; font-size:14.5px; }
|
|
170
|
+
.claim-meta { display:block; font-size:9.5px; color:var(--quiet); letter-spacing:.08em; margin-top:2px; }
|
|
171
|
+
td.cell { text-align:center; border-bottom:1px solid var(--line); padding:4px 2px; }
|
|
172
|
+
td.cell.anchor-col { background:rgba(46,83,57,.06); }
|
|
173
|
+
td.front { border-bottom:1px solid var(--line); text-align:right; white-space:nowrap; }
|
|
174
|
+
.g { display:inline-block; width:15px; height:15px; vertical-align:middle; }
|
|
175
|
+
.g-loud { background:var(--ink); }
|
|
176
|
+
.g-quiet { box-shadow:inset 0 0 0 2px var(--quiet); }
|
|
177
|
+
.g-absent { background:radial-gradient(circle at center, var(--line) 0 2.5px, transparent 3px); }
|
|
178
|
+
.g-unobservable { background:repeating-linear-gradient(45deg, var(--line) 0 2px, transparent 2px 5px); }
|
|
179
|
+
tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
|
|
180
|
+
.chip { font-size:9px; letter-spacing:.16em; padding:3px 8px; border:1px solid currentColor; }
|
|
181
|
+
.chip-open { color:var(--accent); } .chip-contested { color:#7a5a12; }
|
|
182
|
+
.chip-owned { color:var(--green); } .chip-saturated { color:var(--ink-soft); } .chip-vacant { color:var(--quiet); }
|
|
183
|
+
.ev { border-bottom:1px solid var(--line); padding:12px 0; }
|
|
184
|
+
.ev-head { font-size:10.5px; letter-spacing:.1em; color:var(--accent); }
|
|
185
|
+
.ev blockquote { font-style:italic; margin:6px 0; font-size:13.5px; line-height:1.5; }
|
|
186
|
+
.ev-src { font-size:10px; color:var(--ink-soft); word-break:break-all; }
|
|
187
|
+
footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; font-size:11px; color:var(--ink-soft);
|
|
188
|
+
display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
|
|
189
|
+
@media print { body { max-width:none; padding:0 8mm; background:white; } section { break-inside:avoid-page; } tr { break-inside:avoid; } }
|
|
190
|
+
</style></head><body>
|
|
191
|
+
<header>
|
|
192
|
+
<div class="kicker">Full Stack GTM · Market Map</div>
|
|
193
|
+
<h1>The <em>${e(config.category.replace(/-/g, " "))}</em> front map</h1>
|
|
194
|
+
<div class="meta">
|
|
195
|
+
<span>RUN ${e(set.runLabel.toUpperCase())}</span><span>OBSERVED ${e(set.runAt)}</span>
|
|
196
|
+
<span>${config.vendors.length} VENDORS · ${config.claims.length} CLAIMS · ${set.observations.length} READINGS</span>
|
|
197
|
+
<span>${unobservable} UNOBSERVABLE · EXTRACTOR ${e(set.extractor)}</span>
|
|
198
|
+
</div>
|
|
199
|
+
<div class="stamp">Field Report</div>
|
|
200
|
+
</header>
|
|
201
|
+
<section>
|
|
202
|
+
<h2><span class="no">01</span> Front summary</h2>
|
|
203
|
+
<div class="fronts">
|
|
204
|
+
<div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
|
|
205
|
+
<div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
|
|
206
|
+
<div class="fcard"><b>${counts.owned}</b><span>Owned</span></div>
|
|
207
|
+
<div class="fcard"><b>${counts.saturated}</b><span>Saturated</span></div>
|
|
208
|
+
</div>
|
|
209
|
+
<ul class="openlist">${openList}</ul>
|
|
210
|
+
</section>
|
|
211
|
+
<section>
|
|
212
|
+
<h2><span class="no">02</span> Claim × vendor intensity matrix</h2>
|
|
213
|
+
<div class="legend">
|
|
214
|
+
<span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
|
|
215
|
+
<span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
|
|
216
|
+
<span class="lg"><i class="g g-absent"></i>ABSENT</span>
|
|
217
|
+
<span class="lg"><i class="g g-unobservable"></i>UNOBSERVABLE — capture failed</span>
|
|
218
|
+
</div>
|
|
219
|
+
<table>
|
|
220
|
+
<thead><tr><th></th>${vendorHeads}<th></th></tr></thead>
|
|
221
|
+
<tbody>${matrixRows}</tbody>
|
|
222
|
+
</table>
|
|
223
|
+
</section>
|
|
224
|
+
<section>
|
|
225
|
+
<h2><span class="no">03</span> Evidence appendix</h2>
|
|
226
|
+
${appendix}
|
|
227
|
+
</section>
|
|
228
|
+
<footer>
|
|
229
|
+
<span>Generated by fullstackgtm market · deterministic render of ${e(set.runLabel)}</span>
|
|
230
|
+
<span>Front rule v1: 0 loud=open · 1=owned · 2–3=contested · ≥4=saturated</span>
|
|
231
|
+
</footer>
|
|
232
|
+
</body></html>`;
|
|
233
|
+
}
|
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;
|