fullstackgtm 0.46.0 → 0.48.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +44 -6
  2. package/INSTALL_FOR_AGENTS.md +3 -3
  3. package/README.md +16 -8
  4. package/dist/audit.js +7 -0
  5. package/dist/backfill.js +2 -16
  6. package/dist/cli/fix.d.ts +3 -0
  7. package/dist/cli/fix.js +70 -1
  8. package/dist/cli/help.d.ts +6 -0
  9. package/dist/cli/help.js +76 -2
  10. package/dist/cli/suggest.d.ts +2 -1
  11. package/dist/cli/suggest.js +49 -3
  12. package/dist/cli.js +40 -15
  13. package/dist/connector.js +96 -25
  14. package/dist/connectors/hubspot.d.ts +2 -1
  15. package/dist/connectors/hubspot.js +170 -4
  16. package/dist/connectors/salesforce.d.ts +2 -1
  17. package/dist/connectors/salesforce.js +135 -0
  18. package/dist/connectors/signalSources.js +2 -11
  19. package/dist/format.js +31 -5
  20. package/dist/freeEmailDomains.d.ts +1 -0
  21. package/dist/freeEmailDomains.js +14 -0
  22. package/dist/hierarchy.d.ts +29 -0
  23. package/dist/hierarchy.js +164 -0
  24. package/dist/index.d.ts +6 -2
  25. package/dist/index.js +5 -1
  26. package/dist/mcp.js +19 -15
  27. package/dist/relationships.d.ts +39 -0
  28. package/dist/relationships.js +101 -0
  29. package/dist/route.d.ts +46 -0
  30. package/dist/route.js +228 -0
  31. package/dist/rules.d.ts +1 -0
  32. package/dist/rules.js +172 -12
  33. package/dist/types.d.ts +30 -0
  34. package/docs/api.md +22 -4
  35. package/docs/architecture.md +5 -2
  36. package/package.json +1 -1
  37. package/skills/fullstackgtm/SKILL.md +2 -2
  38. package/src/audit.ts +7 -0
  39. package/src/backfill.ts +2 -17
  40. package/src/cli/fix.ts +68 -1
  41. package/src/cli/help.ts +83 -2
  42. package/src/cli/suggest.ts +58 -4
  43. package/src/cli.ts +38 -13
  44. package/src/connector.ts +94 -20
  45. package/src/connectors/hubspot.ts +164 -5
  46. package/src/connectors/salesforce.ts +136 -1
  47. package/src/connectors/signalSources.ts +2 -12
  48. package/src/format.ts +33 -5
  49. package/src/freeEmailDomains.ts +14 -0
  50. package/src/hierarchy.ts +185 -0
  51. package/src/index.ts +25 -0
  52. package/src/mcp.ts +22 -16
  53. package/src/relationships.ts +115 -0
  54. package/src/route.ts +278 -0
  55. package/src/rules.ts +192 -12
  56. package/src/types.ts +32 -0
package/dist/format.js CHANGED
@@ -1,8 +1,7 @@
1
- // `summary: true` renders only the header + the Findings-by-Rule table + an
2
- // operation count, for read verbs like `audit` where the full per-operation
3
- // dump (1,400+ lines on a real portal) buries the signal. Defaults to the full
4
- // view so write-preview callers (bulk-update, fix, dedupe, plans show, MCP) —
5
- // where you approve specific operations — keep every operation's detail.
1
+ // `summary: true` renders the compact audit view: header, findings summary,
2
+ // assumptions/open questions, and an operation count. Defaults to the full view
3
+ // so write-preview callers (bulk-update, fix, dedupe, plans show, MCP) where
4
+ // you approve specific operations keep every operation's detail.
6
5
  export function patchPlanToMarkdown(plan, opts = {}) {
7
6
  const lines = [
8
7
  `# ${plan.title}`,
@@ -25,6 +24,7 @@ export function patchPlanToMarkdown(plan, opts = {}) {
25
24
  });
26
25
  lines.push("");
27
26
  }
