fullstackgtm 0.10.0 → 0.11.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 +106 -0
- package/INSTALL_FOR_AGENTS.md +28 -2
- package/README.md +74 -6
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +251 -16
- package/dist/connectors/hubspot.js +36 -11
- package/dist/connectors/hubspotAuth.js +10 -2
- package/dist/connectors/salesforce.js +34 -9
- package/dist/connectors/salesforceAuth.js +17 -3
- package/dist/credentials.d.ts +19 -0
- package/dist/credentials.js +69 -8
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/mcp.js +53 -5
- package/dist/report.d.ts +61 -0
- package/dist/report.js +331 -0
- package/dist/rules.d.ts +1 -0
- package/dist/rules.js +47 -0
- package/dist/suggest.d.ts +31 -0
- package/dist/suggest.js +148 -0
- package/docs/api.md +13 -1
- package/docs/roadmap-to-1.0.md +31 -3
- package/package.json +1 -1
- package/src/cli.ts +271 -14
- package/src/connectors/hubspot.ts +35 -11
- package/src/connectors/hubspotAuth.ts +9 -2
- package/src/connectors/salesforce.ts +35 -9
- package/src/connectors/salesforceAuth.ts +19 -6
- package/src/credentials.ts +71 -6
- package/src/index.ts +7 -0
- package/src/mcp.ts +55 -5
- package/src/report.ts +502 -0
- package/src/rules.ts +50 -0
- package/src/suggest.ts +202 -0
package/dist/rules.js
CHANGED
|
@@ -380,6 +380,52 @@ export const duplicateContactEmailRule = {
|
|
|
380
380
|
return { findings, operations };
|
|
381
381
|
},
|
|
382
382
|
};
|
|
383
|
+
export const duplicateOpenDealRule = {
|
|
384
|
+
id: "duplicate-open-deal",
|
|
385
|
+
title: "Open deals duplicate the same opportunity",
|
|
386
|
+
description: "Flags multiple open deals carrying the same name (scoped to the account when linked) — " +
|
|
387
|
+
"usually an integration re-creating deals instead of upserting, which counts the same " +
|
|
388
|
+
"revenue several times in pipeline and forecast.",
|
|
389
|
+
category: "data-quality",
|
|
390
|
+
evaluate: ({ snapshot }) => {
|
|
391
|
+
const findings = [];
|
|
392
|
+
const operations = [];
|
|
393
|
+
const keyOf = (deal) => {
|
|
394
|
+
if (!isOpen(deal))
|
|
395
|
+
return undefined;
|
|
396
|
+
const name = deal.name?.trim().toLowerCase().replace(/\s+/g, " ");
|
|
397
|
+
if (!name)
|
|
398
|
+
return undefined;
|
|
399
|
+
return `${deal.accountId ?? "unlinked"}:${name}`;
|
|
400
|
+
};
|
|
401
|
+
for (const [, deals] of duplicateGroups(snapshot.deals, keyOf)) {
|
|
402
|
+
const anchor = deals[0];
|
|
403
|
+
findings.push({
|
|
404
|
+
id: auditFindingId("duplicate-open-deal", anchor.id),
|
|
405
|
+
objectType: "deal",
|
|
406
|
+
objectId: anchor.id,
|
|
407
|
+
ruleId: "duplicate-open-deal",
|
|
408
|
+
title: "Open deals duplicate the same opportunity",
|
|
409
|
+
severity: "warning",
|
|
410
|
+
summary: `${deals.length} open deals named "${anchor.name}"${anchor.accountId ? " on the same account" : ""}: ${deals.map((deal) => deal.id).join(", ")}.`,
|
|
411
|
+
recommendation: "Keep one deal, archive the copies, and fix the integration that is re-creating them.",
|
|
412
|
+
});
|
|
413
|
+
operations.push({
|
|
414
|
+
id: patchOperationId("duplicate-open-deal", anchor.id),
|
|
415
|
+
objectType: "deal",
|
|
416
|
+
objectId: anchor.id,
|
|
417
|
+
operation: "create_task",
|
|
418
|
+
field: "merge_review_task",
|
|
419
|
+
beforeValue: null,
|
|
420
|
+
afterValue: `Review ${deals.length} duplicate open deals named "${anchor.name}" — keep one, archive ${deals.length - 1}`,
|
|
421
|
+
reason: "Duplicate open deals inflate pipeline and forecast the same revenue more than once.",
|
|
422
|
+
riskLevel: "medium",
|
|
423
|
+
approvalRequired: true,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
return { findings, operations };
|
|
427
|
+
},
|
|
428
|
+
};
|
|
383
429
|
export const activeDealAccountWithoutContactsRule = {
|
|
384
430
|
id: "active-deal-account-without-contacts",
|
|
385
431
|
title: "Account with open pipeline has no contacts",
|
|
@@ -506,6 +552,7 @@ export const builtinAuditRules = [
|
|
|
506
552
|
missingDealAmountRule,
|
|
507
553
|
duplicateAccountDomainRule,
|
|
508
554
|
duplicateContactEmailRule,
|
|
555
|
+
duplicateOpenDealRule,
|
|
509
556
|
activeDealAccountWithoutContactsRule,
|
|
510
557
|
closingSoonInactiveRule,
|
|
511
558
|
accountSingleSourceRule,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic value suggestions for `requires_human_*` placeholder
|
|
4
|
+
* operations. The engine never invents data: every suggestion is derived
|
|
5
|
+
* from evidence already in the snapshot (account names, contact→account
|
|
6
|
+
* associations, the org's user list) and carries a confidence level plus a
|
|
7
|
+
* human-readable reason, so a reviewer — or an agent driving the CLI — can
|
|
8
|
+
* approve in bulk at a chosen confidence threshold.
|
|
9
|
+
*
|
|
10
|
+
* Born from dogfooding: an audit of a real portal produced 30
|
|
11
|
+
* `requires_human_account_selection` placeholders whose answers were all
|
|
12
|
+
* derivable from the snapshot itself.
|
|
13
|
+
*/
|
|
14
|
+
export type SuggestionConfidence = "high" | "low" | "create" | "none";
|
|
15
|
+
export type ValueSuggestion = {
|
|
16
|
+
operationId: string;
|
|
17
|
+
objectType: string;
|
|
18
|
+
objectId: string;
|
|
19
|
+
/** Display name of the object the operation targets (e.g. the deal name). */
|
|
20
|
+
objectName?: string;
|
|
21
|
+
/** The requires_human_* placeholder being filled. */
|
|
22
|
+
placeholder: string;
|
|
23
|
+
/**
|
|
24
|
+
* The value to approve with, or null when no evidence supports one.
|
|
25
|
+
* `create:<Name>` proposes creating the missing record on apply.
|
|
26
|
+
*/
|
|
27
|
+
suggestedValue: string | null;
|
|
28
|
+
confidence: SuggestionConfidence;
|
|
29
|
+
reason: string;
|
|
30
|
+
};
|
|
31
|
+
export declare function suggestValues(plan: PatchPlan, snapshot: CanonicalGtmSnapshot): ValueSuggestion[];
|
package/dist/suggest.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { requiresHumanInput } from "./rules.js";
|
|
2
|
+
export function suggestValues(plan, snapshot) {
|
|
3
|
+
const accountsByNorm = new Map();
|
|
4
|
+
for (const account of snapshot.accounts) {
|
|
5
|
+
accountsByNorm.set(normalize(account.name), { id: account.id, name: account.name });
|
|
6
|
+
}
|
|
7
|
+
const accountsById = new Map(snapshot.accounts.map((a) => [a.id, a]));
|
|
8
|
+
const contactsByName = new Map();
|
|
9
|
+
for (const contact of snapshot.contacts) {
|
|
10
|
+
const name = normalize(`${contact.firstName ?? ""} ${contact.lastName ?? ""}`);
|
|
11
|
+
if (name)
|
|
12
|
+
contactsByName.set(name, { id: contact.id, accountId: contact.accountId, name });
|
|
13
|
+
}
|
|
14
|
+
const dealsById = new Map(snapshot.deals.map((d) => [d.id, d]));
|
|
15
|
+
const activeUsers = snapshot.users.filter((user) => user.active !== false);
|
|
16
|
+
const suggestions = [];
|
|
17
|
+
for (const operation of plan.operations) {
|
|
18
|
+
if (!requiresHumanInput(operation.afterValue))
|
|
19
|
+
continue;
|
|
20
|
+
const placeholder = String(operation.afterValue);
|
|
21
|
+
if (placeholder === "requires_human_account_selection" && operation.objectType === "deal") {
|
|
22
|
+
suggestions.push(suggestDealAccount(operation, dealsById, accountsByNorm, accountsById, contactsByName));
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (placeholder === "requires_human_owner_selection" && activeUsers.length === 1) {
|
|
26
|
+
suggestions.push({
|
|
27
|
+
operationId: operation.id,
|
|
28
|
+
objectType: operation.objectType,
|
|
29
|
+
objectId: operation.objectId,
|
|
30
|
+
objectName: dealsById.get(operation.objectId)?.name,
|
|
31
|
+
placeholder,
|
|
32
|
+
suggestedValue: activeUsers[0].id,
|
|
33
|
+
confidence: "high",
|
|
34
|
+
reason: `${activeUsers[0].name} is the only active user in the org.`,
|
|
35
|
+
});
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
suggestions.push({
|
|
39
|
+
operationId: operation.id,
|
|
40
|
+
objectType: operation.objectType,
|
|
41
|
+
objectId: operation.objectId,
|
|
42
|
+
objectName: dealsById.get(operation.objectId)?.name,
|
|
43
|
+
placeholder,
|
|
44
|
+
suggestedValue: null,
|
|
45
|
+
confidence: "none",
|
|
46
|
+
reason: `No deterministic heuristic for ${placeholder}; supply --value ${operation.id}=<value>.`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return suggestions;
|
|
50
|
+
}
|
|
51
|
+
function suggestDealAccount(operation, dealsById, accountsByNorm, accountsById, contactsByName) {
|
|
52
|
+
const deal = dealsById.get(operation.objectId);
|
|
53
|
+
const base = {
|
|
54
|
+
operationId: operation.id,
|
|
55
|
+
objectType: operation.objectType,
|
|
56
|
+
objectId: operation.objectId,
|
|
57
|
+
objectName: deal?.name,
|
|
58
|
+
placeholder: "requires_human_account_selection",
|
|
59
|
+
};
|
|
60
|
+
if (!deal) {
|
|
61
|
+
return { ...base, suggestedValue: null, confidence: "none", reason: "Deal not found in the snapshot." };
|
|
62
|
+
}
|
|
63
|
+
// Convention: "Contact Name - Company Name". Both signals below are
|
|
64
|
+
// independent; agreement upgrades confidence, conflict downgrades it.
|
|
65
|
+
const separatorIndex = deal.name.indexOf(" - ");
|
|
66
|
+
const left = separatorIndex >= 0 ? deal.name.slice(0, separatorIndex) : deal.name;
|
|
67
|
+
const right = separatorIndex >= 0 ? deal.name.slice(separatorIndex + 3).trim() : "";
|
|
68
|
+
// Signal 1: company-name match against account names.
|
|
69
|
+
let nameMatch = null;
|
|
70
|
+
let nameMatchKind = "";
|
|
71
|
+
if (right) {
|
|
72
|
+
nameMatch = accountsByNorm.get(normalize(right)) ?? null;
|
|
73
|
+
nameMatchKind = nameMatch ? "exact name match" : "";
|
|
74
|
+
if (!nameMatch) {
|
|
75
|
+
const rightNorm = normalize(right);
|
|
76
|
+
const candidates = [...accountsByNorm.entries()].filter(([norm]) => norm.includes(rightNorm) || rightNorm.includes(norm));
|
|
77
|
+
if (candidates.length === 1) {
|
|
78
|
+
nameMatch = candidates[0][1];
|
|
79
|
+
nameMatchKind = "partial name match";
|
|
80
|
+
}
|
|
81
|
+
else if (candidates.length > 1) {
|
|
82
|
+
return {
|
|
83
|
+
...base,
|
|
84
|
+
suggestedValue: null,
|
|
85
|
+
confidence: "none",
|
|
86
|
+
reason: `"${right}" matches ${candidates.length} accounts ambiguously: ${candidates.slice(0, 4).map(([, a]) => a.name).join(", ")}.`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Signal 2: the deal's named contact already belongs to an account.
|
|
92
|
+
const contact = contactsByName.get(normalize(left));
|
|
93
|
+
const contactAccount = contact?.accountId ? accountsById.get(contact.accountId) ?? null : null;
|
|
94
|
+
if (nameMatch && contactAccount) {
|
|
95
|
+
if (nameMatch.id === contactAccount.id) {
|
|
96
|
+
return {
|
|
97
|
+
...base,
|
|
98
|
+
suggestedValue: nameMatch.id,
|
|
99
|
+
confidence: "high",
|
|
100
|
+
reason: `${nameMatchKind} on "${nameMatch.name}", confirmed by contact "${left}"'s account association.`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
...base,
|
|
105
|
+
suggestedValue: null,
|
|
106
|
+
confidence: "none",
|
|
107
|
+
reason: `Signals conflict: name matches "${nameMatch.name}" but contact "${left}" belongs to "${contactAccount.name}".`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (nameMatch) {
|
|
111
|
+
return {
|
|
112
|
+
...base,
|
|
113
|
+
suggestedValue: nameMatch.id,
|
|
114
|
+
confidence: nameMatchKind === "exact name match" ? "high" : "low",
|
|
115
|
+
reason: `${nameMatchKind} on "${nameMatch.name}" (no contact signal to confirm).`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (contactAccount) {
|
|
119
|
+
return {
|
|
120
|
+
...base,
|
|
121
|
+
suggestedValue: contactAccount.id,
|
|
122
|
+
confidence: "low",
|
|
123
|
+
reason: `Contact "${left}" belongs to "${contactAccount.name}" (no account-name match to confirm).`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (contact && !contact.accountId && right) {
|
|
127
|
+
return {
|
|
128
|
+
...base,
|
|
129
|
+
suggestedValue: `create:${right}`,
|
|
130
|
+
confidence: "create",
|
|
131
|
+
reason: `No account named "${right}" exists and contact "${left}" has none — approving creates the company, then links.`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
...base,
|
|
136
|
+
suggestedValue: null,
|
|
137
|
+
confidence: "none",
|
|
138
|
+
reason: right
|
|
139
|
+
? `No account matches "${right}" and "${left}" is not a known contact. Supply --value ${operation.id}=<accountId> or --value ${operation.id}=create:<Company Name>.`
|
|
140
|
+
: `Deal name "${deal.name}" has no "Contact - Company" pattern to derive a company from. Supply --value ${operation.id}=<accountId> or create:<Company Name>.`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function normalize(value) {
|
|
144
|
+
return value
|
|
145
|
+
.toLowerCase()
|
|
146
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
147
|
+
.trim();
|
|
148
|
+
}
|
package/docs/api.md
CHANGED
|
@@ -57,7 +57,8 @@ release.
|
|
|
57
57
|
|
|
58
58
|
## CLI
|
|
59
59
|
|
|
60
|
-
Commands: `login` / `logout`, `snapshot`, `audit`, `diff`, `merge`, `plans`,
|
|
60
|
+
Commands: `login` / `logout`, `snapshot`, `audit`, `report`, `diff`, `merge`, `plans`,
|
|
61
|
+
`apply`, `rules`, `profiles`, `doctor`.
|
|
61
62
|
Exit codes: `0` success · `1` error · `2` findings/regressions at the requested gate
|
|
62
63
|
(`--fail-on`, `--fail-on-new-findings`). `--json` everywhere; JSON output shapes are stable.
|
|
63
64
|
|
|
@@ -66,6 +67,17 @@ Credential resolution ladder: explicit `--token-env` → ambient env
|
|
|
66
67
|
`STRIPE_SECRET_KEY`) → stored login (`~/.fullstackgtm`, `FSGTM_HOME` override)
|
|
67
68
|
→ broker pairing (`login --via`).
|
|
68
69
|
|
|
70
|
+
Profiles: the global `--profile <name>` flag (or `FULLSTACKGTM_PROFILE`) scopes
|
|
71
|
+
stored logins and stored plans to `profiles/<name>/` under the home directory,
|
|
72
|
+
so one operator can work across several organizations' CRMs without mixing
|
|
73
|
+
credentials or applying one org's plan through another's connection. The
|
|
74
|
+
default profile keeps the historical flat layout.
|
|
75
|
+
|
|
76
|
+
`report` renders an audit (or an existing plan via `--plan`) as a client-ready
|
|
77
|
+
deliverable in markdown or self-contained HTML: severity counts, prose summary,
|
|
78
|
+
per-rule detail with capped examples, and next steps. `auditReportToMarkdown` /
|
|
79
|
+
`auditReportToHtml` expose the same rendering programmatically.
|
|
80
|
+
|
|
69
81
|
## MCP
|
|
70
82
|
|
|
71
83
|
Tools: `fullstackgtm_audit`, `fullstackgtm_rules`, `fullstackgtm_apply`
|
package/docs/roadmap-to-1.0.md
CHANGED
|
@@ -106,11 +106,39 @@ The original thesis: GTM data disagrees across systems.
|
|
|
106
106
|
- Docs site with the operating-model registry as browsable reference.
|
|
107
107
|
- Performance pass: streaming snapshots for very large orgs.
|
|
108
108
|
|
|
109
|
-
##
|
|
109
|
+
## Known real-portal gaps to close before 1.0
|
|
110
|
+
|
|
111
|
+
Found by exercising the published package as a fresh RevOps user with a real
|
|
112
|
+
CRM (2026-06):
|
|
113
|
+
|
|
114
|
+
- **Pipeline-aware closed-deal detection (HubSpot).** `isClosed`/`isWon` are
|
|
115
|
+
derived by substring-matching the raw `dealstage` value against
|
|
116
|
+
`closedwon`/`closedlost`. Custom pipelines use opaque stage ids, so closed
|
|
117
|
+
deals read as open and flood stale-deal/past-close findings. Fix: resolve
|
|
118
|
+
stage metadata from `/crm/v3/pipelines` once per snapshot.
|
|
119
|
+
- **Rate-limit resilience.** No 429/retry/backoff handling anywhere; a
|
|
120
|
+
mid-size portal snapshot is ~1,000+ sequential page requests and one
|
|
121
|
+
transient failure aborts the run. Fix: honor `Retry-After`, retry
|
|
122
|
+
idempotent reads with backoff.
|
|
123
|
+
- **`fetchChanges` 10k truncation.** The HubSpot search API caps at 10,000
|
|
124
|
+
results; the connector stops silently at MAX_PAGES. Fix: detect the cap
|
|
125
|
+
and fall back to (or instruct) a full snapshot, loudly.
|
|
126
|
+
- **MCP plan hand-off.** `fullstackgtm_audit` can only return the full plan
|
|
127
|
+
inline (200KB+ on real data) while `fullstackgtm_apply` requires a file
|
|
128
|
+
path. Fix: an `outPath` option on the audit tool plus a summary-only
|
|
129
|
+
output mode.
|
|
130
|
+
- **Scope-complete login validation.** `login hubspot` validates against the
|
|
131
|
+
owners endpoint only, so an under-scoped token passes login and fails
|
|
132
|
+
mid-audit. Fix: probe each required object endpoint at login and report
|
|
133
|
+
missing scopes up front.
|
|
134
|
+
|
|
135
|
+
## 1.0.0 — The contract (not yet declared)
|
|
110
136
|
|
|
111
137
|
Semver stability commitment on the canonical model, rule interface, connector
|
|
112
|
-
contract, plan format, CLI, and MCP tools.
|
|
113
|
-
|
|
138
|
+
contract, plan format, CLI, and MCP tools. Declared only after the API
|
|
139
|
+
surface has survived external usage and the real-portal gaps above are
|
|
140
|
+
closed. From there, new providers and new rules are minor releases; the
|
|
141
|
+
model only breaks at 2.0.
|
|
114
142
|
|
|
115
143
|
## Deliberately out of scope until after 1.0
|
|
116
144
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|