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
package/dist/cli.js
CHANGED
|
@@ -17,6 +17,7 @@ import { createFilePlanStore } from "./planStore.js";
|
|
|
17
17
|
import { auditReportToHtml, auditReportToMarkdown } from "./report.js";
|
|
18
18
|
import { builtinAuditRules } from "./rules.js";
|
|
19
19
|
import { sampleSnapshot } from "./sampleData.js";
|
|
20
|
+
import { parseCall, suggestCallDeal } from "./calls.js";
|
|
20
21
|
import { suggestValues } from "./suggest.js";
|
|
21
22
|
function usage() {
|
|
22
23
|
return `FullStackGTM — audit GTM data across providers, propose reviewable patch plans,
|
|
@@ -40,6 +41,11 @@ Usage:
|
|
|
40
41
|
fullstackgtm report [source options] [audit options] [report options]
|
|
41
42
|
fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]
|
|
42
43
|
fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]
|
|
44
|
+
fullstackgtm call parse --transcript <file> [--title t] [--source fathom|granola|...] [--json|--ndjson] [--out <path>]
|
|
45
|
+
fullstackgtm call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
|
|
46
|
+
fullstackgtm call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
|
|
47
|
+
calls become evidence: parse dialects (Speaker:/[Me]/Granola JSON),
|
|
48
|
+
link to the right deal, and propose governed next-step writes
|
|
43
49
|
fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
|
|
44
50
|
derive values for requires_human_* placeholders
|
|
45
51
|
from snapshot evidence, with confidence + reasons
|
|
@@ -403,6 +409,175 @@ function parseValueOverrides(args) {
|
|
|
403
409
|
}
|
|
404
410
|
return valueOverrides;
|
|
405
411
|
}
|
|
412
|
+
async function callCommand(args) {
|
|
413
|
+
const [subcommand, ...rest] = args;
|
|
414
|
+
const loadParsedCall = () => {
|
|
415
|
+
const callPath = option(rest, "--call");
|
|
416
|
+
if (callPath) {
|
|
417
|
+
return JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8"));
|
|
418
|
+
}
|
|
419
|
+
const transcriptPath = option(rest, "--transcript");
|
|
420
|
+
if (!transcriptPath)
|
|
421
|
+
throw new Error(`call ${subcommand} requires --transcript <file> or --call <parsed.json>`);
|
|
422
|
+
const raw = readFileSync(resolve(process.cwd(), transcriptPath), "utf8");
|
|
423
|
+
const source = option(rest, "--source");
|
|
424
|
+
return parseCall(raw, {
|
|
425
|
+
title: option(rest, "--title") ?? undefined,
|
|
426
|
+
sourceSystem: source,
|
|
427
|
+
capturedAt: new Date().toISOString(),
|
|
428
|
+
});
|
|
429
|
+
};
|
|
430
|
+
if (subcommand === "parse") {
|
|
431
|
+
const parsed = loadParsedCall();
|
|
432
|
+
const outPath = option(rest, "--out");
|
|
433
|
+
if (outPath)
|
|
434
|
+
writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(parsed, null, 2)}\n`);
|
|
435
|
+
if (rest.includes("--ndjson")) {
|
|
436
|
+
// One flat row per insight — warehouse-friendly (e.g. Snowflake COPY).
|
|
437
|
+
for (const insight of parsed.insights) {
|
|
438
|
+
console.log(JSON.stringify({
|
|
439
|
+
call_id: parsed.id,
|
|
440
|
+
call_title: parsed.title ?? null,
|
|
441
|
+
source_system: parsed.sourceSystem,
|
|
442
|
+
type: insight.type,
|
|
443
|
+
title: insight.title,
|
|
444
|
+
text: insight.text,
|
|
445
|
+
evidence: insight.evidence,
|
|
446
|
+
speaker: insight.speaker ?? null,
|
|
447
|
+
confidence: insight.confidence,
|
|
448
|
+
importance: insight.importance,
|
|
449
|
+
}));
|
|
450
|
+
}
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (rest.includes("--json") || outPath) {
|
|
454
|
+
if (!outPath)
|
|
455
|
+
console.log(JSON.stringify(parsed, null, 2));
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
console.log(`Call ${parsed.id}${parsed.title ? ` — ${parsed.title}` : ""} (${parsed.sourceSystem})`);
|
|
459
|
+
console.log(`${parsed.segments.length} segments · ${parsed.insights.length} insights (${parsed.summary.highImportance} high-importance)\n`);
|
|
460
|
+
for (const insight of parsed.insights) {
|
|
461
|
+
console.log(`[${insight.type}] (importance ${insight.importance}) ${insight.text}`);
|
|
462
|
+
}
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (subcommand === "link") {
|
|
466
|
+
const attendees = option(rest, "--attendees");
|
|
467
|
+
const domain = option(rest, "--domain");
|
|
468
|
+
if (!attendees && !domain)
|
|
469
|
+
throw new Error("call link requires --attendees <emails,comma-separated> and/or --domain <example.com>");
|
|
470
|
+
const snapshot = await readSnapshot(rest);
|
|
471
|
+
const suggestion = suggestCallDeal(snapshot, {
|
|
472
|
+
attendeeEmails: attendees?.split(",").map((e) => e.trim()).filter(Boolean),
|
|
473
|
+
domain: domain ?? undefined,
|
|
474
|
+
});
|
|
475
|
+
if (rest.includes("--json")) {
|
|
476
|
+
console.log(JSON.stringify(suggestion, null, 2));
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
const marker = suggestion.confidence === "high" ? "✓" : suggestion.confidence === "low" ? "~" : "✗";
|
|
480
|
+
console.log(`${marker} [${suggestion.confidence}] ${suggestion.dealId ?? "no match"}${suggestion.dealName ? ` — ${suggestion.dealName}` : ""}`);
|
|
481
|
+
console.log(` ${suggestion.reason}`);
|
|
482
|
+
}
|
|
483
|
+
if (suggestion.confidence === "none")
|
|
484
|
+
process.exitCode = 1;
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (subcommand === "plan") {
|
|
488
|
+
const dealId = option(rest, "--deal");
|
|
489
|
+
if (!dealId)
|
|
490
|
+
throw new Error("call plan requires --deal <dealId> (use `call link` to find it)");
|
|
491
|
+
const parsed = loadParsedCall();
|
|
492
|
+
const snapshot = await readSnapshot(rest);
|
|
493
|
+
const deal = snapshot.deals.find((row) => row.id === dealId);
|
|
494
|
+
if (!deal)
|
|
495
|
+
throw new Error(`Deal ${dealId} is not in the snapshot — check the id or the snapshot source.`);
|
|
496
|
+
const nextSteps = parsed.insights.filter((insight) => insight.type === "next_step");
|
|
497
|
+
if (nextSteps.length === 0) {
|
|
498
|
+
console.log("No next-step insights in this call — nothing to plan. (Other insight types are evidence, not writes.)");
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const [top, ...others] = nextSteps;
|
|
502
|
+
const proposed = top.text.trim().slice(0, 255);
|
|
503
|
+
const current = deal.nextStep?.trim() ?? "";
|
|
504
|
+
const plan = buildCallPlan(parsed, deal, proposed, current, others.slice(0, 3));
|
|
505
|
+
if (rest.includes("--save")) {
|
|
506
|
+
await createFilePlanStore().save(plan);
|
|
507
|
+
console.log(`Saved plan ${plan.id}. Review with \`fullstackgtm plans show ${plan.id}\`, approve with \`fullstackgtm plans approve ${plan.id} --operations <ids|all>\`, then \`fullstackgtm apply --plan-id ${plan.id} --provider <name>\`.`);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
console.log(rest.includes("--json") ? JSON.stringify(plan, null, 2) : patchPlanToMarkdown(plan));
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
throw new Error(`call supports: parse, link, plan (got ${subcommand ?? "nothing"})`);
|
|
514
|
+
}
|
|
515
|
+
function buildCallPlan(parsed, deal, proposed, current, extraNextSteps) {
|
|
516
|
+
const findings = [];
|
|
517
|
+
const operations = [];
|
|
518
|
+
const nextStepEvidence = parsed.evidence.filter((item) => item.metadata?.insightType === "next_step");
|
|
519
|
+
const evidenceIds = nextStepEvidence.map((item) => item.id);
|
|
520
|
+
if (current.toLowerCase() !== proposed.toLowerCase()) {
|
|
521
|
+
findings.push({
|
|
522
|
+
id: `finding_${parsed.id.replace(/^call_/, "")}_${deal.id}`,
|
|
523
|
+
objectType: "deal",
|
|
524
|
+
objectId: deal.id,
|
|
525
|
+
ruleId: "call-next-step-not-reflected-in-crm",
|
|
526
|
+
type: "call_next_step_not_reflected_in_crm",
|
|
527
|
+
title: "Call agreed a next step the CRM does not reflect",
|
|
528
|
+
severity: "warning",
|
|
529
|
+
summary: current
|
|
530
|
+
? `The call produced "${proposed}" but ${deal.name}'s next step still reads "${current}".`
|
|
531
|
+
: `The call produced "${proposed}" but ${deal.name} has no next step set.`,
|
|
532
|
+
recommendation: "Review the evidence and approve the next-step update.",
|
|
533
|
+
evidenceIds,
|
|
534
|
+
currentCrmValue: current || null,
|
|
535
|
+
proposedValue: proposed,
|
|
536
|
+
});
|
|
537
|
+
operations.push({
|
|
538
|
+
id: `op_${parsed.id.replace(/^call_/, "")}_next`,
|
|
539
|
+
objectType: "deal",
|
|
540
|
+
objectId: deal.id,
|
|
541
|
+
operation: "set_field",
|
|
542
|
+
field: "nextStep",
|
|
543
|
+
beforeValue: current || null,
|
|
544
|
+
afterValue: proposed,
|
|
545
|
+
reason: `Call evidence: ${nextStepEvidence[0]?.text.slice(0, 200) ?? proposed}`,
|
|
546
|
+
sourceRuleOrPolicy: "call_intelligence.next_step",
|
|
547
|
+
riskLevel: "high",
|
|
548
|
+
approvalRequired: true,
|
|
549
|
+
rollback: "Restore the previous deal next step (the before value) if the update is wrong.",
|
|
550
|
+
evidenceIds,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
for (const [index, extra] of extraNextSteps.entries()) {
|
|
554
|
+
operations.push({
|
|
555
|
+
id: `op_${parsed.id.replace(/^call_/, "")}_task${index}`,
|
|
556
|
+
objectType: "deal",
|
|
557
|
+
objectId: deal.id,
|
|
558
|
+
operation: "create_task",
|
|
559
|
+
field: "follow_up_task",
|
|
560
|
+
beforeValue: null,
|
|
561
|
+
afterValue: extra.text.trim().slice(0, 255),
|
|
562
|
+
reason: `Additional commitment from the call: ${extra.evidence.slice(0, 160)}`,
|
|
563
|
+
sourceRuleOrPolicy: "call_intelligence.follow_up",
|
|
564
|
+
riskLevel: "low",
|
|
565
|
+
approvalRequired: true,
|
|
566
|
+
rollback: "Close or delete the created task.",
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
id: `patch_plan_${parsed.id.replace(/^call_/, "")}${deal.id.slice(-4)}`,
|
|
571
|
+
title: `Call evidence plan${parsed.title ? ` — ${parsed.title}` : ""} → ${deal.name}`,
|
|
572
|
+
createdAt: new Date().toISOString(),
|
|
573
|
+
status: "needs_approval",
|
|
574
|
+
dryRun: true,
|
|
575
|
+
summary: `${findings.length} finding(s) and ${operations.length} proposed operation(s) from call ${parsed.id}.`,
|
|
576
|
+
findings,
|
|
577
|
+
evidence: parsed.evidence,
|
|
578
|
+
operations,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
406
581
|
async function suggest(args) {
|
|
407
582
|
const planId = option(args, "--plan-id");
|
|
408
583
|
const planPath = option(args, "--plan");
|
|
@@ -1123,6 +1298,10 @@ export async function runCli(argv) {
|
|
|
1123
1298
|
await suggest(args);
|
|
1124
1299
|
return;
|
|
1125
1300
|
}
|
|
1301
|
+
if (command === "call") {
|
|
1302
|
+
await callCommand(args);
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1126
1305
|
if (command === "profiles") {
|
|
1127
1306
|
profilesCommand(args);
|
|
1128
1307
|
return;
|
|
@@ -440,6 +440,74 @@ export function createHubspotConnector(options) {
|
|
|
440
440
|
detail: `Archived ${objectPath}/${operation.objectId}.`,
|
|
441
441
|
};
|
|
442
442
|
}
|
|
443
|
+
/**
|
|
444
|
+
* Merge a duplicate group into the approved survivor via HubSpot's v3
|
|
445
|
+
* merge API (supported for contacts, companies, deals, and tickets).
|
|
446
|
+
* Merges are pairwise and IRREVERSIBLE; the survivor's values win on
|
|
447
|
+
* conflict and each loser is archived by HubSpot. A loser that is already
|
|
448
|
+
* gone (404 — e.g. a replayed plan) is treated as already merged.
|
|
449
|
+
*/
|
|
450
|
+
async function mergeRecords(operation) {
|
|
451
|
+
const objectPath = OBJECT_PATHS[operation.objectType];
|
|
452
|
+
if (!objectPath || operation.objectType === "user" || operation.objectType === "activity") {
|
|
453
|
+
return {
|
|
454
|
+
operationId: operation.id,
|
|
455
|
+
status: "skipped",
|
|
456
|
+
detail: "merge_records is supported for accounts, contacts, and deals.",
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
const survivorId = String(operation.afterValue ?? "");
|
|
460
|
+
const groupIds = Array.isArray(operation.beforeValue)
|
|
461
|
+
? operation.beforeValue.map((id) => String(id))
|
|
462
|
+
: [];
|
|
463
|
+
if (!survivorId || groupIds.length < 2) {
|
|
464
|
+
return {
|
|
465
|
+
operationId: operation.id,
|
|
466
|
+
status: "skipped",
|
|
467
|
+
detail: "merge_records needs a survivor id (afterValue) and the duplicate group ids (beforeValue).",
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
if (!groupIds.includes(survivorId)) {
|
|
471
|
+
return {
|
|
472
|
+
operationId: operation.id,
|
|
473
|
+
status: "skipped",
|
|
474
|
+
detail: `Survivor ${survivorId} is not in the duplicate group (${groupIds.join(", ")}); refusing to merge into an unrelated record.`,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
const losers = groupIds.filter((id) => id !== survivorId);
|
|
478
|
+
const mergedIds = [];
|
|
479
|
+
const alreadyGoneIds = [];
|
|
480
|
+
for (const loser of losers) {
|
|
481
|
+
try {
|
|
482
|
+
await request(`/crm/v3/objects/${objectPath}/merge`, {
|
|
483
|
+
method: "POST",
|
|
484
|
+
body: JSON.stringify({ primaryObjectId: survivorId, objectIdToMerge: loser }),
|
|
485
|
+
});
|
|
486
|
+
mergedIds.push(loser);
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
490
|
+
if (message.includes(" 404")) {
|
|
491
|
+
alreadyGoneIds.push(loser); // replayed plan: loser already merged/archived
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
return {
|
|
495
|
+
operationId: operation.id,
|
|
496
|
+
status: "failed",
|
|
497
|
+
detail: `Merged ${mergedIds.length} of ${losers.length} into ${survivorId}, then failed on ${loser}: ${message}`,
|
|
498
|
+
providerData: { survivorId, mergedIds, alreadyGoneIds },
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
operationId: operation.id,
|
|
504
|
+
status: mergedIds.length === 0 && alreadyGoneIds.length === losers.length ? "skipped" : "applied",
|
|
505
|
+
detail: mergedIds.length === 0 && alreadyGoneIds.length === losers.length
|
|
506
|
+
? `All ${losers.length} duplicates were already merged into ${survivorId}; nothing to do.`
|
|
507
|
+
: `Merged ${mergedIds.length} duplicate ${objectPath} into ${survivorId}${alreadyGoneIds.length ? ` (${alreadyGoneIds.length} already gone)` : ""}. Irreversible.`,
|
|
508
|
+
providerData: { survivorId, mergedIds, alreadyGoneIds },
|
|
509
|
+
};
|
|
510
|
+
}
|
|
443
511
|
async function applyOperation(operation) {
|
|
444
512
|
try {
|
|
445
513
|
switch (operation.operation) {
|
|
@@ -450,6 +518,8 @@ export function createHubspotConnector(options) {
|
|
|
450
518
|
return await linkRecord(operation);
|
|
451
519
|
case "create_task":
|
|
452
520
|
return await createTask(operation);
|
|
521
|
+
case "merge_records":
|
|
522
|
+
return await mergeRecords(operation);
|
|
453
523
|
case "archive_record":
|
|
454
524
|
return await archiveRecord(operation);
|
|
455
525
|
default:
|
|
@@ -338,6 +338,15 @@ export function createSalesforceConnector(options) {
|
|
|
338
338
|
}
|
|
339
339
|
case "create_task":
|
|
340
340
|
return await createTask(operation);
|
|
341
|
+
case "merge_records":
|
|
342
|
+
// Salesforce merge exists only in the SOAP API and Apex (Lead,
|
|
343
|
+
// Contact, Account, Case; max 3 records) — there is no REST merge
|
|
344
|
+
// resource. Surface that honestly instead of half-merging.
|
|
345
|
+
return {
|
|
346
|
+
operationId: operation.id,
|
|
347
|
+
status: "skipped",
|
|
348
|
+
detail: "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.",
|
|
349
|
+
};
|
|
341
350
|
case "archive_record":
|
|
342
351
|
return await archiveRecord(operation);
|
|
343
352
|
default:
|
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
|
15
15
|
export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
|
|
16
16
|
export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, type CrmObjectType, type FieldMappings, } from "./mappings.ts";
|
|
17
17
|
export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.ts";
|
|
18
|
+
export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, type CallDealSuggestion, type CallInsightType, type ExtractedCallInsight, type ParsedCall, type ParsedTranscriptSegment, } from "./calls.ts";
|
|
18
19
|
export { sampleSnapshot } from "./sampleData.ts";
|
|
19
20
|
export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
|
|
20
21
|
export type { ApprovalStatus, AuditFinding, AuditFindingSeverity, CanonicalAccount, CanonicalActivity, CanonicalContact, CanonicalDeal, CanonicalGtmSnapshot, CanonicalUser, CrmProvider, GtmAuditRule, GtmConnector, GtmEvidence, GtmEvidenceSourceSystem, GtmObjectType, GtmPolicy, GtmRuleContext, GtmRuleResult, GtmSnapshotIndex, PatchOperation, PatchOperationResult, PatchOperationType, PatchPlan, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
|
package/dist/index.js
CHANGED
|
@@ -15,5 +15,6 @@ export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
|
|
|
15
15
|
export { auditReportToHtml, auditReportToMarkdown } from "./report.js";
|
|
16
16
|
export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, } from "./mappings.js";
|
|
17
17
|
export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.js";
|
|
18
|
+
export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, } from "./calls.js";
|
|
18
19
|
export { sampleSnapshot } from "./sampleData.js";
|
|
19
20
|
export { suggestValues } from "./suggest.js";
|
package/dist/mcp.js
CHANGED
|
@@ -45,6 +45,7 @@ import { generateDemoSnapshot } from "./demo.js";
|
|
|
45
45
|
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
|
|
46
46
|
import { builtinAuditRules } from "./rules.js";
|
|
47
47
|
import { sampleSnapshot } from "./sampleData.js";
|
|
48
|
+
import { parseCall } from "./calls.js";
|
|
48
49
|
import { suggestValues } from "./suggest.js";
|
|
49
50
|
function content(value) {
|
|
50
51
|
return {
|
|
@@ -156,6 +157,25 @@ export async function startMcpServer() {
|
|
|
156
157
|
const snapshot = await readSnapshot(provider, inputPath);
|
|
157
158
|
return content({ suggestions: suggestValues(plan, snapshot) });
|
|
158
159
|
});
|
|
160
|
+
server.registerTool("fullstackgtm_call_parse", {
|
|
161
|
+
title: "Parse Call Transcript",
|
|
162
|
+
description: "Deterministically parse a call transcript (Speaker:/[Speaker]: lines or Granola " +
|
|
163
|
+
"utterance JSON) into canonical segments, keyword-derived insights (next steps, " +
|
|
164
|
+
"objections, pricing, risks, competitor mentions...), and GtmEvidence records. " +
|
|
165
|
+
"Read-only and LLM-free; pair with fullstackgtm_audit/apply for governed writes.",
|
|
166
|
+
inputSchema: {
|
|
167
|
+
transcript: z.string().optional(),
|
|
168
|
+
transcriptPath: z.string().optional(),
|
|
169
|
+
title: z.string().optional(),
|
|
170
|
+
source: z.enum(["gong", "chorus", "fathom", "manual", "csv", "unknown"]).optional(),
|
|
171
|
+
},
|
|
172
|
+
}, async ({ transcript, transcriptPath, title, source }) => {
|
|
173
|
+
const raw = transcript ??
|
|
174
|
+
(transcriptPath ? readFileSync(resolve(process.cwd(), transcriptPath), "utf8") : null);
|
|
175
|
+
if (!raw)
|
|
176
|
+
throw new Error("Provide transcript (text) or transcriptPath (file).");
|
|
177
|
+
return content(parseCall(raw, { title, sourceSystem: source }));
|
|
178
|
+
});
|
|
159
179
|
server.registerTool("fullstackgtm_rules", {
|
|
160
180
|
title: "List Audit Rules",
|
|
161
181
|
description: "List the built-in deterministic audit rules with ids and descriptions.",
|
package/dist/rules.js
CHANGED
|
@@ -333,13 +333,14 @@ export const duplicateAccountDomainRule = {
|
|
|
333
333
|
id: patchOperationId("duplicate-account-domain", anchor.id),
|
|
334
334
|
objectType: "account",
|
|
335
335
|
objectId: anchor.id,
|
|
336
|
-
operation: "
|
|
337
|
-
field: "
|
|
338
|
-
beforeValue:
|
|
339
|
-
afterValue:
|
|
340
|
-
reason:
|
|
341
|
-
riskLevel: "
|
|
336
|
+
operation: "merge_records",
|
|
337
|
+
field: "merge",
|
|
338
|
+
beforeValue: accounts.map((account) => account.id),
|
|
339
|
+
afterValue: "requires_human_survivor_selection",
|
|
340
|
+
reason: `Duplicate accounts split pipeline, attribution, and ownership. Merge the ${accounts.length} accounts sharing ${domain} into one survivor.`,
|
|
341
|
+
riskLevel: "high",
|
|
342
342
|
approvalRequired: true,
|
|
343
|
+
rollback: "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.",
|
|
343
344
|
});
|
|
344
345
|
}
|
|
345
346
|
return { findings, operations };
|
|
@@ -369,13 +370,14 @@ export const duplicateContactEmailRule = {
|
|
|
369
370
|
id: patchOperationId("duplicate-contact-email", anchor.id),
|
|
370
371
|
objectType: "contact",
|
|
371
372
|
objectId: anchor.id,
|
|
372
|
-
operation: "
|
|
373
|
-
field: "
|
|
374
|
-
beforeValue:
|
|
375
|
-
afterValue:
|
|
376
|
-
reason:
|
|
377
|
-
riskLevel: "
|
|
373
|
+
operation: "merge_records",
|
|
374
|
+
field: "merge",
|
|
375
|
+
beforeValue: contacts.map((contact) => contact.id),
|
|
376
|
+
afterValue: "requires_human_survivor_selection",
|
|
377
|
+
reason: `Duplicate contacts fragment engagement history and double-route outreach. Merge the ${contacts.length} contacts sharing ${email} into one survivor.`,
|
|
378
|
+
riskLevel: "high",
|
|
378
379
|
approvalRequired: true,
|
|
380
|
+
rollback: "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.",
|
|
379
381
|
});
|
|
380
382
|
}
|
|
381
383
|
return { findings, operations };
|
|
@@ -415,13 +417,14 @@ export const duplicateOpenDealRule = {
|
|
|
415
417
|
id: patchOperationId("duplicate-open-deal", anchor.id),
|
|
416
418
|
objectType: "deal",
|
|
417
419
|
objectId: anchor.id,
|
|
418
|
-
operation: "
|
|
419
|
-
field: "
|
|
420
|
-
beforeValue:
|
|
421
|
-
afterValue:
|
|
422
|
-
reason:
|
|
423
|
-
riskLevel: "
|
|
420
|
+
operation: "merge_records",
|
|
421
|
+
field: "merge",
|
|
422
|
+
beforeValue: deals.map((deal) => deal.id),
|
|
423
|
+
afterValue: "requires_human_survivor_selection",
|
|
424
|
+
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.`,
|
|
425
|
+
riskLevel: "high",
|
|
424
426
|
approvalRequired: true,
|
|
427
|
+
rollback: "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.",
|
|
425
428
|
});
|
|
426
429
|
}
|
|
427
430
|
return { findings, operations };
|
package/dist/suggest.js
CHANGED
|
@@ -22,6 +22,10 @@ export function suggestValues(plan, snapshot) {
|
|
|
22
22
|
suggestions.push(suggestDealAccount(operation, dealsById, accountsByNorm, accountsById, contactsByName));
|
|
23
23
|
continue;
|
|
24
24
|
}
|
|
25
|
+
if (placeholder === "requires_human_survivor_selection") {
|
|
26
|
+
suggestions.push(suggestSurvivor(operation, snapshot, dealsById));
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
25
29
|
if (placeholder === "requires_human_owner_selection" && activeUsers.length === 1) {
|
|
26
30
|
suggestions.push({
|
|
27
31
|
operationId: operation.id,
|
|
@@ -140,6 +144,79 @@ function suggestDealAccount(operation, dealsById, accountsByNorm, accountsById,
|
|
|
140
144
|
: `Deal name "${deal.name}" has no "Contact - Company" pattern to derive a company from. Supply --value ${operation.id}=<accountId> or create:<Company Name>.`,
|
|
141
145
|
};
|
|
142
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Survivor selection for merge_records. Ranking is deterministic and
|
|
149
|
+
* evidence-based: most complete record first (count of populated canonical
|
|
150
|
+
* fields), most recent activity as the tiebreaker, group order last.
|
|
151
|
+
* Confidence is capped at "low" by design: merges are irreversible, so a
|
|
152
|
+
* survivor suggestion must never clear the default bulk-approval bar —
|
|
153
|
+
* accepting one requires --min-confidence low or an explicit --value.
|
|
154
|
+
*/
|
|
155
|
+
function suggestSurvivor(operation, snapshot, dealsById) {
|
|
156
|
+
const base = {
|
|
157
|
+
operationId: operation.id,
|
|
158
|
+
objectType: operation.objectType,
|
|
159
|
+
objectId: operation.objectId,
|
|
160
|
+
objectName: dealsById.get(operation.objectId)?.name,
|
|
161
|
+
placeholder: "requires_human_survivor_selection",
|
|
162
|
+
};
|
|
163
|
+
const groupIds = Array.isArray(operation.beforeValue)
|
|
164
|
+
? operation.beforeValue.map((id) => String(id))
|
|
165
|
+
: [];
|
|
166
|
+
if (groupIds.length < 2) {
|
|
167
|
+
return {
|
|
168
|
+
...base,
|
|
169
|
+
suggestedValue: null,
|
|
170
|
+
confidence: "none",
|
|
171
|
+
reason: "Operation does not carry a duplicate group (expected ids in beforeValue).",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const collection = operation.objectType === "account"
|
|
175
|
+
? snapshot.accounts
|
|
176
|
+
: operation.objectType === "contact"
|
|
177
|
+
? snapshot.contacts
|
|
178
|
+
: operation.objectType === "deal"
|
|
179
|
+
? snapshot.deals
|
|
180
|
+
: [];
|
|
181
|
+
const records = groupIds
|
|
182
|
+
.map((id) => collection.find((row) => row.id === id))
|
|
183
|
+
.filter((row) => row !== undefined);
|
|
184
|
+
if (records.length !== groupIds.length) {
|
|
185
|
+
return {
|
|
186
|
+
...base,
|
|
187
|
+
suggestedValue: null,
|
|
188
|
+
confidence: "none",
|
|
189
|
+
reason: "Not every group member is present in the snapshot; re-run the audit before merging.",
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const ranked = [...records].sort((a, b) => {
|
|
193
|
+
const completeness = populatedFields(b) - populatedFields(a);
|
|
194
|
+
if (completeness !== 0)
|
|
195
|
+
return completeness;
|
|
196
|
+
return activityMs(b) - activityMs(a);
|
|
197
|
+
});
|
|
198
|
+
const winner = ranked[0];
|
|
199
|
+
const runnerUp = ranked[1];
|
|
200
|
+
const name = "name" in winner && typeof winner.name === "string" ? winner.name : winner.id;
|
|
201
|
+
return {
|
|
202
|
+
...base,
|
|
203
|
+
suggestedValue: winner.id,
|
|
204
|
+
confidence: "low",
|
|
205
|
+
reason: `"${name}" (${winner.id}) is the most complete record in the group ` +
|
|
206
|
+
`(${populatedFields(winner)} populated fields vs ${populatedFields(runnerUp)}` +
|
|
207
|
+
`${activityMs(winner) > activityMs(runnerUp) ? ", and more recent activity" : ""}). ` +
|
|
208
|
+
`Merging is IRREVERSIBLE — survivor suggestions never exceed low confidence; ` +
|
|
209
|
+
`approve with --min-confidence low or an explicit --value after review.`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function populatedFields(record) {
|
|
213
|
+
return Object.values(record).filter((value) => value !== undefined && value !== null && value !== "").length;
|
|
214
|
+
}
|
|
215
|
+
function activityMs(record) {
|
|
216
|
+
const value = record.lastActivityAt;
|
|
217
|
+
const parsed = value ? Date.parse(value) : NaN;
|
|
218
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
219
|
+
}
|
|
143
220
|
function normalize(value) {
|
|
144
221
|
return value
|
|
145
222
|
.toLowerCase()
|
package/dist/types.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export type RiskLevel = "low" | "medium" | "high";
|
|
|
10
10
|
export type ApprovalStatus = "draft" | "needs_approval" | "approved" | "rejected" | "applied";
|
|
11
11
|
export type GtmObjectType = "account" | "contact" | "deal" | "user" | "activity";
|
|
12
12
|
export type GtmEvidenceSourceSystem = "salesforce" | "hubspot" | "gong" | "chorus" | "fathom" | "manual" | "csv" | "mock" | "unknown";
|
|
13
|
-
export type PatchOperationType = "set_field" | "clear_field" | "link_record" | "archive_record" | "create_task";
|
|
13
|
+
export type PatchOperationType = "set_field" | "clear_field" | "link_record" | "archive_record" | "create_task" | "merge_records";
|
|
14
14
|
export type AuditFindingSeverity = "info" | "warning" | "critical";
|
|
15
15
|
/**
|
|
16
16
|
* One claim that a canonical record exists in an external system. A record
|
package/docs/api.md
CHANGED
|
@@ -40,7 +40,7 @@ release.
|
|
|
40
40
|
- `GtmConnector` — `{ provider, fetchSnapshot(), applyOperation?, readField?, fetchChanges? }`.
|
|
41
41
|
- Connectors never silently drop unresolvable records; audits surface them.
|
|
42
42
|
- `fetchChanges(sinceIso)` returns a partial snapshot; change feeds may omit associations.
|
|
43
|
-
- `createHubspotConnector(options)` — read/write/readField/fetchChanges. `applyOperation` implements every `PatchOperationType`: `set_field`, `clear_field`, `link_record`, `create_task`, `archive_record
|
|
43
|
+
- `createHubspotConnector(options)` — read/write/readField/fetchChanges. `applyOperation` implements every `PatchOperationType`: `set_field`, `clear_field`, `link_record`, `create_task`, `archive_record`, `merge_records` (HubSpot v3 merge — pairwise, irreversible; survivor must belong to the duplicate group). The Salesforce connector skips `merge_records` honestly (SOAP/Apex-only on that platform).
|
|
44
44
|
- `createSalesforceConnector(options)` — read/write/readField/fetchChanges; probabilities normalized to 0..1; `applyOperation` implements every operation type.
|
|
45
45
|
- `createStripeConnector(options)` — read-only billing by design (`applyOperation` returns `skipped`); email domains are the cross-system merge keys. Implements `fetchChanges` (incremental via `created[gte]`).
|
|
46
46
|
|
|
@@ -130,6 +130,6 @@ Lessons from auditing our own apply path:
|
|
|
130
130
|
| Release | Scope |
|
|
131
131
|
| --- | --- |
|
|
132
132
|
| 0.11.1 | Fix our own faucet: resolve-first `create:` + plan-scoped dedup, HubSpot association-aware CAS for `link_record`, domain normalization in `duplicate-account-domain`, `create_task` idempotency token |
|
|
133
|
-
| 0.12 | `merge_records` (HubSpot contacts/companies/deals) + survivor suggestions; rules emit governed merges
|
|
133
|
+
| 0.12 (shipped) | `merge_records` (HubSpot contacts/companies/deals) + survivor suggestions capped at low confidence; the three duplicate rules emit governed merges instead of review tasks |
|
|
134
134
|
| 0.13 | `resolve` gate (CLI/lib/MCP), provenance capture + attribution in findings, prevention-posture checks |
|
|
135
135
|
| docs | The nightly watch recipe (existing flags, documented as CRM CI) |
|
package/llms.txt
CHANGED
|
@@ -18,6 +18,13 @@ at/above `--fail-on`.
|
|
|
18
18
|
- [CRM-health lifecycle](https://github.com/fullstackgtm/core/blob/main/docs/crm-health-lifecycle.md): the Prevent → Detect → Remediate → Verify/Attribute model; no-new-dupes design
|
|
19
19
|
- [CHANGELOG](https://github.com/fullstackgtm/core/blob/main/CHANGELOG.md): release history
|
|
20
20
|
|
|
21
|
+
## Key invariants (calls)
|
|
22
|
+
|
|
23
|
+
`fullstackgtm call parse` (and MCP fullstackgtm_call_parse) is deterministic
|
|
24
|
+
and LLM-free; `call link` suggests the deal with confidence + reason;
|
|
25
|
+
`call plan` proposes governed next-step writes through the standard
|
|
26
|
+
approve/apply lifecycle.
|
|
27
|
+
|
|
21
28
|
## Key invariants
|
|
22
29
|
|
|
23
30
|
- 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.13.0",
|
|
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",
|