27
+ appendAssumptionsAndDecisionPoints(lines, plan);
28
28
  if (opts.summary) {
29
29
  const ops = plan.operations.length;
30
30
  lines.push(ops === 0
@@ -96,6 +96,32 @@ export function formatPatchPlanRun(run) {
96
96
  }
97
97
  return `${lines.join("\n")}\n`;
98
98
  }
99
+ function appendAssumptionsAndDecisionPoints(lines, plan) {
100
+ const assumptions = [...(plan.assumptions ?? [])].sort((left, right) => {
101
+ if (left.confidence === "guess" && right.confidence !== "guess")
102
+ return -1;
103
+ if (left.confidence !== "guess" && right.confidence === "guess")
104
+ return 1;
105
+ return 0;
106
+ });
107
+ const openQuestions = plan.openQuestions ?? [];
108
+ if (assumptions.length === 0 && openQuestions.length === 0)
109
+ return;
110
+ lines.push("## Assumptions & decision points", "");
111
+ for (const assumption of assumptions) {
112
+ const warning = assumption.confidence === "guess" ? "⚠️ " : "";
113
+ lines.push(`- ${warning}${assumption.text}${assumption.source ? ` _(source: ${assumption.source})_` : ""}`);
114
+ }
115
+ if (openQuestions.length > 0) {
116
+ if (assumptions.length > 0)
117
+ lines.push("");
118
+ lines.push("### Open questions", "");
119
+ for (const question of openQuestions) {
120
+ lines.push(`- ${question}`);
121
+ }
122
+ }
123
+ lines.push("");
124
+ }
99
125
  function formatValue(value) {
100
126
  if (value === undefined || value === null || value === "")
101
127
  return "unset";
@@ -0,0 +1 @@
1
+ export declare const FREE_EMAIL_DOMAINS: Set<string>;
@@ -0,0 +1,14 @@
1
+ // Shared free-mail domains that should never be treated as company domains.
2
+ export const FREE_EMAIL_DOMAINS = new Set([
3
+ "gmail.com",
4
+ "googlemail.com",
5
+ "yahoo.com",
6
+ "outlook.com",
7
+ "hotmail.com",
8
+ "live.com",
9
+ "icloud.com",
10
+ "me.com",
11
+ "aol.com",
12
+ "proton.me",
13
+ "protonmail.com",
14
+ ]);
@@ -0,0 +1,29 @@
1
+ import type { CanonicalGtmSnapshot } from "./types.ts";
2
+ export type AccountHierarchyNode = {
3
+ accountId: string;
4
+ name: string;
5
+ domain?: string;
6
+ parentAccountId?: string;
7
+ parentReason?: string;
8
+ children: AccountHierarchyNode[];
9
+ };
10
+ export type AccountHierarchyConflict = {
11
+ type: "duplicate_domain" | "ambiguous_parent" | "cycle";
12
+ accountIds: string[];
13
+ key: string;
14
+ detail: string;
15
+ };
16
+ export type AccountHierarchyReport = {
17
+ generatedAt: string;
18
+ roots: AccountHierarchyNode[];
19
+ orphanAccounts: string[];
20
+ conflicts: AccountHierarchyConflict[];
21
+ counts: {
22
+ accounts: number;
23
+ roots: number;
24
+ linkedChildren: number;
25
+ conflicts: number;
26
+ };
27
+ };
28
+ export declare function buildAccountHierarchy(snapshot: CanonicalGtmSnapshot): AccountHierarchyReport;
29
+ export declare function accountHierarchyToMarkdown(report: AccountHierarchyReport): string;
@@ -0,0 +1,164 @@
1
+ import { normalizeDomain } from "./merge.js";
2
+ function rawString(account, keys) {
3
+ if (!account.raw || typeof account.raw !== "object")
4
+ return undefined;
5
+ const raw = account.raw;
6
+ for (const key of keys) {
7
+ const value = raw[key];
8
+ if (typeof value === "string" && value.trim())
9
+ return value.trim();
10
+ if (value && typeof value === "object") {
11
+ const nested = value;
12
+ for (const nestedKey of ["id", "value", "name"]) {
13
+ if (typeof nested[nestedKey] === "string" && String(nested[nestedKey]).trim())
14
+ return String(nested[nestedKey]).trim();
15
+ }
16
+ }
17
+ }
18
+ return undefined;
19
+ }
20
+ function parentHint(account) {
21
+ return rawString(account, ["parentAccountId", "parent_account_id", "ParentId", "parentCompanyId", "hs_parent_company_id"]);
22
+ }
23
+ function cloneNode(account) {
24
+ return { accountId: String(account.id), name: account.name, ...(account.domain ? { domain: account.domain } : {}), children: [] };
25
+ }
26
+ function looksLikePublicSuffix(domain) {
27
+ const parts = domain.split(".");
28
+ if (parts.length !== 2)
29
+ return false;
30
+ const [label, tld] = parts;
31
+ return tld.length === 2 && ["ac", "co", "com", "edu", "gov", "net", "org"].includes(label);
32
+ }
33
+ function domainParent(childDomain, domains) {
34
+ const parts = childDomain.split(".");
35
+ for (let i = 1; i < parts.length - 1; i++) {
36
+ const candidate = parts.slice(i).join(".");
37
+ if (candidate.split(".").length < 2 || looksLikePublicSuffix(candidate))
38
+ continue;
39
+ const unique = new Map((domains.get(candidate) ?? []).map((account) => [String(account.id), account]));
40
+ if (unique.size > 1)
41
+ return "ambiguous";
42
+ if (unique.size === 1)
43
+ return [...unique.values()][0];
44
+ }
45
+ return null;
46
+ }
47
+ function detectCycles(parentByChild) {
48
+ const cyclic = new Set();
49
+ const cycles = [];
50
+ const emitted = new Set();
51
+ for (const start of parentByChild.keys()) {
52
+ const path = [];
53
+ const indexById = new Map();
54
+ let cursor = start;
55
+ while (cursor && parentByChild.has(cursor)) {
56
+ const seenAt = indexById.get(cursor);
57
+ if (seenAt !== undefined) {
58
+ const cycle = path.slice(seenAt);
59
+ for (const id of cycle)
60
+ cyclic.add(id);
61
+ const key = [...cycle].sort().join("|");
62
+ if (!emitted.has(key)) {
63
+ emitted.add(key);
64
+ cycles.push(cycle);
65
+ }
66
+ break;
67
+ }
68
+ indexById.set(cursor, path.length);
69
+ path.push(cursor);
70
+ cursor = parentByChild.get(cursor)?.parentId;
71
+ }
72
+ }
73
+ return { accountIds: cyclic, cycles };
74
+ }
75
+ export function buildAccountHierarchy(snapshot) {
76
+ const byId = new Map(snapshot.accounts.map((account) => [String(account.id), account]));
77
+ const accounts = [...byId.values()];
78
+ const domains = new Map();
79
+ for (const account of accounts) {
80
+ const domain = normalizeDomain(account.domain);
81
+ if (domain)
82
+ domains.set(domain, [...(domains.get(domain) ?? []), account]);
83
+ }
84
+ const conflicts = [];
85
+ for (const [domain, accounts] of domains) {
86
+ const ids = [...new Set(accounts.map((account) => String(account.id)))];
87
+ if (ids.length > 1)
88
+ conflicts.push({ type: "duplicate_domain", accountIds: ids.sort(), key: domain, detail: `${ids.length} accounts share domain ${domain}; hierarchy inference will not choose between them.` });
89
+ }
90
+ const nodes = new Map(accounts.map((account) => [String(account.id), cloneNode(account)]));
91
+ const parentByChild = new Map();
92
+ for (const account of accounts) {
93
+ const id = String(account.id);
94
+ const hint = parentHint(account);
95
+ if (hint && byId.has(hint) && hint !== id) {
96
+ parentByChild.set(id, { parentId: hint, reason: "provider-parent" });
97
+ continue;
98
+ }
99
+ const domain = normalizeDomain(account.domain);
100
+ if (!domain)
101
+ continue;
102
+ const inferred = domainParent(domain, domains);
103
+ if (inferred === "ambiguous")
104
+ conflicts.push({ type: "ambiguous_parent", accountIds: [id], key: domain, detail: `Multiple possible parent accounts found for ${domain}; leaving account as a root.` });
105
+ else if (inferred && String(inferred.id) !== id)
106
+ parentByChild.set(id, { parentId: String(inferred.id), reason: "domain-subdomain" });
107
+ }
108
+ const cyclicAccounts = detectCycles(parentByChild);
109
+ for (const cycle of cyclicAccounts.cycles) {
110
+ const ordered = [...cycle].sort();
111
+ const key = ordered.join(" -> ");
112
+ conflicts.push({
113
+ type: "cycle",
114
+ accountIds: ordered,
115
+ key,
116
+ detail: `Cyclic parent hints detected among ${ordered.join(", ")}; leaving those accounts as roots instead of hiding them in a loop.`,
117
+ });
118
+ }
119
+ for (const accountId of cyclicAccounts.accountIds)
120
+ parentByChild.delete(accountId);
121
+ const roots = [];
122
+ const orphanAccounts = [];
123
+ const linkedChildren = new Set();
124
+ for (const account of accounts) {
125
+ const id = String(account.id);
126
+ const node = nodes.get(id);
127
+ const parent = parentByChild.get(id);
128
+ if (parent && nodes.has(parent.parentId)) {
129
+ node.parentAccountId = parent.parentId;
130
+ node.parentReason = parent.reason;
131
+ nodes.get(parent.parentId).children.push(node);
132
+ linkedChildren.add(id);
133
+ }
134
+ else {
135
+ roots.push(node);
136
+ if (!account.domain && !parentHint(account))
137
+ orphanAccounts.push(id);
138
+ }
139
+ }
140
+ const sortTree = (list) => {
141
+ list.sort((a, b) => a.name.localeCompare(b.name) || a.accountId.localeCompare(b.accountId));
142
+ for (const node of list)
143
+ sortTree(node.children);
144
+ };
145
+ sortTree(roots);
146
+ return { generatedAt: snapshot.generatedAt, roots, orphanAccounts: orphanAccounts.sort(), conflicts, counts: { accounts: accounts.length, roots: roots.length, linkedChildren: linkedChildren.size, conflicts: conflicts.length } };
147
+ }
148
+ export function accountHierarchyToMarkdown(report) {
149
+ const lines = ["# Account hierarchy", "", `${report.counts.accounts} account(s), ${report.counts.roots} root(s), ${report.counts.linkedChildren} inferred child link(s), ${report.counts.conflicts} conflict(s).`, ""];
150
+ const walk = (nodes, depth) => {
151
+ for (const node of nodes) {
152
+ const suffix = [node.domain, node.parentReason ? `via ${node.parentReason}` : undefined].filter(Boolean).join("; ");
153
+ lines.push(`${" ".repeat(depth)}- ${node.name} (${node.accountId}${suffix ? ` — ${suffix}` : ""})`);
154
+ walk(node.children, depth + 1);
155
+ }
156
+ };
157
+ walk(report.roots, 0);
158
+ if (report.conflicts.length) {
159
+ lines.push("", "## Conflicts");
160
+ for (const conflict of report.conflicts)
161
+ lines.push(`- ${conflict.type} ${conflict.key}: ${conflict.detail}`);
162
+ }
163
+ return lines.join("\n");
164
+ }
package/dist/index.d.ts CHANGED
@@ -3,6 +3,9 @@ export { matchesTerritory, parseAssignmentPolicy, resolveAssignment, type Assign
3
3
  export { buildBulkUpdatePlan, isFilterableField, parseWhere, type BulkUpdateOptions } from "./bulkUpdate.ts";
4
4
  export { buildDedupePlan, dedupeKey, type DedupeOptions } from "./dedupe.ts";
5
5
  export { buildReassignPlans, type ReassignObjectType, type ReassignOptions } from "./reassign.ts";
6
+ export { buildLeadRoutePlan, type LeadRouteCounts, type LeadRouteOptions, type LeadRouteResult, } from "./route.ts";
7
+ export { accountHierarchyToMarkdown, buildAccountHierarchy, type AccountHierarchyConflict, type AccountHierarchyNode, type AccountHierarchyReport, } from "./hierarchy.ts";
8
+ export { buildRelationshipMap, relationshipMapToMarkdown, type RelationshipMap, type StakeholderNode, type StakeholderRole, type StakeholderSentiment, } from "./relationships.ts";
6
9
  export { CONFIG_FILE_NAME, loadConfig, mergePolicy, resolveConfiguredRules, type FullstackgtmConfig, type LoadedConfig, } from "./config.ts";
7
10
  export { applyPatchPlan, type ApplyPatchPlanOptions } from "./connector.ts";
8
11
  export { APPLY_STAGES, BACKFILL_STRIPE_STAGES, composeListeners, createProgressEmitter, CRM_SYNC_STAGES, nullProgressEmitter, SNAPSHOT_PULL_STAGES, STRIPE_SNAPSHOT_STAGES, type ProgressEmitter, type ProgressEvent, type ProgressListener, type ProgressSnapshot, } from "./progress.ts";
@@ -23,11 +26,12 @@ export { mergeSnapshots, type MergeConflict, type MergeMatch, type MergeReport,
23
26
  export { createFilePlanStore, type PlanStore, type StoredPlan } from "./planStore.ts";
24
27
  export { computeApprovalDigests, loadOrCreateSigningKey, loadSigningKey, signApproval, verifyApprovalDigests, type ApprovalVerification, } from "./integrity.ts";
25
28
  export { buildAuditLog, verifyAuditLog, type AuditLogEntry, type AuditLogExport, type AuditLogVerification, } from "./auditLog.ts";
29
+ export { FREE_EMAIL_DOMAINS } from "./freeEmailDomains.ts";
26
30
  export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
27
31
  export { computeHealth, summarizeHealth, healthToMarkdown, type HealthEntry, type HealthRollup, type HealthRuleDelta, } from "./health.ts";
28
32
  export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
29
33
  export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, type CrmObjectType, type FieldMappings, } from "./mappings.ts";
30
- export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, provenanceSummary, requiresHumanInput, staleDealRule, } from "./rules.ts";
34
+ export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, isFindingsOnlyRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, provenanceSummary, requiresHumanInput, staleDealRule, } from "./rules.ts";
31
35
  export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, type CallDealSuggestion, type CallInsightType, type ExtractedCallInsight, type ParsedCall, type ParsedTranscriptSegment, } from "./calls.ts";
