fullstackgtm 0.10.1 → 0.11.1
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 +90 -0
- package/INSTALL_FOR_AGENTS.md +14 -0
- package/README.md +33 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +244 -13
- package/dist/connectors/hubspot.js +101 -4
- package/dist/connectors/hubspotAuth.js +6 -2
- package/dist/connectors/salesforce.js +68 -2
- 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 +16 -0
- package/dist/merge.d.ts +1 -0
- package/dist/merge.js +1 -1
- package/dist/report.d.ts +61 -0
- package/dist/report.js +331 -0
- package/dist/rules.d.ts +1 -0
- package/dist/rules.js +49 -1
- package/dist/suggest.d.ts +31 -0
- package/dist/suggest.js +148 -0
- package/docs/api.md +13 -1
- package/docs/crm-health-lifecycle.md +135 -0
- package/llms.txt +1 -0
- package/package.json +1 -1
- package/src/cli.ts +264 -11
- package/src/connectors/hubspot.ts +101 -4
- package/src/connectors/hubspotAuth.ts +6 -2
- package/src/connectors/salesforce.ts +70 -2
- package/src/connectors/salesforceAuth.ts +19 -6
- package/src/credentials.ts +71 -6
- package/src/index.ts +7 -0
- package/src/mcp.ts +22 -0
- package/src/merge.ts +1 -1
- package/src/report.ts +502 -0
- package/src/rules.ts +52 -1
- package/src/suggest.ts +202 -0
package/dist/report.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { REQUIRES_HUMAN_PREFIX } from "./rules.js";
|
|
2
|
+
const SEVERITY_ORDER = ["critical", "warning", "info"];
|
|
3
|
+
const SEVERITY_RANK = {
|
|
4
|
+
critical: 2,
|
|
5
|
+
warning: 1,
|
|
6
|
+
info: 0,
|
|
7
|
+
};
|
|
8
|
+
export function buildReportModel(plan, options = {}) {
|
|
9
|
+
const ruleMeta = new Map((options.rules ?? []).map((rule) => [rule.id, rule]));
|
|
10
|
+
const byRule = new Map();
|
|
11
|
+
for (const finding of plan.findings) {
|
|
12
|
+
const findings = byRule.get(finding.ruleId) ?? [];
|
|
13
|
+
findings.push(finding);
|
|
14
|
+
byRule.set(finding.ruleId, findings);
|
|
15
|
+
}
|
|
16
|
+
const sections = Array.from(byRule.entries())
|
|
17
|
+
.map(([ruleId, findings]) => {
|
|
18
|
+
const meta = ruleMeta.get(ruleId);
|
|
19
|
+
const severity = findings.reduce((worst, finding) => SEVERITY_RANK[finding.severity] > SEVERITY_RANK[worst] ? finding.severity : worst, "info");
|
|
20
|
+
return {
|
|
21
|
+
ruleId,
|
|
22
|
+
title: meta?.title ?? ruleId,
|
|
23
|
+
description: meta?.description,
|
|
24
|
+
category: meta?.category ?? "uncategorized",
|
|
25
|
+
severity,
|
|
26
|
+
findings,
|
|
27
|
+
};
|
|
28
|
+
})
|
|
29
|
+
.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] ||
|
|
30
|
+
b.findings.length - a.findings.length ||
|
|
31
|
+
a.ruleId.localeCompare(b.ruleId));
|
|
32
|
+
const severityCounts = {
|
|
33
|
+
critical: 0,
|
|
34
|
+
warning: 0,
|
|
35
|
+
info: 0,
|
|
36
|
+
};
|
|
37
|
+
const affected = new Set();
|
|
38
|
+
for (const finding of plan.findings) {
|
|
39
|
+
severityCounts[finding.severity] += 1;
|
|
40
|
+
affected.add(`${finding.objectType}:${finding.objectId}`);
|
|
41
|
+
}
|
|
42
|
+
const snapshot = options.snapshot;
|
|
43
|
+
const recordCounts = snapshot
|
|
44
|
+
? [
|
|
45
|
+
{ label: "Accounts", count: snapshot.accounts.length },
|
|
46
|
+
{ label: "Contacts", count: snapshot.contacts.length },
|
|
47
|
+
{ label: "Deals", count: snapshot.deals.length },
|
|
48
|
+
{ label: "Users", count: snapshot.users.length },
|
|
49
|
+
{ label: "Activities", count: snapshot.activities.length },
|
|
50
|
+
]
|
|
51
|
+
: undefined;
|
|
52
|
+
const totalRecords = recordCounts?.reduce((sum, entry) => sum + entry.count, 0);
|
|
53
|
+
const recordLabels = new Map();
|
|
54
|
+
if (snapshot) {
|
|
55
|
+
for (const row of snapshot.accounts)
|
|
56
|
+
recordLabels.set(`account:${row.id}`, row.name);
|
|
57
|
+
for (const row of snapshot.users)
|
|
58
|
+
recordLabels.set(`user:${row.id}`, row.name);
|
|
59
|
+
for (const row of snapshot.deals)
|
|
60
|
+
recordLabels.set(`deal:${row.id}`, row.name);
|
|
61
|
+
for (const row of snapshot.contacts) {
|
|
62
|
+
const name = [row.firstName, row.lastName].filter(Boolean).join(" ") || row.email;
|
|
63
|
+
if (name)
|
|
64
|
+
recordLabels.set(`contact:${row.id}`, name);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const humanInputOperationCount = plan.operations.filter((operation) => typeof operation.afterValue === "string" &&
|
|
68
|
+
operation.afterValue.startsWith(REQUIRES_HUMAN_PREFIX)).length;
|
|
69
|
+
const model = {
|
|
70
|
+
title: options.title ?? "GTM Data Health Report",
|
|
71
|
+
clientName: options.clientName,
|
|
72
|
+
preparedBy: options.preparedBy,
|
|
73
|
+
date: options.date ?? plan.createdAt.slice(0, 10),
|
|
74
|
+
provider: snapshot?.provider,
|
|
75
|
+
recordCounts,
|
|
76
|
+
totalRecords,
|
|
77
|
+
severityCounts,
|
|
78
|
+
affectedRecords: affected.size,
|
|
79
|
+
sections,
|
|
80
|
+
maxExamplesPerRule: Math.max(1, options.maxExamplesPerRule ?? 10),
|
|
81
|
+
operationCount: plan.operations.length,
|
|
82
|
+
humanInputOperationCount,
|
|
83
|
+
recordLabels,
|
|
84
|
+
summaryText: "",
|
|
85
|
+
};
|
|
86
|
+
model.summaryText = summaryText(model);
|
|
87
|
+
return model;
|
|
88
|
+
}
|
|
89
|
+
function summaryText(model) {
|
|
90
|
+
const subject = model.clientName ? `${model.clientName}'s CRM data` : "the CRM data";
|
|
91
|
+
if (model.sections.length === 0) {
|
|
92
|
+
return `This audit reviewed ${subject} and found no issues with the rules that ran. The checks are deterministic and can be re-run at any time to confirm the data stays healthy.`;
|
|
93
|
+
}
|
|
94
|
+
const total = model.sections.reduce((sum, section) => sum + section.findings.length, 0);
|
|
95
|
+
const severityParts = SEVERITY_ORDER.filter((severity) => model.severityCounts[severity] > 0)
|
|
96
|
+
.map((severity) => `${model.severityCounts[severity]} ${severity}`)
|
|
97
|
+
.join(", ");
|
|
98
|
+
const scope = model.totalRecords !== undefined
|
|
99
|
+
? `${model.affectedRecords} of ${model.totalRecords} records (${percent(model.affectedRecords, model.totalRecords)}%)`
|
|
100
|
+
: `${model.affectedRecords} records`;
|
|
101
|
+
const top = model.sections[0];
|
|
102
|
+
const fixes = model.operationCount > 0
|
|
103
|
+
? ` ${model.operationCount} of the issues have a proposed fix ready for review; nothing is changed in the CRM until each fix is explicitly approved.`
|
|
104
|
+
: "";
|
|
105
|
+
return (`This audit reviewed ${subject} and surfaced ${total} findings (${severityParts}) across ${scope}. ` +
|
|
106
|
+
`The largest issue is "${top.title}" (${top.findings.length} records).${fixes}`);
|
|
107
|
+
}
|
|
108
|
+
function percent(part, whole) {
|
|
109
|
+
if (whole === 0)
|
|
110
|
+
return 0;
|
|
111
|
+
return Math.round((part / whole) * 100);
|
|
112
|
+
}
|
|
113
|
+
function findingLabel(model, finding) {
|
|
114
|
+
const name = model.recordLabels.get(`${finding.objectType}:${finding.objectId}`);
|
|
115
|
+
return name ? `${name} (${finding.objectType})` : `${finding.objectType}/${finding.objectId}`;
|
|
116
|
+
}
|
|
117
|
+
export function auditReportToMarkdown(plan, options = {}) {
|
|
118
|
+
const model = buildReportModel(plan, options);
|
|
119
|
+
const lines = [];
|
|
120
|
+
lines.push(`# ${model.title}${model.clientName ? ` — ${model.clientName}` : ""}`, "");
|
|
121
|
+
const subtitle = [
|
|
122
|
+
`Prepared ${model.date}`,
|
|
123
|
+
model.provider ? `Source: ${model.provider}` : null,
|
|
124
|
+
model.preparedBy ? `By: ${model.preparedBy}` : null,
|
|
125
|
+
].filter(Boolean);
|
|
126
|
+
lines.push(subtitle.join(" · "), "");
|
|
127
|
+
lines.push("## At a Glance", "", "| Metric | Value |", "| --- | --- |");
|
|
128
|
+
if (model.recordCounts && model.totalRecords !== undefined) {
|
|
129
|
+
lines.push(`| Records audited | ${model.totalRecords} (${model.recordCounts
|
|
130
|
+
.map((entry) => `${entry.count} ${entry.label.toLowerCase()}`)
|
|
131
|
+
.join(", ")}) |`);
|
|
132
|
+
}
|
|
133
|
+
const totalFindings = SEVERITY_ORDER.reduce((sum, severity) => sum + model.severityCounts[severity], 0);
|
|
134
|
+
lines.push(`| Findings | ${totalFindings} |`);
|
|
135
|
+
for (const severity of SEVERITY_ORDER) {
|
|
136
|
+
lines.push(`| ${capitalize(severity)} | ${model.severityCounts[severity]} |`);
|
|
137
|
+
}
|
|
138
|
+
lines.push(`| Records affected | ${model.affectedRecords}${model.totalRecords !== undefined && model.totalRecords > 0
|
|
139
|
+
? ` (${percent(model.affectedRecords, model.totalRecords)}%)`
|
|
140
|
+
: ""} |`, `| Proposed fixes | ${model.operationCount}${model.humanInputOperationCount > 0
|
|
141
|
+
? ` (${model.humanInputOperationCount} need a human-chosen value)`
|
|
142
|
+
: ""} |`, "");
|
|
143
|
+
lines.push("## Summary", "", model.summaryText, "");
|
|
144
|
+
if (model.sections.length > 0) {
|
|
145
|
+
lines.push("## Findings by Rule", "", "| Rule | Category | Severity | Records |", "| --- | --- | --- | --- |");
|
|
146
|
+
for (const section of model.sections) {
|
|
147
|
+
lines.push(`| ${section.title} | ${section.category} | ${section.severity} | ${section.findings.length} |`);
|
|
148
|
+
}
|
|
149
|
+
lines.push("", "## Details", "");
|
|
150
|
+
for (const section of model.sections) {
|
|
151
|
+
lines.push(`### ${section.title} (${section.findings.length} ${section.findings.length === 1 ? "record" : "records"}, ${section.severity})`, "");
|
|
152
|
+
if (section.description)
|
|
153
|
+
lines.push(section.description, "");
|
|
154
|
+
const shown = section.findings.slice(0, model.maxExamplesPerRule);
|
|
155
|
+
for (const finding of shown) {
|
|
156
|
+
lines.push(`- **${findingLabel(model, finding)}** — ${finding.summary}`);
|
|
157
|
+
lines.push(` - Recommendation: ${finding.recommendation}`);
|
|
158
|
+
}
|
|
159
|
+
const hidden = section.findings.length - shown.length;
|
|
160
|
+
if (hidden > 0)
|
|
161
|
+
lines.push(`- … and ${hidden} more.`);
|
|
162
|
+
lines.push("");
|
|
163
|
+
}
|
|
164
|
+
lines.push("## Recommended Next Steps", "");
|
|
165
|
+
if (model.operationCount > 0) {
|
|
166
|
+
lines.push(`1. Review the ${model.operationCount} proposed fixes (\`fullstackgtm audit --save\`, then \`fullstackgtm plans show <id>\`).`, `2. Approve the operations to apply${model.humanInputOperationCount > 0
|
|
167
|
+
? `, supplying values for the ${model.humanInputOperationCount} that need a human decision`
|
|
168
|
+
: ""} (\`fullstackgtm plans approve\`). Nothing is written without explicit approval.`, "3. Re-run the audit after applying to confirm the findings clear, and schedule it recurring to catch regressions.");
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
lines.push("1. Address the findings above in the CRM (no automated fixes are available for them yet).", "2. Re-run the audit to confirm the findings clear, and schedule it recurring to catch regressions.");
|
|
172
|
+
}
|
|
173
|
+
lines.push("");
|
|
174
|
+
}
|
|
175
|
+
lines.push("---", "", `Generated by the fullstackgtm CLI on ${model.date}.` +
|
|
176
|
+
(model.preparedBy ? ` Prepared by ${model.preparedBy}.` : "") +
|
|
177
|
+
" Findings are deterministic and re-runnable; no CRM data was modified to produce this report.");
|
|
178
|
+
return `${lines.join("\n")}\n`;
|
|
179
|
+
}
|
|
180
|
+
const SEVERITY_COLORS = {
|
|
181
|
+
critical: { fg: "#991b1b", bg: "#fee2e2" },
|
|
182
|
+
warning: { fg: "#92400e", bg: "#fef3c7" },
|
|
183
|
+
info: { fg: "#1e40af", bg: "#dbeafe" },
|
|
184
|
+
};
|
|
185
|
+
function escapeHtml(value) {
|
|
186
|
+
return value
|
|
187
|
+
.replaceAll("&", "&")
|
|
188
|
+
.replaceAll("<", "<")
|
|
189
|
+
.replaceAll(">", ">")
|
|
190
|
+
.replaceAll('"', """);
|
|
191
|
+
}
|
|
192
|
+
function severityBadge(severity) {
|
|
193
|
+
const colors = SEVERITY_COLORS[severity];
|
|
194
|
+
return `<span class="badge" style="color:${colors.fg};background:${colors.bg}">${severity}</span>`;
|
|
195
|
+
}
|
|
196
|
+
function capitalize(value) {
|
|
197
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Self-contained HTML (inline styles, no external assets) so the file can be
|
|
201
|
+
* emailed or dropped into a shared drive and render anywhere, including
|
|
202
|
+
* print-to-PDF.
|
|
203
|
+
*/
|
|
204
|
+
export function auditReportToHtml(plan, options = {}) {
|
|
205
|
+
const model = buildReportModel(plan, options);
|
|
206
|
+
const totalFindings = SEVERITY_ORDER.reduce((sum, severity) => sum + model.severityCounts[severity], 0);
|
|
207
|
+
const glanceRows = [];
|
|
208
|
+
if (model.recordCounts && model.totalRecords !== undefined) {
|
|
209
|
+
glanceRows.push(row("Records audited", `${model.totalRecords} (${model.recordCounts
|
|
210
|
+
.map((entry) => `${entry.count} ${entry.label.toLowerCase()}`)
|
|
211
|
+
.join(", ")})`));
|
|
212
|
+
}
|
|
213
|
+
glanceRows.push(row("Findings", String(totalFindings)));
|
|
214
|
+
for (const severity of SEVERITY_ORDER) {
|
|
215
|
+
glanceRows.push(`<tr><th>${capitalize(severity)}</th><td>${model.severityCounts[severity]} ${severityBadge(severity)}</td></tr>`);
|
|
216
|
+
}
|
|
217
|
+
glanceRows.push(row("Records affected", `${model.affectedRecords}${model.totalRecords !== undefined && model.totalRecords > 0
|
|
218
|
+
? ` (${percent(model.affectedRecords, model.totalRecords)}%)`
|
|
219
|
+
: ""}`), row("Proposed fixes", `${model.operationCount}${model.humanInputOperationCount > 0
|
|
220
|
+
? ` (${model.humanInputOperationCount} need a human-chosen value)`
|
|
221
|
+
: ""}`));
|
|
222
|
+
const ruleRows = model.sections
|
|
223
|
+
.map((section) => `<tr><td>${escapeHtml(section.title)}</td><td>${escapeHtml(section.category)}</td>` +
|
|
224
|
+
`<td>${severityBadge(section.severity)}</td><td class="num">${section.findings.length}</td></tr>`)
|
|
225
|
+
.join("\n");
|
|
226
|
+
const detailSections = model.sections
|
|
227
|
+
.map((section) => {
|
|
228
|
+
const shown = section.findings.slice(0, model.maxExamplesPerRule);
|
|
229
|
+
const hidden = section.findings.length - shown.length;
|
|
230
|
+
const items = shown
|
|
231
|
+
.map((finding) => `<li><strong>${escapeHtml(findingLabel(model, finding))}</strong> — ${escapeHtml(finding.summary)}` +
|
|
232
|
+
`<div class="rec">Recommendation: ${escapeHtml(finding.recommendation)}</div></li>`)
|
|
233
|
+
.join("\n");
|
|
234
|
+
return [
|
|
235
|
+
`<section>`,
|
|
236
|
+
`<h3>${escapeHtml(section.title)} <span class="count">${section.findings.length} ${section.findings.length === 1 ? "record" : "records"}</span> ${severityBadge(section.severity)}</h3>`,
|
|
237
|
+
section.description ? `<p>${escapeHtml(section.description)}</p>` : "",
|
|
238
|
+
`<ul>`,
|
|
239
|
+
items,
|
|
240
|
+
hidden > 0 ? `<li class="more">… and ${hidden} more.</li>` : "",
|
|
241
|
+
`</ul>`,
|
|
242
|
+
`</section>`,
|
|
243
|
+
]
|
|
244
|
+
.filter(Boolean)
|
|
245
|
+
.join("\n");
|
|
246
|
+
})
|
|
247
|
+
.join("\n");
|
|
248
|
+
const nextSteps = model.sections.length === 0
|
|
249
|
+
? ""
|
|
250
|
+
: model.operationCount > 0
|
|
251
|
+
? `<ol>
|
|
252
|
+
<li>Review the ${model.operationCount} proposed fixes (<code>fullstackgtm audit --save</code>, then <code>fullstackgtm plans show <id></code>).</li>
|
|
253
|
+
<li>Approve the operations to apply${model.humanInputOperationCount > 0
|
|
254
|
+
? `, supplying values for the ${model.humanInputOperationCount} that need a human decision`
|
|
255
|
+
: ""} (<code>fullstackgtm plans approve</code>). Nothing is written without explicit approval.</li>
|
|
256
|
+
<li>Re-run the audit after applying to confirm the findings clear, and schedule it recurring to catch regressions.</li>
|
|
257
|
+
</ol>`
|
|
258
|
+
: `<ol>
|
|
259
|
+
<li>Address the findings above in the CRM (no automated fixes are available for them yet).</li>
|
|
260
|
+
<li>Re-run the audit to confirm the findings clear, and schedule it recurring to catch regressions.</li>
|
|
261
|
+
</ol>`;
|
|
262
|
+
const subtitle = [
|
|
263
|
+
`Prepared ${model.date}`,
|
|
264
|
+
model.provider ? `Source: ${escapeHtml(model.provider)}` : null,
|
|
265
|
+
model.preparedBy ? `By: ${escapeHtml(model.preparedBy)}` : null,
|
|
266
|
+
]
|
|
267
|
+
.filter(Boolean)
|
|
268
|
+
.join(" · ");
|
|
269
|
+
return `<!doctype html>
|
|
270
|
+
<html lang="en">
|
|
271
|
+
<head>
|
|
272
|
+
<meta charset="utf-8">
|
|
273
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
274
|
+
<title>${escapeHtml(model.title)}${model.clientName ? ` — ${escapeHtml(model.clientName)}` : ""}</title>
|
|
275
|
+
<style>
|
|
276
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
277
|
+
color: #1f2937; max-width: 860px; margin: 0 auto; padding: 2rem 1.5rem; line-height: 1.55; }
|
|
278
|
+
h1 { font-size: 1.6rem; margin-bottom: 0.25rem; }
|
|
279
|
+
h2 { font-size: 1.2rem; margin-top: 2rem; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.3rem; }
|
|
280
|
+
h3 { font-size: 1.05rem; margin-top: 1.5rem; }
|
|
281
|
+
.subtitle { color: #6b7280; margin-top: 0; }
|
|
282
|
+
table { border-collapse: collapse; width: 100%; margin: 0.75rem 0; }
|
|
283
|
+
th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid #e5e7eb; font-size: 0.95rem; }
|
|
284
|
+
th { color: #374151; font-weight: 600; }
|
|
285
|
+
td.num { text-align: right; }
|
|
286
|
+
.badge { display: inline-block; padding: 0.05rem 0.5rem; border-radius: 999px;
|
|
287
|
+
font-size: 0.78rem; font-weight: 600; vertical-align: middle; }
|
|
288
|
+
.count { color: #6b7280; font-weight: 400; font-size: 0.9rem; }
|
|
289
|
+
ul { padding-left: 1.2rem; }
|
|
290
|
+
li { margin-bottom: 0.5rem; }
|
|
291
|
+
.rec { color: #4b5563; font-size: 0.92rem; }
|
|
292
|
+
.more { color: #6b7280; list-style: none; }
|
|
293
|
+
code { background: #f3f4f6; padding: 0.1rem 0.3rem; border-radius: 4px; font-size: 0.88rem; }
|
|
294
|
+
footer { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid #e5e7eb;
|
|
295
|
+
color: #6b7280; font-size: 0.85rem; }
|
|
296
|
+
@media print { body { padding: 0; } }
|
|
297
|
+
</style>
|
|
298
|
+
</head>
|
|
299
|
+
<body>
|
|
300
|
+
<h1>${escapeHtml(model.title)}${model.clientName ? ` — ${escapeHtml(model.clientName)}` : ""}</h1>
|
|
301
|
+
<p class="subtitle">${subtitle}</p>
|
|
302
|
+
|
|
303
|
+
<h2>At a Glance</h2>
|
|
304
|
+
<table>
|
|
305
|
+
${glanceRows.join("\n")}
|
|
306
|
+
</table>
|
|
307
|
+
|
|
308
|
+
<h2>Summary</h2>
|
|
309
|
+
<p>${escapeHtml(model.summaryText)}</p>
|
|
310
|
+
${model.sections.length > 0
|
|
311
|
+
? `
|
|
312
|
+
<h2>Findings by Rule</h2>
|
|
313
|
+
<table>
|
|
314
|
+
<tr><th>Rule</th><th>Category</th><th>Severity</th><th class="num">Records</th></tr>
|
|
315
|
+
${ruleRows}
|
|
316
|
+
</table>
|
|
317
|
+
|
|
318
|
+
<h2>Details</h2>
|
|
319
|
+
${detailSections}
|
|
320
|
+
|
|
321
|
+
<h2>Recommended Next Steps</h2>
|
|
322
|
+
${nextSteps}`
|
|
323
|
+
: ""}
|
|
324
|
+
<footer>Generated by the fullstackgtm CLI on ${model.date}.${model.preparedBy ? ` Prepared by ${escapeHtml(model.preparedBy)}.` : ""} Findings are deterministic and re-runnable; no CRM data was modified to produce this report.</footer>
|
|
325
|
+
</body>
|
|
326
|
+
</html>
|
|
327
|
+
`;
|
|
328
|
+
}
|
|
329
|
+
function row(label, value) {
|
|
330
|
+
return `<tr><th>${escapeHtml(label)}</th><td>${escapeHtml(value)}</td></tr>`;
|
|
331
|
+
}
|
package/dist/rules.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export declare const staleDealRule: GtmAuditRule;
|
|
|
18
18
|
export declare const missingDealAmountRule: GtmAuditRule;
|
|
19
19
|
export declare const duplicateAccountDomainRule: GtmAuditRule;
|
|
20
20
|
export declare const duplicateContactEmailRule: GtmAuditRule;
|
|
21
|
+
export declare const duplicateOpenDealRule: GtmAuditRule;
|
|
21
22
|
export declare const activeDealAccountWithoutContactsRule: GtmAuditRule;
|
|
22
23
|
export declare const closingSoonInactiveRule: GtmAuditRule;
|
|
23
24
|
export declare const accountSingleSourceRule: GtmAuditRule;
|
package/dist/rules.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeDomain } from "./merge.js";
|
|
1
2
|
/**
|
|
2
3
|
* Placeholder used as `afterValue` when the right value is a human decision
|
|
3
4
|
* (e.g. which owner to assign). Apply orchestration refuses to write these
|
|
@@ -316,7 +317,7 @@ export const duplicateAccountDomainRule = {
|
|
|
316
317
|
evaluate: ({ snapshot }) => {
|
|
317
318
|
const findings = [];
|
|
318
319
|
const operations = [];
|
|
319
|
-
for (const [domain, accounts] of duplicateGroups(snapshot.accounts, (account) => account.domain)) {
|
|
320
|
+
for (const [domain, accounts] of duplicateGroups(snapshot.accounts, (account) => normalizeDomain(account.domain))) {
|
|
320
321
|
const anchor = accounts[0];
|
|
321
322
|
findings.push({
|
|
322
323
|
id: auditFindingId("duplicate-account-domain", anchor.id),
|
|
@@ -380,6 +381,52 @@ export const duplicateContactEmailRule = {
|
|
|
380
381
|
return { findings, operations };
|
|
381
382
|
},
|
|
382
383
|
};
|
|
384
|
+
export const duplicateOpenDealRule = {
|
|
385
|
+
id: "duplicate-open-deal",
|
|
386
|
+
title: "Open deals duplicate the same opportunity",
|
|
387
|
+
description: "Flags multiple open deals carrying the same name (scoped to the account when linked) — " +
|
|
388
|
+
"usually an integration re-creating deals instead of upserting, which counts the same " +
|
|
389
|
+
"revenue several times in pipeline and forecast.",
|
|
390
|
+
category: "data-quality",
|
|
391
|
+
evaluate: ({ snapshot }) => {
|
|
392
|
+
const findings = [];
|
|
393
|
+
const operations = [];
|
|
394
|
+
const keyOf = (deal) => {
|
|
395
|
+
if (!isOpen(deal))
|
|
396
|
+
return undefined;
|
|
397
|
+
const name = deal.name?.trim().toLowerCase().replace(/\s+/g, " ");
|
|
398
|
+
if (!name)
|
|
399
|
+
return undefined;
|
|
400
|
+
return `${deal.accountId ?? "unlinked"}:${name}`;
|
|
401
|
+
};
|
|
402
|
+
for (const [, deals] of duplicateGroups(snapshot.deals, keyOf)) {
|
|
403
|
+
const anchor = deals[0];
|
|
404
|
+
findings.push({
|
|
405
|
+
id: auditFindingId("duplicate-open-deal", anchor.id),
|
|
406
|
+
objectType: "deal",
|
|
407
|
+
objectId: anchor.id,
|
|
408
|
+
ruleId: "duplicate-open-deal",
|
|
409
|
+
title: "Open deals duplicate the same opportunity",
|
|
410
|
+
severity: "warning",
|
|
411
|
+
summary: `${deals.length} open deals named "${anchor.name}"${anchor.accountId ? " on the same account" : ""}: ${deals.map((deal) => deal.id).join(", ")}.`,
|
|
412
|
+
recommendation: "Keep one deal, archive the copies, and fix the integration that is re-creating them.",
|
|
413
|
+
});
|
|
414
|
+
operations.push({
|
|
415
|
+
id: patchOperationId("duplicate-open-deal", anchor.id),
|
|
416
|
+
objectType: "deal",
|
|
417
|
+
objectId: anchor.id,
|
|
418
|
+
operation: "create_task",
|
|
419
|
+
field: "merge_review_task",
|
|
420
|
+
beforeValue: null,
|
|
421
|
+
afterValue: `Review ${deals.length} duplicate open deals named "${anchor.name}" — keep one, archive ${deals.length - 1}`,
|
|
422
|
+
reason: "Duplicate open deals inflate pipeline and forecast the same revenue more than once.",
|
|
423
|
+
riskLevel: "medium",
|
|
424
|
+
approvalRequired: true,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
return { findings, operations };
|
|
428
|
+
},
|
|
429
|
+
};
|
|
383
430
|
export const activeDealAccountWithoutContactsRule = {
|
|
384
431
|
id: "active-deal-account-without-contacts",
|
|
385
432
|
title: "Account with open pipeline has no contacts",
|
|
@@ -506,6 +553,7 @@ export const builtinAuditRules = [
|
|
|
506
553
|
missingDealAmountRule,
|
|
507
554
|
duplicateAccountDomainRule,
|
|
508
555
|
duplicateContactEmailRule,
|
|
556
|
+
duplicateOpenDealRule,
|
|
509
557
|
activeDealAccountWithoutContactsRule,
|
|
510
558
|
closingSoonInactiveRule,
|
|
511
559
|
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`
|