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/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("&", "&amp;")
188
+ .replaceAll("<", "&lt;")
189
+ .replaceAll(">", "&gt;")
190
+ .replaceAll('"', "&quot;");
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 &lt;id&gt;</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[];
@@ -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`