32
36
  export { sampleSnapshot } from "./sampleData.ts";
33
37
  export { DEFAULT_MODELS, DEFAULT_RUBRIC, classifyCallLlm, detectProviderFromKey, extractInsightsLlm, forcedToolCall, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, type CallScorecard, type LlmCallClassification, type LlmCredential, type LlmExtractedInsight, type LlmProvider, type Rubric, type RubricDimension, type ScoreBand, type ScoredDimension, } from "./llm.ts";
@@ -48,4 +52,4 @@ export { fetchAtsJobs, snippetFor, type AtsBoardSource, type AtsJob, } from "./c
48
52
  export { scoreBand, judgeRunId, normalizeSpan, isGroundedSpan, scoreAccount, accountRecentlyTouched, bestContactForAccount, deterministicDecision, deterministicWhyNow, JUDGE_SCHEMA, DEFAULT_JUDGE_PROMPT, judgeDecisionLlm, judgeSignals, judgeDir, createFileJudgeStore, type JudgeDecisionKind, type JudgeDecision, type JudgeRun, type JudgeBand, type AccountScore, type JudgeStore, } from "./judge.ts";
49
53
  export { draft, authorOpeners, draftOpenerLlm, deterministicOpener, creditedTriggerSignal, draftEvidence, sanitizeOpener, firstLine, firstLineGroundedInTrigger, hasBannedGreeting, isStaleTrigger, draftOperationId, draftEvidenceId, DRAFT_CHANNELS, DRAFT_SCHEMA, DEFAULT_DRAFT_PROMPT, DEFAULT_FRESHNESS_DAYS, type Draft, type RejectedDraft, type DraftResult, type DraftChannel, } from "./draft.ts";
