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/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[];
@@ -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`, `apply`, `rules`.
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`
@@ -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
- ## 1.0.0 The contract (reached)
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. From here, new providers and new
113
- rules are minor releases; the model only breaks at 2.0.
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.10.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",