fullstackgtm 0.10.1 → 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 +60 -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 +21 -3
- package/dist/connectors/hubspotAuth.js +6 -2
- package/dist/connectors/salesforce.js +19 -1
- 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/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/package.json +1 -1
- package/src/cli.ts +264 -11
- package/src/connectors/hubspot.ts +21 -3
- package/src/connectors/hubspotAuth.ts +6 -2
- package/src/connectors/salesforce.ts +19 -1
- 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/report.ts +502 -0
- package/src/rules.ts +50 -0
- package/src/suggest.ts +202 -0
package/dist/credentials.js
CHANGED
|
@@ -1,11 +1,66 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { refreshHubspotToken } from "./connectors/hubspotAuth.js";
|
|
5
5
|
import { refreshSalesforceToken } from "./connectors/salesforceAuth.js";
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Local CLI credential store: ~/.fullstackgtm/credentials.json (0600), or
|
|
8
|
+
* $FSGTM_HOME/credentials.json when set. Environment tokens always win over
|
|
9
|
+
* stored credentials so CI and agent sandboxes never touch the filesystem.
|
|
10
|
+
*
|
|
11
|
+
* Profiles let one operator hold credentials for several organizations at
|
|
12
|
+
* once (a consultant working across client CRMs). The default profile keeps
|
|
13
|
+
* the historical layout; a named profile scopes the entire home — credentials
|
|
14
|
+
* AND stored plans — under `profiles/<name>/`, so a patch plan proposed
|
|
15
|
+
* against one client's CRM can never be applied through another client's
|
|
16
|
+
* credentials.
|
|
17
|
+
*/
|
|
18
|
+
export const DEFAULT_PROFILE = "default";
|
|
19
|
+
const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
20
|
+
let explicitProfile = null;
|
|
21
|
+
export function validateProfileName(name) {
|
|
22
|
+
if (!PROFILE_NAME_PATTERN.test(name) || name === "." || name === "..") {
|
|
23
|
+
throw new Error(`Invalid profile name: ${JSON.stringify(name)}. Use letters, numbers, dots, dashes, ` +
|
|
24
|
+
"or underscores (must start with a letter or number, max 64 characters).");
|
|
25
|
+
}
|
|
26
|
+
return name;
|
|
27
|
+
}
|
|
28
|
+
/** Select the profile for this process; wins over $FULLSTACKGTM_PROFILE. */
|
|
29
|
+
export function setActiveProfile(name) {
|
|
30
|
+
explicitProfile = validateProfileName(name);
|
|
31
|
+
}
|
|
32
|
+
export function activeProfile() {
|
|
33
|
+
if (explicitProfile)
|
|
34
|
+
return explicitProfile;
|
|
35
|
+
const fromEnv = process.env.FULLSTACKGTM_PROFILE;
|
|
36
|
+
return fromEnv ? validateProfileName(fromEnv) : DEFAULT_PROFILE;
|
|
37
|
+
}
|
|
38
|
+
/** Base home directory, shared by every profile. */
|
|
39
|
+
export function baseHomeDir() {
|
|
7
40
|
return process.env.FSGTM_HOME ?? join(homedir(), ".fullstackgtm");
|
|
8
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Profiles that exist on disk (have a directory), always including the
|
|
44
|
+
* default profile. Existence does not imply stored credentials.
|
|
45
|
+
*/
|
|
46
|
+
export function listProfiles() {
|
|
47
|
+
const names = new Set([DEFAULT_PROFILE]);
|
|
48
|
+
try {
|
|
49
|
+
for (const entry of readdirSync(join(baseHomeDir(), "profiles"), { withFileTypes: true })) {
|
|
50
|
+
if (entry.isDirectory() && PROFILE_NAME_PATTERN.test(entry.name))
|
|
51
|
+
names.add(entry.name);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// No profiles directory yet.
|
|
56
|
+
}
|
|
57
|
+
return Array.from(names).sort();
|
|
58
|
+
}
|
|
59
|
+
export function credentialsDir() {
|
|
60
|
+
const base = baseHomeDir();
|
|
61
|
+
const profile = activeProfile();
|
|
62
|
+
return profile === DEFAULT_PROFILE ? base : join(base, "profiles", profile);
|
|
63
|
+
}
|
|
9
64
|
export function credentialsPath() {
|
|
10
65
|
return join(credentialsDir(), "credentials.json");
|
|
11
66
|
}
|
|
@@ -18,12 +73,18 @@ export function credentialsPath() {
|
|
|
18
73
|
*/
|
|
19
74
|
export function ensureSecureHomeDir() {
|
|
20
75
|
const dir = credentialsDir();
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
76
|
+
// A named profile nests under base/profiles/<name>; lock down every level
|
|
77
|
+
// we create, not just the leaf — recursive mkdir applies `mode` (less
|
|
78
|
+
// umask) only to directories it creates, and never to pre-existing ones.
|
|
79
|
+
const levels = dir === baseHomeDir() ? [dir] : [baseHomeDir(), join(baseHomeDir(), "profiles"), dir];
|
|
80
|
+
for (const level of levels) {
|
|
81
|
+
mkdirSync(level, { recursive: true, mode: 0o700 });
|
|
82
|
+
try {
|
|
83
|
+
chmodSync(level, 0o700);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Non-POSIX filesystems (e.g. Windows) ignore chmod; nothing to enforce.
|
|
87
|
+
}
|
|
27
88
|
}
|
|
28
89
|
return dir;
|
|
29
90
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,13 +6,15 @@ export { DEFAULT_LOOPBACK_PORT, DEFAULT_OAUTH_SCOPES, exchangeHubspotCode, refre
|
|
|
6
6
|
export { createSalesforceConnector, type SalesforceConnection, type SalesforceConnectorOptions, } from "./connectors/salesforce.ts";
|
|
7
7
|
export { pollSalesforceDeviceLogin, refreshSalesforceToken, startSalesforceDeviceLogin, validateSalesforceToken, type SalesforceDeviceAuthorization, type SalesforceTokenSet, } from "./connectors/salesforceAuth.ts";
|
|
8
8
|
export { createStripeConnector, type StripeConnectorOptions } from "./connectors/stripe.ts";
|
|
9
|
-
export { credentialsDir, credentialsPath, deleteCredential, getCredential, resolveHubspotAccessToken, resolveHubspotConnection, storeCredential, type HubspotConnection, type StoredCredential, } from "./credentials.ts";
|
|
9
|
+
export { activeProfile, credentialsDir, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotAccessToken, resolveHubspotConnection, setActiveProfile, storeCredential, type HubspotConnection, type StoredCredential, } from "./credentials.ts";
|
|
10
10
|
export { generateDemoSnapshot, type DemoSnapshotOptions } from "./demo.ts";
|
|
11
11
|
export { diffFindings, diffSnapshots, diffToMarkdown, type CollectionDiff, type FieldChange, type FindingsDrift, type RecordChange, type SnapshotDiff, } from "./diff.ts";
|
|
12
12
|
export { mergeSnapshots, type MergeConflict, type MergeMatch, type MergeReport, type MergeSuggestion, } from "./merge.ts";
|
|
13
13
|
export { createFilePlanStore, type PlanStore, type StoredPlan } from "./planStore.ts";
|
|
14
14
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
15
|
+
export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
|
|
15
16
|
export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, type CrmObjectType, type FieldMappings, } from "./mappings.ts";
|
|
16
|
-
export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.ts";
|
|
17
|
+
export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.ts";
|
|
17
18
|
export { sampleSnapshot } from "./sampleData.ts";
|
|
19
|
+
export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
|
|
18
20
|
export type { ApprovalStatus, AuditFinding, AuditFindingSeverity, CanonicalAccount, CanonicalActivity, CanonicalContact, CanonicalDeal, CanonicalGtmSnapshot, CanonicalUser, CrmProvider, GtmAuditRule, GtmConnector, GtmEvidence, GtmEvidenceSourceSystem, GtmObjectType, GtmPolicy, GtmRuleContext, GtmRuleResult, GtmSnapshotIndex, PatchOperation, PatchOperationResult, PatchOperationType, PatchPlan, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
|
package/dist/index.js
CHANGED
|
@@ -6,12 +6,14 @@ export { DEFAULT_LOOPBACK_PORT, DEFAULT_OAUTH_SCOPES, exchangeHubspotCode, refre
|
|
|
6
6
|
export { createSalesforceConnector, } from "./connectors/salesforce.js";
|
|
7
7
|
export { pollSalesforceDeviceLogin, refreshSalesforceToken, startSalesforceDeviceLogin, validateSalesforceToken, } from "./connectors/salesforceAuth.js";
|
|
8
8
|
export { createStripeConnector } from "./connectors/stripe.js";
|
|
9
|
-
export { credentialsDir, credentialsPath, deleteCredential, getCredential, resolveHubspotAccessToken, resolveHubspotConnection, storeCredential, } from "./credentials.js";
|
|
9
|
+
export { activeProfile, credentialsDir, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotAccessToken, resolveHubspotConnection, setActiveProfile, storeCredential, } from "./credentials.js";
|
|
10
10
|
export { generateDemoSnapshot } from "./demo.js";
|
|
11
11
|
export { diffFindings, diffSnapshots, diffToMarkdown, } from "./diff.js";
|
|
12
12
|
export { mergeSnapshots, } from "./merge.js";
|
|
13
13
|
export { createFilePlanStore } from "./planStore.js";
|
|
14
14
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
|
|
15
|
+
export { auditReportToHtml, auditReportToMarkdown } from "./report.js";
|
|
15
16
|
export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, } from "./mappings.js";
|
|
16
|
-
export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.js";
|
|
17
|
+
export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.js";
|
|
17
18
|
export { sampleSnapshot } from "./sampleData.js";
|
|
19
|
+
export { suggestValues } from "./suggest.js";
|
package/dist/mcp.js
CHANGED
|
@@ -45,6 +45,7 @@ import { generateDemoSnapshot } from "./demo.js";
|
|
|
45
45
|
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
|
|
46
46
|
import { builtinAuditRules } from "./rules.js";
|
|
47
47
|
import { sampleSnapshot } from "./sampleData.js";
|
|
48
|
+
import { suggestValues } from "./suggest.js";
|
|
48
49
|
function content(value) {
|
|
49
50
|
return {
|
|
50
51
|
content: [
|
|
@@ -140,6 +141,21 @@ export async function startMcpServer() {
|
|
|
140
141
|
const plan = auditSnapshot(await readSnapshot(provider, inputPath), policy, selected);
|
|
141
142
|
return content(output === "markdown" ? patchPlanToMarkdown(plan) : plan);
|
|
142
143
|
});
|
|
144
|
+
server.registerTool("fullstackgtm_suggest", {
|
|
145
|
+
title: "Suggest Placeholder Values",
|
|
146
|
+
description: "Derive values for a plan's requires_human_* placeholder operations from snapshot " +
|
|
147
|
+
"evidence (account-name matching, contact associations), with confidence levels and " +
|
|
148
|
+
"reasons. Read-only; feed accepted values into fullstackgtm_apply's valueOverrides.",
|
|
149
|
+
inputSchema: {
|
|
150
|
+
planPath: z.string(),
|
|
151
|
+
provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
|
|
152
|
+
inputPath: z.string().optional(),
|
|
153
|
+
},
|
|
154
|
+
}, async ({ planPath, provider, inputPath }) => {
|
|
155
|
+
const plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8"));
|
|
156
|
+
const snapshot = await readSnapshot(provider, inputPath);
|
|
157
|
+
return content({ suggestions: suggestValues(plan, snapshot) });
|
|
158
|
+
});
|
|
143
159
|
server.registerTool("fullstackgtm_rules", {
|
|
144
160
|
title: "List Audit Rules",
|
|
145
161
|
description: "List the built-in deterministic audit rules with ids and descriptions.",
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { AuditFinding, AuditFindingSeverity, CanonicalGtmSnapshot, GtmAuditRule, PatchPlan } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Client-ready rendering of an audit patch plan: the same findings the CLI
|
|
4
|
+
* prints for operators, reshaped for the person who owns the CRM — counts up
|
|
5
|
+
* front, prose summary, per-rule detail with capped examples, and next steps.
|
|
6
|
+
* Deterministic: identical plan + options produce identical output, so a
|
|
7
|
+
* report can be regenerated and diffed across engagements.
|
|
8
|
+
*/
|
|
9
|
+
export type ReportOptions = {
|
|
10
|
+
/** Report heading (default "GTM Data Health Report"). */
|
|
11
|
+
title?: string;
|
|
12
|
+
/** Organization the report is about; shown in the heading and summary. */
|
|
13
|
+
clientName?: string;
|
|
14
|
+
/** Attribution line in the footer (e.g. a consultancy or team name). */
|
|
15
|
+
preparedBy?: string;
|
|
16
|
+
/** Report date (YYYY-MM-DD); defaults to the plan's creation date (UTC). */
|
|
17
|
+
date?: string;
|
|
18
|
+
/** Example records listed per rule before truncating (default 10). */
|
|
19
|
+
maxExamplesPerRule?: number;
|
|
20
|
+
/** Rule metadata used for section titles, descriptions, and categories. */
|
|
21
|
+
rules?: GtmAuditRule[];
|
|
22
|
+
/** Snapshot the plan was generated from; enables record counts and rates. */
|
|
23
|
+
snapshot?: CanonicalGtmSnapshot;
|
|
24
|
+
};
|
|
25
|
+
type RuleSection = {
|
|
26
|
+
ruleId: string;
|
|
27
|
+
title: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
category: string;
|
|
30
|
+
severity: AuditFindingSeverity;
|
|
31
|
+
findings: AuditFinding[];
|
|
32
|
+
};
|
|
33
|
+
type ReportModel = {
|
|
34
|
+
title: string;
|
|
35
|
+
clientName?: string;
|
|
36
|
+
preparedBy?: string;
|
|
37
|
+
date: string;
|
|
38
|
+
provider?: string;
|
|
39
|
+
recordCounts?: {
|
|
40
|
+
label: string;
|
|
41
|
+
count: number;
|
|
42
|
+
}[];
|
|
43
|
+
totalRecords?: number;
|
|
44
|
+
severityCounts: Record<AuditFindingSeverity, number>;
|
|
45
|
+
affectedRecords: number;
|
|
46
|
+
sections: RuleSection[];
|
|
47
|
+
maxExamplesPerRule: number;
|
|
48
|
+
operationCount: number;
|
|
49
|
+
humanInputOperationCount: number;
|
|
50
|
+
recordLabels: Map<string, string>;
|
|
51
|
+
summaryText: string;
|
|
52
|
+
};
|
|
53
|
+
export declare function buildReportModel(plan: PatchPlan, options?: ReportOptions): ReportModel;
|
|
54
|
+
export declare function auditReportToMarkdown(plan: PatchPlan, options?: ReportOptions): string;
|
|
55
|
+
/**
|
|
56
|
+
* Self-contained HTML (inline styles, no external assets) so the file can be
|
|
57
|
+
* emailed or dropped into a shared drive and render anywhere, including
|
|
58
|
+
* print-to-PDF.
|
|
59
|
+
*/
|
|
60
|
+
export declare function auditReportToHtml(plan: PatchPlan, options?: ReportOptions): string;
|
|
61
|
+
export {};
|
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
|
@@ -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[];
|