50
54
  export { DEFAULT_GOLDEN_SET, DEFAULT_MIN_ACCURACY, HOT_SCORE, defaultJudgeFn, gradeAgainstOutcomes, gradeJudge, parseGoldenSet, type Confusion, type EvalJudgeFn, type GoldenHistory, type GoldenRow, type GradeResult, type OutcomeCalibration, } from "./judgeEval.ts";
51
- 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";
55
+ 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, PatchPlanAssumption, PatchPlanAssumptionConfidence, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
package/dist/index.js CHANGED
@@ -3,6 +3,9 @@ export { matchesTerritory, parseAssignmentPolicy, resolveAssignment, } from "./a
3
3
  export { buildBulkUpdatePlan, isFilterableField, parseWhere } from "./bulkUpdate.js";
4
4
  export { buildDedupePlan, dedupeKey } from "./dedupe.js";
5
5
  export { buildReassignPlans } from "./reassign.js";
6
+ export { buildLeadRoutePlan, } from "./route.js";
7
+ export { accountHierarchyToMarkdown, buildAccountHierarchy, } from "./hierarchy.js";
8
+ export { buildRelationshipMap, relationshipMapToMarkdown, } from "./relationships.js";
6
9
  export { CONFIG_FILE_NAME, loadConfig, mergePolicy, resolveConfiguredRules, } from "./config.js";
