fullstackgtm 0.21.2 → 0.23.1
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 +80 -0
- package/README.md +15 -0
- package/dist/bulkUpdate.d.ts +16 -1
- package/dist/bulkUpdate.js +88 -5
- package/dist/cli.js +670 -8
- package/dist/dedupe.d.ts +14 -0
- package/dist/dedupe.js +140 -0
- package/dist/enrich.d.ts +220 -0
- package/dist/enrich.js +724 -0
- package/dist/enrichApollo.d.ts +59 -0
- package/dist/enrichApollo.js +192 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/marketReport.js +97 -44
- package/dist/reassign.d.ts +19 -0
- package/dist/reassign.js +87 -0
- package/dist/suggest.js +67 -5
- package/llms.txt +15 -0
- package/package.json +1 -1
- package/src/bulkUpdate.ts +109 -6
- package/src/cli.ts +756 -8
- package/src/dedupe.ts +182 -0
- package/src/enrich.ts +1016 -0
- package/src/enrichApollo.ts +250 -0
- package/src/index.ts +48 -1
- package/src/marketReport.ts +116 -62
- package/src/reassign.ts +117 -0
- package/src/suggest.ts +69 -5
package/dist/suggest.js
CHANGED
|
@@ -64,11 +64,39 @@ function suggestDealAccount(operation, dealsById, accountsByNorm, accountsById,
|
|
|
64
64
|
if (!deal) {
|
|
65
65
|
return { ...base, suggestedValue: null, confidence: "none", reason: "Deal not found in the snapshot." };
|
|
66
66
|
}
|
|
67
|
-
// Convention: "Contact Name - Company Name"
|
|
68
|
-
// independent; agreement upgrades confidence,
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
67
|
+
// Convention 1: "Contact Name - Company Name" (hyphen, en or em dash).
|
|
68
|
+
// Both signals below are independent; agreement upgrades confidence,
|
|
69
|
+
// conflict downgrades it.
|
|
70
|
+
const separator = [" - ", " – ", " — "]
|
|
71
|
+
.map((s) => ({ s, index: deal.name.indexOf(s) }))
|
|
72
|
+
.filter(({ index }) => index >= 0)
|
|
73
|
+
.sort((a, b) => a.index - b.index)[0];
|
|
74
|
+
const left = separator ? deal.name.slice(0, separator.index).trim() : deal.name;
|
|
75
|
+
const right = separator ? deal.name.slice(separator.index + separator.s.length).trim() : "";
|
|
76
|
+
// Convention 2: "Company - Deal descriptor" (e.g. "Globex – Expansion",
|
|
77
|
+
// "Hooli – New Business"). When the right side is purely deal-descriptor
|
|
78
|
+
// words, the company is on the LEFT. Only an exact account-name match
|
|
79
|
+
// counts as high; an unknown left side proposes a create — the engine
|
|
80
|
+
// never guesses at an existing record.
|
|
81
|
+
if (left && right && isDealDescriptor(right)) {
|
|
82
|
+
const leftMatch = accountsByNorm.get(normalize(left));
|
|
83
|
+
if (leftMatch) {
|
|
84
|
+
return {
|
|
85
|
+
...base,
|
|
86
|
+
suggestedValue: leftMatch.id,
|
|
87
|
+
confidence: "high",
|
|
88
|
+
reason: `Deal name leads with the exact account name "${leftMatch.name}" ("${right}" is a deal descriptor, not a company).`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (!contactsByName.get(normalize(left))) {
|
|
92
|
+
return {
|
|
93
|
+
...base,
|
|
94
|
+
suggestedValue: `create:${left}`,
|
|
95
|
+
confidence: "create",
|
|
96
|
+
reason: `No account named "${left}" exists ("${right}" is a deal descriptor) — approving creates the company, then links.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
72
100
|
// Signal 1: company-name match against account names.
|
|
73
101
|
let nameMatch = null;
|
|
74
102
|
let nameMatchKind = "";
|
|
@@ -135,6 +163,23 @@ function suggestDealAccount(operation, dealsById, accountsByNorm, accountsById,
|
|
|
135
163
|
reason: `No account named "${right}" exists and contact "${left}" has none — approving creates the company, then links.`,
|
|
136
164
|
};
|
|
137
165
|
}
|
|
166
|
+
// Convention 3: "<Company> <descriptor…>" without a separator (e.g.
|
|
167
|
+
// "INITECH renewal"): strip trailing descriptor words and accept only an
|
|
168
|
+
// exact account-name match on what remains.
|
|
169
|
+
if (!separator) {
|
|
170
|
+
const words = normalize(deal.name).split(" ").filter(Boolean);
|
|
171
|
+
while (words.length > 1 && DEAL_DESCRIPTOR_WORDS.has(words[words.length - 1]))
|
|
172
|
+
words.pop();
|
|
173
|
+
const strippedMatch = words.length > 0 ? accountsByNorm.get(words.join(" ")) : undefined;
|
|
174
|
+
if (strippedMatch) {
|
|
175
|
+
return {
|
|
176
|
+
...base,
|
|
177
|
+
suggestedValue: strippedMatch.id,
|
|
178
|
+
confidence: "high",
|
|
179
|
+
reason: `Deal name leads with the exact account name "${strippedMatch.name}" (trailing words are deal descriptors).`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
138
183
|
return {
|
|
139
184
|
...base,
|
|
140
185
|
suggestedValue: null,
|
|
@@ -144,6 +189,23 @@ function suggestDealAccount(operation, dealsById, accountsByNorm, accountsById,
|
|
|
144
189
|
: `Deal name "${deal.name}" has no "Contact - Company" pattern to derive a company from. Supply --value ${operation.id}=<accountId> or create:<Company Name>.`,
|
|
145
190
|
};
|
|
146
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Words that describe the deal rather than name a company. Used to recognize
|
|
194
|
+
* the "Company - Deal descriptor" naming convention: a segment counts as a
|
|
195
|
+
* descriptor only when EVERY word is in this list, so any real company name
|
|
196
|
+
* ("Brand New Startup") falls through to the contact/company conventions.
|
|
197
|
+
*/
|
|
198
|
+
const DEAL_DESCRIPTOR_WORDS = new Set([
|
|
199
|
+
"renewal", "expansion", "pilot", "annual", "monthly", "platform", "new", "business",
|
|
200
|
+
"add", "on", "addon", "upsell", "upgrade", "trial", "poc", "proof", "concept",
|
|
201
|
+
"subscription", "license", "licence", "contract", "opportunity", "deal", "engagement",
|
|
202
|
+
"implementation", "onboarding", "services", "inbound", "outbound",
|
|
203
|
+
"q1", "q2", "q3", "q4",
|
|
204
|
+
]);
|
|
205
|
+
function isDealDescriptor(segment) {
|
|
206
|
+
const words = normalize(segment).split(" ").filter(Boolean);
|
|
207
|
+
return words.length > 0 && words.every((word) => DEAL_DESCRIPTOR_WORDS.has(word));
|
|
208
|
+
}
|
|
147
209
|
/**
|
|
148
210
|
* Survivor selection for merge_records. Ranking is deterministic and
|
|
149
211
|
* evidence-based: most complete record first (count of populated canonical
|
package/llms.txt
CHANGED
|
@@ -47,6 +47,21 @@ report; `refresh` = capture → classify → drift → report in one command.
|
|
|
47
47
|
Storage is profile-scoped under `<home>/market/<category>`. MCP:
|
|
48
48
|
`fullstackgtm_market_worksheet`, `fullstackgtm_market_observe`.
|
|
49
49
|
|
|
50
|
+
## Key invariants (enrich)
|
|
51
|
+
|
|
52
|
+
`fullstackgtm enrich` is governed enrichment: `append` pulls (Apollo, BYO key
|
|
53
|
+
via `login apollo`/APOLLO_API_KEY) or reads data staged by `ingest` (Clay CSV
|
|
54
|
+
exports / webhook payload JSON), matches source records to CRM records via
|
|
55
|
+
ordered keys in `enrich.config.json` (unique hit wins; ambiguity skips or
|
|
56
|
+
becomes `requires_human_record_selection` placeholders — never a coin flip),
|
|
57
|
+
and emits a fill-blanks-only patch plan through the normal dry-run → approve →
|
|
58
|
+
apply gate. No `--save` = dry-run diff, nothing written. `refresh` re-checks
|
|
59
|
+
stale fields the run-store ledger proves enrich stamped, proposing ops only
|
|
60
|
+
where the source value changed (beforeValue = current CRM value → apply-time
|
|
61
|
+
CAS). Conflict policy MVP is `never`; `system-only`/`always` are phase 2 and
|
|
62
|
+
refused explicitly. Run store (checkpoint + staleness ledger + `status`) is
|
|
63
|
+
profile-scoped under `<home>/enrich/runs`. No cron — scheduling is horizontal.
|
|
64
|
+
|
|
50
65
|
## Key invariants
|
|
51
66
|
|
|
52
67
|
- Reads are safe by default; nothing is written without explicit `--approve`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.1",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM",
|
package/src/bulkUpdate.ts
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* `account.ownerId`, `account.contactCount`; accounts get `contactCount`
|
|
28
28
|
* and `openDealCount`.
|
|
29
29
|
*/
|
|
30
|
+
import { normalizeDomain } from "./merge.ts";
|
|
30
31
|
import { stableHash } from "./rules.ts";
|
|
31
32
|
import type {
|
|
32
33
|
CanonicalGtmSnapshot,
|
|
@@ -40,10 +41,23 @@ export type BulkUpdateOptions = {
|
|
|
40
41
|
objectType: "account" | "contact" | "deal";
|
|
41
42
|
/** raw --where expressions, AND-ed together; at least one is required */
|
|
42
43
|
where: string[];
|
|
43
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* canonical field → new value; one action only. A value of the form
|
|
46
|
+
* `from:<sourceField>` is resolved PER RECORD from the filter view at
|
|
47
|
+
* plan time (relational pseudo-fields like account.ownerId included);
|
|
48
|
+
* records whose source value is empty are skipped, not failed, and
|
|
49
|
+
* counted in the plan summary.
|
|
50
|
+
*/
|
|
44
51
|
set?: Record<string, string>;
|
|
45
52
|
/** propose archive_record instead of field writes */
|
|
46
53
|
archive?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* bypass the archive duplicate guard: by default --archive refuses when a
|
|
56
|
+
* matched account/contact shares its identity key (normalized domain /
|
|
57
|
+
* lowercased email) with another record — those are duplicates, and
|
|
58
|
+
* archiving a duplicate discards its data where merging preserves it
|
|
59
|
+
*/
|
|
60
|
+
forceArchiveDuplicates?: boolean;
|
|
47
61
|
/** propose create_task on each matched record with this subject/body text */
|
|
48
62
|
createTask?: string;
|
|
49
63
|
/** explicit preconditions (field=value), re-verified at apply time */
|
|
@@ -131,6 +145,14 @@ const VALID_FIELDS: Record<BulkUpdateOptions["objectType"], Set<string>> = {
|
|
|
131
145
|
deal: new Set(["id", "crmId", "accountId", "ownerId", "name", "amount", "currency", "stage", "closeDate", "dealType", "forecastCategory", "nextStep", "probability", "isClosed", "isWon", "lastActivityAt", "lastSyncAt", ...RELATIONAL_FIELDS]),
|
|
132
146
|
};
|
|
133
147
|
|
|
148
|
+
/** True when `field` is filterable for this object type (relational pseudo-fields included). */
|
|
149
|
+
export function isFilterableField(
|
|
150
|
+
objectType: BulkUpdateOptions["objectType"],
|
|
151
|
+
field: string,
|
|
152
|
+
): boolean {
|
|
153
|
+
return VALID_FIELDS[objectType].has(field);
|
|
154
|
+
}
|
|
155
|
+
|
|
134
156
|
function assertValidFields(objectType: BulkUpdateOptions["objectType"], clauses: WhereClause[], context: string): void {
|
|
135
157
|
for (const clause of clauses) {
|
|
136
158
|
if (!VALID_FIELDS[objectType].has(clause.field)) {
|
|
@@ -251,10 +273,25 @@ export function buildBulkUpdatePlan(
|
|
|
251
273
|
const clauses = options.where.map(parseWhere);
|
|
252
274
|
assertValidFields(options.objectType, clauses, "--where");
|
|
253
275
|
const WRITABLE_BLOCKLIST = new Set(["id", "crmId", "contactCount", "openDealCount", "openDealStages"]);
|
|
254
|
-
|
|
276
|
+
// `from:<sourceField>` values resolve per record from the filter view —
|
|
277
|
+
// the source is validated with the same strictness as filters (relational
|
|
278
|
+
// pseudo-fields allowed; the WRITTEN field still must be canonical).
|
|
279
|
+
const assignments: Array<{ field: string; literal?: string; fromField?: string }> = [];
|
|
280
|
+
for (const [field, value] of Object.entries(options.set ?? {})) {
|
|
255
281
|
if (!VALID_FIELDS[options.objectType].has(field) || WRITABLE_BLOCKLIST.has(field) || field.includes(".")) {
|
|
256
282
|
throw new Error(`Cannot --set "${field}" on ${options.objectType}s — not a writable canonical field.`);
|
|
257
283
|
}
|
|
284
|
+
if (value.startsWith("from:")) {
|
|
285
|
+
const fromField = value.slice("from:".length);
|
|
286
|
+
if (!VALID_FIELDS[options.objectType].has(fromField)) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`Cannot --set ${field}=from:${fromField} on ${options.objectType}s — unknown source field "${fromField}". Valid fields: ${[...VALID_FIELDS[options.objectType]].join(", ")}.`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
assignments.push({ field, fromField });
|
|
292
|
+
} else {
|
|
293
|
+
assignments.push({ field, literal: value });
|
|
294
|
+
}
|
|
258
295
|
}
|
|
259
296
|
const views = buildViews(snapshot, options.objectType);
|
|
260
297
|
const matched = views.filter(({ view }) => clauses.every((c) => matches(view, c)));
|
|
@@ -264,6 +301,45 @@ export function buildBulkUpdatePlan(
|
|
|
264
301
|
);
|
|
265
302
|
}
|
|
266
303
|
|
|
304
|
+
// Archive duplicate guard: archiving a record that shares its identity key
|
|
305
|
+
// with another active record discards data a merge would preserve. Refuse
|
|
306
|
+
// and point at `dedupe` unless explicitly overridden. Deals are exempt —
|
|
307
|
+
// they carry no identity key.
|
|
308
|
+
if (options.archive && options.objectType !== "deal" && !options.forceArchiveDuplicates) {
|
|
309
|
+
const keyName = options.objectType === "account" ? "domain" : "email";
|
|
310
|
+
const keyOf = (record: Record<string, unknown>): string | undefined =>
|
|
311
|
+
options.objectType === "account"
|
|
312
|
+
? normalizeDomain(record.domain as string | undefined)
|
|
313
|
+
: ((record.email as string | undefined)?.trim().toLowerCase() || undefined);
|
|
314
|
+
const allRecords = snapshot[COLLECTIONS[options.objectType]] as Array<Record<string, unknown>>;
|
|
315
|
+
const byKey = new Map<string, Array<Record<string, unknown>>>();
|
|
316
|
+
for (const record of allRecords) {
|
|
317
|
+
const key = keyOf(record);
|
|
318
|
+
if (!key) continue;
|
|
319
|
+
const existing = byKey.get(key) ?? [];
|
|
320
|
+
existing.push(record);
|
|
321
|
+
byKey.set(key, existing);
|
|
322
|
+
}
|
|
323
|
+
const collisions: string[] = [];
|
|
324
|
+
for (const { record } of matched) {
|
|
325
|
+
const key = keyOf(record);
|
|
326
|
+
if (!key) continue;
|
|
327
|
+
const others = (byKey.get(key) ?? []).filter((other) => other.id !== record.id);
|
|
328
|
+
if (others.length === 0) continue;
|
|
329
|
+
const label = (record.name as string | undefined) ?? (record.email as string | undefined) ?? "";
|
|
330
|
+
collisions.push(
|
|
331
|
+
`${options.objectType} ${record.id}${label ? ` "${label}"` : ""} shares ${keyName} "${key}" with ${others
|
|
332
|
+
.map((other) => `${other.id}${other.name ? ` "${other.name}"` : ""}`)
|
|
333
|
+
.join(", ")}`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
if (collisions.length > 0) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`Refusing to archive: ${collisions.length} matched record(s) look like duplicates of other records — archiving a duplicate DISCARDS its data, merging preserves it. Use \`fullstackgtm dedupe ${options.objectType} --key ${keyName}\` (merge_records) instead, or pass --force-archive-duplicates to archive anyway.\n - ${collisions.join("\n - ")}`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
267
343
|
// Preconditions: explicit --require, plus every equality filter on a real
|
|
268
344
|
// (re-readable, non-relational) field. The premise the plan was built on
|
|
269
345
|
// is re-verified per record at apply time.
|
|
@@ -295,7 +371,9 @@ export function buildBulkUpdatePlan(
|
|
|
295
371
|
const reason = options.reason ?? `bulk-update: ${action} where ${whereText}`;
|
|
296
372
|
|
|
297
373
|
const operations: PatchOperation[] = [];
|
|
298
|
-
|
|
374
|
+
// records skipped because a from:<sourceField> value was empty, per source
|
|
375
|
+
const skippedBySource = new Map<string, number>();
|
|
376
|
+
for (const { record, view } of matched) {
|
|
299
377
|
const objectId = String(record.id);
|
|
300
378
|
const groupId = `grp_${options.objectType}_${objectId}`;
|
|
301
379
|
const preconditions = preconditionSpecs.map((p) => ({
|
|
@@ -338,7 +416,29 @@ export function buildBulkUpdatePlan(
|
|
|
338
416
|
});
|
|
339
417
|
continue;
|
|
340
418
|
}
|
|
341
|
-
for
|
|
419
|
+
// Resolve every assignment for this record BEFORE emitting any of its
|
|
420
|
+
// operations: a record whose from:<sourceField> resolves empty is
|
|
421
|
+
// skipped whole (its operations share a groupId — half a record's
|
|
422
|
+
// updates is exactly what grouping exists to prevent).
|
|
423
|
+
const resolved: Array<{ field: string; value: string }> = [];
|
|
424
|
+
let emptySource: string | null = null;
|
|
425
|
+
for (const assignment of assignments) {
|
|
426
|
+
if (assignment.fromField !== undefined) {
|
|
427
|
+
const value = fieldValue(view, assignment.fromField);
|
|
428
|
+
if (value === "") {
|
|
429
|
+
emptySource = assignment.fromField;
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
resolved.push({ field: assignment.field, value });
|
|
433
|
+
} else {
|
|
434
|
+
resolved.push({ field: assignment.field, value: assignment.literal! });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (emptySource !== null) {
|
|
438
|
+
skippedBySource.set(emptySource, (skippedBySource.get(emptySource) ?? 0) + 1);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
for (const { field, value } of resolved) {
|
|
342
442
|
operations.push({
|
|
343
443
|
...shared,
|
|
344
444
|
id: `op_${stableHash(`bulk-set:${options.objectType}:${objectId}:${field}:${value}`)}`,
|
|
@@ -360,13 +460,16 @@ export function buildBulkUpdatePlan(
|
|
|
360
460
|
if (failure) throw new Error(`${failure} The guard already fails against the current snapshot — the plan would never apply.`);
|
|
361
461
|
}
|
|
362
462
|
|
|
463
|
+
const skippedText = [...skippedBySource.entries()]
|
|
464
|
+
.map(([sourceField, count]) => ` ${count} skipped: empty ${sourceField}.`)
|
|
465
|
+
.join("");
|
|
363
466
|
return {
|
|
364
|
-
id: `patch_plan_${stableHash(`bulk:${snapshot.provider}:${snapshot.generatedAt}:${whereText}:${action}:${operations.length}`)}`,
|
|
467
|
+
id: `patch_plan_${stableHash(`bulk:${snapshot.provider}:${snapshot.generatedAt}:${options.objectType}:${whereText}:${action}:${operations.length}`)}`,
|
|
365
468
|
title: `Bulk update: ${options.objectType}s where ${whereText}`,
|
|
366
469
|
createdAt: snapshot.generatedAt,
|
|
367
470
|
status: operations.length > 0 ? "needs_approval" : "draft",
|
|
368
471
|
dryRun: true,
|
|
369
|
-
summary: `${matched.length} ${COLLECTIONS[options.objectType]} matched (${whereText}); ${operations.length} proposed dry-run operations (${action}).${guards.length > 0 ? ` ${guards.length} apply-time guard(s).` : ""}`,
|
|
472
|
+
summary: `${matched.length} ${COLLECTIONS[options.objectType]} matched (${whereText}); ${operations.length} proposed dry-run operations (${action}).${skippedText}${guards.length > 0 ? ` ${guards.length} apply-time guard(s).` : ""}`,
|
|
370
473
|
findings: [],
|
|
371
474
|
operations,
|
|
372
475
|
filter: { objectType: options.objectType, where: options.where },
|