fullstackgtm 0.11.1 → 0.13.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 +56 -0
- package/README.md +20 -1
- package/dist/calls.d.ts +72 -0
- package/dist/calls.js +345 -0
- package/dist/cli.js +179 -0
- package/dist/connectors/hubspot.js +70 -0
- package/dist/connectors/salesforce.js +9 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +20 -0
- package/dist/rules.js +21 -18
- package/dist/suggest.js +77 -0
- package/dist/types.d.ts +1 -1
- package/docs/api.md +1 -1
- package/docs/crm-health-lifecycle.md +1 -1
- package/llms.txt +7 -0
- package/package.json +1 -1
- package/src/calls.ts +434 -0
- package/src/cli.ts +193 -0
- package/src/connectors/hubspot.ts +71 -0
- package/src/connectors/salesforce.ts +10 -0
- package/src/index.ts +13 -0
- package/src/mcp.ts +26 -0
- package/src/rules.ts +24 -18
- package/src/suggest.ts +88 -0
- package/src/types.ts +5 -1
|
@@ -542,6 +542,75 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
542
542
|
};
|
|
543
543
|
}
|
|
544
544
|
|
|
545
|
+
/**
|
|
546
|
+
* Merge a duplicate group into the approved survivor via HubSpot's v3
|
|
547
|
+
* merge API (supported for contacts, companies, deals, and tickets).
|
|
548
|
+
* Merges are pairwise and IRREVERSIBLE; the survivor's values win on
|
|
549
|
+
* conflict and each loser is archived by HubSpot. A loser that is already
|
|
550
|
+
* gone (404 — e.g. a replayed plan) is treated as already merged.
|
|
551
|
+
*/
|
|
552
|
+
async function mergeRecords(operation: PatchOperation): Promise<PatchOperationResult> {
|
|
553
|
+
const objectPath = OBJECT_PATHS[operation.objectType];
|
|
554
|
+
if (!objectPath || operation.objectType === "user" || operation.objectType === "activity") {
|
|
555
|
+
return {
|
|
556
|
+
operationId: operation.id,
|
|
557
|
+
status: "skipped",
|
|
558
|
+
detail: "merge_records is supported for accounts, contacts, and deals.",
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
const survivorId = String(operation.afterValue ?? "");
|
|
562
|
+
const groupIds = Array.isArray(operation.beforeValue)
|
|
563
|
+
? operation.beforeValue.map((id) => String(id))
|
|
564
|
+
: [];
|
|
565
|
+
if (!survivorId || groupIds.length < 2) {
|
|
566
|
+
return {
|
|
567
|
+
operationId: operation.id,
|
|
568
|
+
status: "skipped",
|
|
569
|
+
detail: "merge_records needs a survivor id (afterValue) and the duplicate group ids (beforeValue).",
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
if (!groupIds.includes(survivorId)) {
|
|
573
|
+
return {
|
|
574
|
+
operationId: operation.id,
|
|
575
|
+
status: "skipped",
|
|
576
|
+
detail: `Survivor ${survivorId} is not in the duplicate group (${groupIds.join(", ")}); refusing to merge into an unrelated record.`,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
const losers = groupIds.filter((id) => id !== survivorId);
|
|
580
|
+
const mergedIds: string[] = [];
|
|
581
|
+
const alreadyGoneIds: string[] = [];
|
|
582
|
+
for (const loser of losers) {
|
|
583
|
+
try {
|
|
584
|
+
await request(`/crm/v3/objects/${objectPath}/merge`, {
|
|
585
|
+
method: "POST",
|
|
586
|
+
body: JSON.stringify({ primaryObjectId: survivorId, objectIdToMerge: loser }),
|
|
587
|
+
});
|
|
588
|
+
mergedIds.push(loser);
|
|
589
|
+
} catch (error) {
|
|
590
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
591
|
+
if (message.includes(" 404")) {
|
|
592
|
+
alreadyGoneIds.push(loser); // replayed plan: loser already merged/archived
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
return {
|
|
596
|
+
operationId: operation.id,
|
|
597
|
+
status: "failed",
|
|
598
|
+
detail: `Merged ${mergedIds.length} of ${losers.length} into ${survivorId}, then failed on ${loser}: ${message}`,
|
|
599
|
+
providerData: { survivorId, mergedIds, alreadyGoneIds },
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return {
|
|
604
|
+
operationId: operation.id,
|
|
605
|
+
status: mergedIds.length === 0 && alreadyGoneIds.length === losers.length ? "skipped" : "applied",
|
|
606
|
+
detail:
|
|
607
|
+
mergedIds.length === 0 && alreadyGoneIds.length === losers.length
|
|
608
|
+
? `All ${losers.length} duplicates were already merged into ${survivorId}; nothing to do.`
|
|
609
|
+
: `Merged ${mergedIds.length} duplicate ${objectPath} into ${survivorId}${alreadyGoneIds.length ? ` (${alreadyGoneIds.length} already gone)` : ""}. Irreversible.`,
|
|
610
|
+
providerData: { survivorId, mergedIds, alreadyGoneIds },
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
545
614
|
async function applyOperation(operation: PatchOperation): Promise<PatchOperationResult> {
|
|
546
615
|
try {
|
|
547
616
|
switch (operation.operation) {
|
|
@@ -552,6 +621,8 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
552
621
|
return await linkRecord(operation);
|
|
553
622
|
case "create_task":
|
|
554
623
|
return await createTask(operation);
|
|
624
|
+
case "merge_records":
|
|
625
|
+
return await mergeRecords(operation);
|
|
555
626
|
case "archive_record":
|
|
556
627
|
return await archiveRecord(operation);
|
|
557
628
|
default:
|
|
@@ -435,6 +435,16 @@ export function createSalesforceConnector(
|
|
|
435
435
|
}
|
|
436
436
|
case "create_task":
|
|
437
437
|
return await createTask(operation);
|
|
438
|
+
case "merge_records":
|
|
439
|
+
// Salesforce merge exists only in the SOAP API and Apex (Lead,
|
|
440
|
+
// Contact, Account, Case; max 3 records) — there is no REST merge
|
|
441
|
+
// resource. Surface that honestly instead of half-merging.
|
|
442
|
+
return {
|
|
443
|
+
operationId: operation.id,
|
|
444
|
+
status: "skipped",
|
|
445
|
+
detail:
|
|
446
|
+
"Salesforce merge requires the SOAP API or Apex (Lead/Contact/Account/Case only) — this REST connector cannot merge. Merge in the Salesforce UI, or archive the duplicates explicitly.",
|
|
447
|
+
};
|
|
438
448
|
case "archive_record":
|
|
439
449
|
return await archiveRecord(operation);
|
|
440
450
|
default:
|
package/src/index.ts
CHANGED
|
@@ -98,6 +98,19 @@ export {
|
|
|
98
98
|
requiresHumanInput,
|
|
99
99
|
staleDealRule,
|
|
100
100
|
} from "./rules.ts";
|
|
101
|
+
export {
|
|
102
|
+
extractCallInsights,
|
|
103
|
+
normalizeTranscript,
|
|
104
|
+
parseCall,
|
|
105
|
+
parseTranscript,
|
|
106
|
+
suggestCallDeal,
|
|
107
|
+
summarizeInsights,
|
|
108
|
+
type CallDealSuggestion,
|
|
109
|
+
type CallInsightType,
|
|
110
|
+
type ExtractedCallInsight,
|
|
111
|
+
type ParsedCall,
|
|
112
|
+
type ParsedTranscriptSegment,
|
|
113
|
+
} from "./calls.ts";
|
|
101
114
|
export { sampleSnapshot } from "./sampleData.ts";
|
|
102
115
|
export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
|
|
103
116
|
export type {
|
package/src/mcp.ts
CHANGED
|
@@ -46,6 +46,7 @@ import type { FieldMappings } from "./mappings.ts";
|
|
|
46
46
|
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
47
47
|
import { builtinAuditRules } from "./rules.ts";
|
|
48
48
|
import { sampleSnapshot } from "./sampleData.ts";
|
|
49
|
+
import { parseCall } from "./calls.ts";
|
|
49
50
|
import { suggestValues } from "./suggest.ts";
|
|
50
51
|
import type { CanonicalGtmSnapshot, GtmConnector, PatchPlan } from "./types.ts";
|
|
51
52
|
|
|
@@ -188,6 +189,31 @@ export async function startMcpServer() {
|
|
|
188
189
|
},
|
|
189
190
|
);
|
|
190
191
|
|
|
192
|
+
server.registerTool(
|
|
193
|
+
"fullstackgtm_call_parse",
|
|
194
|
+
{
|
|
195
|
+
title: "Parse Call Transcript",
|
|
196
|
+
description:
|
|
197
|
+
"Deterministically parse a call transcript (Speaker:/[Speaker]: lines or Granola " +
|
|
198
|
+
"utterance JSON) into canonical segments, keyword-derived insights (next steps, " +
|
|
199
|
+
"objections, pricing, risks, competitor mentions...), and GtmEvidence records. " +
|
|
200
|
+
"Read-only and LLM-free; pair with fullstackgtm_audit/apply for governed writes.",
|
|
201
|
+
inputSchema: {
|
|
202
|
+
transcript: z.string().optional(),
|
|
203
|
+
transcriptPath: z.string().optional(),
|
|
204
|
+
title: z.string().optional(),
|
|
205
|
+
source: z.enum(["gong", "chorus", "fathom", "manual", "csv", "unknown"]).optional(),
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
async ({ transcript, transcriptPath, title, source }) => {
|
|
209
|
+
const raw =
|
|
210
|
+
transcript ??
|
|
211
|
+
(transcriptPath ? readFileSync(resolve(process.cwd(), transcriptPath), "utf8") : null);
|
|
212
|
+
if (!raw) throw new Error("Provide transcript (text) or transcriptPath (file).");
|
|
213
|
+
return content(parseCall(raw, { title, sourceSystem: source }));
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
|
|
191
217
|
server.registerTool(
|
|
192
218
|
"fullstackgtm_rules",
|
|
193
219
|
{
|
package/src/rules.ts
CHANGED
|
@@ -350,13 +350,15 @@ export const duplicateAccountDomainRule: GtmAuditRule = {
|
|
|
350
350
|
id: patchOperationId("duplicate-account-domain", anchor.id),
|
|
351
351
|
objectType: "account" as const,
|
|
352
352
|
objectId: anchor.id,
|
|
353
|
-
operation: "
|
|
354
|
-
field: "
|
|
355
|
-
beforeValue:
|
|
356
|
-
afterValue:
|
|
357
|
-
reason:
|
|
358
|
-
riskLevel: "
|
|
353
|
+
operation: "merge_records" as const,
|
|
354
|
+
field: "merge",
|
|
355
|
+
beforeValue: accounts.map((account) => account.id),
|
|
356
|
+
afterValue: "requires_human_survivor_selection",
|
|
357
|
+
reason: `Duplicate accounts split pipeline, attribution, and ownership. Merge the ${accounts.length} accounts sharing ${domain} into one survivor.`,
|
|
358
|
+
riskLevel: "high" as const,
|
|
359
359
|
approvalRequired: true,
|
|
360
|
+
rollback:
|
|
361
|
+
"IRREVERSIBLE: provider merges cannot be unmerged. The pre-apply snapshot retains every record's field values; recreate a record manually from it if a merge was wrong.",
|
|
360
362
|
});
|
|
361
363
|
}
|
|
362
364
|
return { findings, operations };
|
|
@@ -388,13 +390,15 @@ export const duplicateContactEmailRule: GtmAuditRule = {
|
|
|
388
390
|
id: patchOperationId("duplicate-contact-email", anchor.id),
|
|
389
391
|
objectType: "contact" as const,
|
|
390
392
|
objectId: anchor.id,
|
|
391
|
-
operation: "
|
|
392
|
-
field: "
|
|
393
|
-
beforeValue:
|
|
394
|
-
afterValue:
|
|
395
|
-
reason:
|
|
396
|
-
riskLevel: "
|
|
393
|
+
operation: "merge_records" as const,
|
|
394
|
+
field: "merge",
|
|
395
|
+
beforeValue: contacts.map((contact) => contact.id),
|
|
396
|
+
afterValue: "requires_human_survivor_selection",
|
|
397
|
+
reason: `Duplicate contacts fragment engagement history and double-route outreach. Merge the ${contacts.length} contacts sharing ${email} into one survivor.`,
|
|
398
|
+
riskLevel: "high" as const,
|
|
397
399
|
approvalRequired: true,
|
|
400
|
+
rollback:
|
|
401
|
+
"IRREVERSIBLE: provider merges cannot be unmerged. The pre-apply snapshot retains every record's field values; recreate a record manually from it if a merge was wrong.",
|
|
398
402
|
});
|
|
399
403
|
}
|
|
400
404
|
return { findings, operations };
|
|
@@ -437,13 +441,15 @@ export const duplicateOpenDealRule: GtmAuditRule = {
|
|
|
437
441
|
id: patchOperationId("duplicate-open-deal", anchor.id),
|
|
438
442
|
objectType: "deal" as const,
|
|
439
443
|
objectId: anchor.id,
|
|
440
|
-
operation: "
|
|
441
|
-
field: "
|
|
442
|
-
beforeValue:
|
|
443
|
-
afterValue:
|
|
444
|
-
reason:
|
|
445
|
-
riskLevel: "
|
|
444
|
+
operation: "merge_records" as const,
|
|
445
|
+
field: "merge",
|
|
446
|
+
beforeValue: deals.map((deal) => deal.id),
|
|
447
|
+
afterValue: "requires_human_survivor_selection",
|
|
448
|
+
reason: `Duplicate open deals inflate pipeline and forecast the same revenue more than once. Merge the ${deals.length} deals named "${anchor.name}" into one survivor.`,
|
|
449
|
+
riskLevel: "high" as const,
|
|
446
450
|
approvalRequired: true,
|
|
451
|
+
rollback:
|
|
452
|
+
"IRREVERSIBLE: provider merges cannot be unmerged. The pre-apply snapshot retains every record's field values; recreate a record manually from it if a merge was wrong.",
|
|
447
453
|
});
|
|
448
454
|
}
|
|
449
455
|
return { findings, operations };
|
package/src/suggest.ts
CHANGED
|
@@ -62,6 +62,11 @@ export function suggestValues(
|
|
|
62
62
|
continue;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
if (placeholder === "requires_human_survivor_selection") {
|
|
66
|
+
suggestions.push(suggestSurvivor(operation, snapshot, dealsById));
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
65
70
|
if (placeholder === "requires_human_owner_selection" && activeUsers.length === 1) {
|
|
66
71
|
suggestions.push({
|
|
67
72
|
operationId: operation.id,
|
|
@@ -194,6 +199,89 @@ function suggestDealAccount(
|
|
|
194
199
|
};
|
|
195
200
|
}
|
|
196
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Survivor selection for merge_records. Ranking is deterministic and
|
|
204
|
+
* evidence-based: most complete record first (count of populated canonical
|
|
205
|
+
* fields), most recent activity as the tiebreaker, group order last.
|
|
206
|
+
* Confidence is capped at "low" by design: merges are irreversible, so a
|
|
207
|
+
* survivor suggestion must never clear the default bulk-approval bar —
|
|
208
|
+
* accepting one requires --min-confidence low or an explicit --value.
|
|
209
|
+
*/
|
|
210
|
+
function suggestSurvivor(
|
|
211
|
+
operation: PatchOperation,
|
|
212
|
+
snapshot: CanonicalGtmSnapshot,
|
|
213
|
+
dealsById: Map<string, { name: string }>,
|
|
214
|
+
): ValueSuggestion {
|
|
215
|
+
const base = {
|
|
216
|
+
operationId: operation.id,
|
|
217
|
+
objectType: operation.objectType,
|
|
218
|
+
objectId: operation.objectId,
|
|
219
|
+
objectName: dealsById.get(operation.objectId)?.name,
|
|
220
|
+
placeholder: "requires_human_survivor_selection",
|
|
221
|
+
};
|
|
222
|
+
const groupIds = Array.isArray(operation.beforeValue)
|
|
223
|
+
? operation.beforeValue.map((id) => String(id))
|
|
224
|
+
: [];
|
|
225
|
+
if (groupIds.length < 2) {
|
|
226
|
+
return {
|
|
227
|
+
...base,
|
|
228
|
+
suggestedValue: null,
|
|
229
|
+
confidence: "none",
|
|
230
|
+
reason: "Operation does not carry a duplicate group (expected ids in beforeValue).",
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const collection =
|
|
234
|
+
operation.objectType === "account"
|
|
235
|
+
? snapshot.accounts
|
|
236
|
+
: operation.objectType === "contact"
|
|
237
|
+
? snapshot.contacts
|
|
238
|
+
: operation.objectType === "deal"
|
|
239
|
+
? snapshot.deals
|
|
240
|
+
: [];
|
|
241
|
+
const records = groupIds
|
|
242
|
+
.map((id) => collection.find((row) => row.id === id))
|
|
243
|
+
.filter((row): row is NonNullable<typeof row> => row !== undefined);
|
|
244
|
+
if (records.length !== groupIds.length) {
|
|
245
|
+
return {
|
|
246
|
+
...base,
|
|
247
|
+
suggestedValue: null,
|
|
248
|
+
confidence: "none",
|
|
249
|
+
reason: "Not every group member is present in the snapshot; re-run the audit before merging.",
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
const ranked = [...records].sort((a, b) => {
|
|
253
|
+
const completeness = populatedFields(b) - populatedFields(a);
|
|
254
|
+
if (completeness !== 0) return completeness;
|
|
255
|
+
return activityMs(b) - activityMs(a);
|
|
256
|
+
});
|
|
257
|
+
const winner = ranked[0];
|
|
258
|
+
const runnerUp = ranked[1];
|
|
259
|
+
const name = "name" in winner && typeof winner.name === "string" ? winner.name : winner.id;
|
|
260
|
+
return {
|
|
261
|
+
...base,
|
|
262
|
+
suggestedValue: winner.id,
|
|
263
|
+
confidence: "low",
|
|
264
|
+
reason:
|
|
265
|
+
`"${name}" (${winner.id}) is the most complete record in the group ` +
|
|
266
|
+
`(${populatedFields(winner)} populated fields vs ${populatedFields(runnerUp)}` +
|
|
267
|
+
`${activityMs(winner) > activityMs(runnerUp) ? ", and more recent activity" : ""}). ` +
|
|
268
|
+
`Merging is IRREVERSIBLE — survivor suggestions never exceed low confidence; ` +
|
|
269
|
+
`approve with --min-confidence low or an explicit --value after review.`,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function populatedFields(record: object) {
|
|
274
|
+
return Object.values(record).filter(
|
|
275
|
+
(value) => value !== undefined && value !== null && value !== "",
|
|
276
|
+
).length;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function activityMs(record: object) {
|
|
280
|
+
const value = (record as { lastActivityAt?: string }).lastActivityAt;
|
|
281
|
+
const parsed = value ? Date.parse(value) : NaN;
|
|
282
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
197
285
|
function normalize(value: string) {
|
|
198
286
|
return value
|
|
199
287
|
.toLowerCase()
|
package/src/types.ts
CHANGED
|
@@ -41,7 +41,11 @@ export type PatchOperationType =
|
|
|
41
41
|
| "clear_field"
|
|
42
42
|
| "link_record"
|
|
43
43
|
| "archive_record"
|
|
44
|
-
| "create_task"
|
|
44
|
+
| "create_task"
|
|
45
|
+
// Merge a duplicate group into a survivor. beforeValue is the group's
|
|
46
|
+
// record ids; afterValue is the survivor id (requires_human_survivor_selection
|
|
47
|
+
// until a human picks). IRREVERSIBLE on every provider that supports it.
|
|
48
|
+
| "merge_records";
|
|
45
49
|
|
|
46
50
|
export type AuditFindingSeverity = "info" | "warning" | "critical";
|
|
47
51
|
|