7
10
  export { applyPatchPlan } from "./connector.js";
8
11
  export { APPLY_STAGES, BACKFILL_STRIPE_STAGES, composeListeners, createProgressEmitter, CRM_SYNC_STAGES, nullProgressEmitter, SNAPSHOT_PULL_STAGES, STRIPE_SNAPSHOT_STAGES, } from "./progress.js";
@@ -23,11 +26,12 @@ export { mergeSnapshots, } from "./merge.js";
23
26
  export { createFilePlanStore } from "./planStore.js";
24
27
  export { computeApprovalDigests, loadOrCreateSigningKey, loadSigningKey, signApproval, verifyApprovalDigests, } from "./integrity.js";
25
28
  export { buildAuditLog, verifyAuditLog, } from "./auditLog.js";
29
+ export { FREE_EMAIL_DOMAINS } from "./freeEmailDomains.js";
26
30
  export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
27
31
  export { computeHealth, summarizeHealth, healthToMarkdown, } from "./health.js";
28
32
  export { auditReportToHtml, auditReportToMarkdown } from "./report.js";
29
33
  export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, } from "./mappings.js";
30
- export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, provenanceSummary, requiresHumanInput, staleDealRule, } from "./rules.js";
34
+ export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, isFindingsOnlyRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, provenanceSummary, requiresHumanInput, staleDealRule, } from "./rules.js";
31
35
  export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, } from "./calls.js";
32
36
  export { sampleSnapshot } from "./sampleData.js";
33
37
  export { DEFAULT_MODELS, DEFAULT_RUBRIC, classifyCallLlm, detectProviderFromKey, extractInsightsLlm, forcedToolCall, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
package/dist/mcp.js CHANGED
@@ -129,6 +129,9 @@ function packageVersion() {
129
129
  return "0.0.0";
130
130
  }
131
131
  }
132
+ function strictSchema(shape) {
133
+ return z.object(shape).strict();
134
+ }
132
135
  // Single registration table. startMcpServer() registers exactly this list,
133
136
  // and fullstackgtm_capabilities reports its names from the same array — the
134
137
  // advertised inventory cannot drift from what is actually registered.
@@ -143,7 +146,7 @@ const toolDefinitions = [
143
146
  description: "Run a dry-run GTM hygiene audit and return a reviewable patch plan. " +
144
147
  "Sources: the realistic zero-credential demo CRM (provider: \"demo\" — richest test data), " +
145
148
  "the minimal sample dataset, a snapshot file, or a live provider.",
146
- inputSchema: {
149
+ inputSchema: strictSchema({
147
150
  provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
148
151
  inputPath: z.string().optional(),
149
152
  configPath: z.string().optional(),
@@ -151,7 +154,7 @@ const toolDefinitions = [
151
154
  output: z.enum(["json", "markdown"]).optional(),
152
155
  today: z.string().optional(),
153
156
  staleDealDays: z.number().int().positive().optional(),
154
- },
157
+ }),
155
158
  },
156
159
  handler: async ({ provider, inputPath, configPath, rules, output, today, staleDealDays }) => {
157
160
  const loaded = configPath ? loadConfig(configPath) : null;
@@ -176,11 +179,11 @@ const toolDefinitions = [
176
179
  description: "Derive values for a plan's requires_human_* placeholder operations from snapshot " +
177
180
  "evidence (account-name matching, contact associations), with confidence levels and " +
178
181
  "reasons. Read-only; feed accepted values into fullstackgtm_apply's valueOverrides.",
179
- inputSchema: {
182
+ inputSchema: strictSchema({
180
183
  planPath: z.string(),
181
184
  provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
182
185
  inputPath: z.string().optional(),
183
- },
186
+ }),
184
187
  },
185
188
  handler: async ({ planPath, provider, inputPath }) => {
186
189
  const plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8"));
@@ -198,14 +201,14 @@ const toolDefinitions = [
198
201
  "uses LLM extraction when an Anthropic/OpenAI key is configured in the server " +
199
202
  "environment or credential store, else the free deterministic keyword baseline; " +
200
203
  "'llm' and 'deterministic' force either. Read-only; every insight is provenance-marked.",
201
- inputSchema: {
204
+ inputSchema: strictSchema({
202
205
  transcript: z.string().optional(),
203
206
  transcriptPath: z.string().optional(),
204
207
  title: z.string().optional(),
205
208
  source: z.enum(["gong", "chorus", "fathom", "manual", "csv", "unknown"]).optional(),
206
209
  extractor: z.enum(["auto", "llm", "deterministic"]).optional(),
207
210
  model: z.string().optional(),
208
- },
211
+ }),
209
212
  },
210
213
  handler: async ({ transcript, transcriptPath, title, source, extractor, model }) => {
211
214
  const raw = transcript ??
@@ -238,7 +241,7 @@ const toolDefinitions = [
238
241
  "(exists | ambiguous | safe_to_create) with matches and a reason, using the same " +
239
242
  "identity keys as the audit/merge engines (account domain, contact email, open-deal " +
240
243
  "key). Read-only. Never create on 'exists' or 'ambiguous'.",
241
- inputSchema: {
244
+ inputSchema: strictSchema({
242
245
  objectType: z.enum(["account", "contact", "deal"]),
243
246
  name: z.string().optional(),
244
247
  domain: z.string().optional(),
@@ -246,7 +249,7 @@ const toolDefinitions = [
246
249
  accountId: z.string().optional(),
247
250
  provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
248
251
  inputPath: z.string().optional(),
249
- },
252
+ }),
250
253
  },
251
254
  handler: async ({ objectType, name, domain, email, accountId, provider, inputPath }) => {
252
255
  const snapshot = await readSnapshot(provider, inputPath);
@@ -259,7 +262,7 @@ const toolDefinitions = [
259
262
  config: {
260
263
  title: "List Audit Rules",
261
264
  description: "List the built-in deterministic audit rules with ids and descriptions.",
262
- inputSchema: {},
265
+ inputSchema: strictSchema({}),
263
266
  },
264
267
  handler: async () => {
265
268
  return content(builtinAuditRules.map(({ id, title, description }) => ({ id, title, description })));
@@ -277,13 +280,13 @@ const toolDefinitions = [
277
280
  "the store's HMAC approval digests — ids not approved with `plans approve` are " +
278
281
  "refused — and the run is recorded onto the stored plan so `plans show` and " +
279
282
  "`audit-log export` include it.",
280
- inputSchema: {
283
+ inputSchema: strictSchema({
281
284
  provider: z.enum(["hubspot", "salesforce"]),
282
285
  planPath: z.string(),
283
286
  approvedOperationIds: z.array(z.string()).min(1),
284
287
  valueOverrides: z.record(z.string(), z.string()).optional(),
285
288
  output: z.enum(["json", "markdown"]).optional(),
286
- },
289
+ }),
287
290
  },
288
291
  handler: async ({ provider, planPath, approvedOperationIds, valueOverrides, output }) => {
289
292
  // The file may be a raw PatchPlan (audit --out) or a StoredPlan envelope
@@ -400,11 +403,11 @@ const toolDefinitions = [
400
403
  "and quote verbatim spans (≤300 chars) for every loud/quiet reading. Submit the full " +
401
404
  "ObservationSet via fullstackgtm_market_observe — quotes are verified character-for-" +
402
405
  "character against the captures, so never paraphrase.",
403
- inputSchema: {
406
+ inputSchema: strictSchema({
404
407
  vendorId: z.string(),
405
408
  configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
406
409
  captureRun: z.string().optional(),
407
- },
410
+ }),
408
411
  },
409
412
  handler: async ({ vendorId, configPath, captureRun }) => {
410
413
  const config = loadMarketConfigOrHint(resolve(process.cwd(), configPath ?? "market.config.json"));
@@ -420,10 +423,10 @@ const toolDefinitions = [
420
423
  "Validates coverage, the verbatim-evidence rule, and mechanically verifies every quoted " +
421
424
  "span against the stored capture it cites. Returns problems if rejected; nothing is " +
422
425
  "stored unless the whole set passes. Observations are append-only — use a new runLabel.",
423
- inputSchema: {
426
+ inputSchema: strictSchema({
424
427
  observationsPath: z.string().describe("Path to the ObservationSet JSON file"),
425
428
  configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
426
- },
429
+ }),
427
430
  },
428
431
  handler: async ({ observationsPath, configPath }) => {
429
432
  const config = loadMarketConfigOrHint(resolve(process.cwd(), configPath ?? "market.config.json"));
@@ -454,6 +457,7 @@ toolDefinitions.unshift({
454
457
  description: "Machine-readable contract for this MCP server: the tool inventory (derived from the " +
455
458
  "server's own registration table, with per-tool CRM-write flags), safety defaults, and " +
456
459
  "the CLI entrypoints for everything the tools don't cover.",
460
+ inputSchema: strictSchema({}),
457
461
  },
458
462
  handler: async () => content({
459
463
  ok: true,
@@ -0,0 +1,39 @@
1
+ import type { CanonicalGtmSnapshot } from "./types.ts";
2
+ export type StakeholderRole = "economic_buyer" | "decision_maker" | "technical_buyer" | "champion" | "influencer" | "unknown";
3
+ export type StakeholderSentiment = "positive" | "neutral" | "negative" | "unknown";
4
+ export type StakeholderNode = {
5
+ contactId: string;
6
+ name: string;
7
+ title?: string;
8
+ email?: string;
9
+ role: StakeholderRole;
10
+ sentiment: StakeholderSentiment;
11
+ lastActivityAt?: string;
12
+ evidence: string[];
13
+ };
14
+ export type RelationshipMap = {
15
+ account: {
16
+ accountId: string;
17
+ name: string;
18
+ domain?: string;
19
+ };
20
+ stakeholders: StakeholderNode[];
21
+ openDeals: Array<{
22
+ dealId: string;
23
+ name: string;
24
+ stage?: string;
25
+ amount?: number;
26
+ ownerId?: string;
27
+ }>;
28
+ gaps: string[];
29
+ counts: {
30
+ stakeholders: number;
31
+ openDeals: number;
32
+ activities: number;
33
+ };
34
+ };
35
+ export declare function buildRelationshipMap(snapshot: CanonicalGtmSnapshot, selector: {
36
+ accountId?: string;
37
+ domain?: string;
38
+ }): RelationshipMap;
39
+ export declare function relationshipMapToMarkdown(map: RelationshipMap): string;
@@ -0,0 +1,101 @@
1
+ import { normalizeDomain } from "./merge.js";
2
+ function fullName(contact) {
3
+ return [contact.firstName, contact.lastName].filter(Boolean).join(" ") || contact.email || contact.id;
4
+ }
5
+ function inferRole(title) {
6
+ const t = (title ?? "").toLowerCase();
7
+ if (/\b(cfo|ceo|coo|cro|chief|founder|owner|president)\b/.test(t))
8
+ return "economic_buyer";
9
+ if (/\b(vp|vice president|head of|director|gm|general manager)\b/.test(t))
10
+ return "decision_maker";
11
+ if (/\b(cto|cio|security|it|engineering|architect|developer|technical|data)\b/.test(t))
12
+ return "technical_buyer";
13
+ if (/\b(champion|revops|sales ops|revenue operations|operations)\b/.test(t))
14
+ return "champion";
15
+ if (t)
16
+ return "influencer";
17
+ return "unknown";
18
+ }
19
+ function inferSentiment(text) {
20
+ const lower = text.toLowerCase();
21
+ if (/\b(blocked|concern|risk|no budget|not interested|detractor|negative|unhappy)\b/.test(lower))
22
+ return "negative";
23
+ if (/\b(champion|excited|interested|positive|approved|referred|next step|pilot)\b/.test(lower))
24
+ return "positive";
25
+ return "neutral";
26
+ }
27
+ function maxSentiment(a, b) {
28
+ const rank = { unknown: 0, neutral: 1, positive: 2, negative: 3 };
29
+ return rank[b] > rank[a] ? b : a;
30
+ }
31
+ function findAccount(snapshot, selector) {
32
+ if (selector.accountId)
33
+ return snapshot.accounts.find((account) => String(account.id) === selector.accountId) ?? null;
34
+ if (selector.domain) {
35
+ const domain = normalizeDomain(selector.domain);
36
+ return snapshot.accounts.find((account) => normalizeDomain(account.domain) === domain) ?? null;
37
+ }
38
+ return null;
39
+ }
40
+ export function buildRelationshipMap(snapshot, selector) {
41
+ const account = findAccount(snapshot, selector);
42
+ if (!account)
43
+ throw new Error("relationships: account not found (use --account-id or --domain)");
44
+ const contacts = snapshot.contacts.filter((contact) => contact.accountId === account.id);
45
+ const contactIds = new Set(contacts.map((contact) => contact.id));
46
+ const activities = snapshot.activities.filter((activity) => activity.accountId === account.id || (activity.contactId ? contactIds.has(activity.contactId) : false));
47
+ const evidenceByContact = new Map();
48
+ const sentimentByContact = new Map();
49
+ for (const activity of activities) {
50
+ if (!activity.contactId || !contactIds.has(activity.contactId))
51
+ continue;
52
+ const text = [activity.subject, activity.type, activity.occurredAt].filter(Boolean).join(" — ");
53
+ evidenceByContact.set(activity.contactId, [...(evidenceByContact.get(activity.contactId) ?? []), text]);
54
+ sentimentByContact.set(activity.contactId, maxSentiment(sentimentByContact.get(activity.contactId) ?? "unknown", inferSentiment(text)));
55
+ }
56
+ const stakeholders = contacts.map((contact) => ({
57
+ contactId: String(contact.id),
58
+ name: fullName(contact),
59
+ ...(contact.title ? { title: contact.title } : {}),
60
+ ...(contact.email ? { email: contact.email } : {}),
61
+ role: inferRole(contact.title),
62
+ sentiment: sentimentByContact.get(contact.id) ?? "unknown",
63
+ ...(contact.lastActivityAt ? { lastActivityAt: contact.lastActivityAt } : {}),
64
+ evidence: evidenceByContact.get(contact.id) ?? [],
65
+ })).sort((a, b) => a.role.localeCompare(b.role) || a.name.localeCompare(b.name));
66
+ const openDeals = snapshot.deals.filter((deal) => deal.accountId === account.id && !deal.isClosed).map((deal) => ({
67
+ dealId: String(deal.id),
68
+ name: deal.name,
69
+ ...(deal.stage ? { stage: deal.stage } : {}),
70
+ ...(deal.amount !== undefined ? { amount: deal.amount } : {}),
71
+ ...(deal.ownerId ? { ownerId: deal.ownerId } : {}),
72
+ }));
73
+ const roles = new Set(stakeholders.map((s) => s.role));
74
+ const gaps = [];
75
+ if (!roles.has("economic_buyer"))
76
+ gaps.push("No obvious economic buyer found from titles.");
77
+ if (!roles.has("decision_maker"))
78
+ gaps.push("No obvious decision maker found from titles.");
79
+ if (stakeholders.every((s) => s.sentiment === "unknown"))
80
+ gaps.push("No stakeholder activity evidence attached to this account.");
81
+ return { account: { accountId: String(account.id), name: account.name, ...(account.domain ? { domain: account.domain } : {}) }, stakeholders, openDeals, gaps, counts: { stakeholders: stakeholders.length, openDeals: openDeals.length, activities: activities.length } };
82
+ }
83
+ export function relationshipMapToMarkdown(map) {
84
+ const lines = [`# Relationship map: ${map.account.name}`, "", `${map.counts.stakeholders} stakeholder(s), ${map.counts.openDeals} open deal(s), ${map.counts.activities} activity record(s).`, "", "## Stakeholders"];
85
+ for (const s of map.stakeholders) {
86
+ lines.push(`- ${s.name} — ${s.role}, ${s.sentiment}${s.title ? ` (${s.title})` : ""}`);
87
+ for (const evidence of s.evidence.slice(0, 3))
88
+ lines.push(` - evidence: ${evidence}`);
89
+ }
90
+ if (map.openDeals.length) {
91
+ lines.push("", "## Open deals");
92
+ for (const deal of map.openDeals)
93
+ lines.push(`- ${deal.name} (${deal.dealId})${deal.stage ? ` — ${deal.stage}` : ""}${deal.amount !== undefined ? ` — ${deal.amount}` : ""}`);
94
+ }
95
+ if (map.gaps.length) {
96
+ lines.push("", "## Gaps");
97
+ for (const gap of map.gaps)
98
+ lines.push(`- ${gap}`);
99
+ }
100
+ return lines.join("\n");
101
+ }
@@ -0,0 +1,46 @@
1
+ import { type AssignmentPolicy } from "./assign.ts";
2
+ import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
3
+ /**
4
+ * Lead-to-account matching and routing recipes.
5
+ *
6
+ * This is the first packaged Traction-Complete-style workflow in the OSS CLI:
7
+ * take ownerless/unmatched leads, deterministically match them to accounts, and
8
+ * emit normal approval-gated patch operations. No writes happen here.
9
+ */
10
+ export type LeadRouteOptions = {
11
+ /** Match contact email domains to account domains. Default true. */
12
+ matchByDomain?: boolean;
13
+ /** Also try raw company/name fields against account names when domain misses. */
14
+ matchByCompanyName?: boolean;
15
+ /** When an ownerless contact matches an owned account, set contact ownerId to the account owner. Default true. */
16
+ inheritAccountOwner?: boolean;
17
+ /** Reassign contacts that already have an owner when the matched account owner differs. Default false. */
18
+ reassignExistingOwner?: boolean;
19
+ /** Optional fallback policy for contacts that still do not have an owner. */
20
+ assignmentPolicy?: AssignmentPolicy;
21
+ /** Refuse to build larger plans unless explicitly raised. Default 500. */
22
+ maxOperations?: number;
23
+ reason?: string;
24
+ };
25
+ export type LeadRouteCounts = {
26
+ contactsScanned: number;
27
+ /** Contacts that ended with an unambiguous account context (existing link, domain match, or company-name match). */
28
+ matchedContacts: number;
29
+ matchedExistingAccount: number;
30
+ matchedByDomain: number;
31
+ matchedByCompanyName: number;
32
+ ambiguousAccountMatches: number;
33
+ skippedFreeEmail: number;
34
+ linkOperations: number;
35
+ /** Owner set_field operations proposed (account-owner inheritance + policy fallback). */
36
+ ownerOperations: number;
37
+ /** Alias for ownerOperations, named for reports that talk about owner-assigned leads. */
38
+ ownerAssigned: number;
39
+ policyAssigned: number;
40
+ unmatched: number;
41
+ };
42
+ export type LeadRouteResult = {
43
+ plan: PatchPlan;
44
+ counts: LeadRouteCounts;
45
+ };
46
+ export declare function buildLeadRoutePlan(snapshot: CanonicalGtmSnapshot, options?: LeadRouteOptions): LeadRouteResult;