fullstackgtm 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- export function credentialsDir() {
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
- mkdirSync(dir, { recursive: true, mode: 0o700 });
22
- try {
23
- chmodSync(dir, 0o700);
24
- }
25
- catch {
26
- // Non-POSIX filesystems (e.g. Windows) ignore chmod; nothing to enforce.
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
@@ -1,8 +1,39 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
1
9
  import { readFileSync } from "node:fs";
2
- import { resolve } from "node:path";
3
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
- import { z } from "zod/v4";
10
+ import { createRequire } from "node:module";
11
+ import { join, resolve } from "node:path";
12
+ import { pathToFileURL } from "node:url";
13
+ /**
14
+ * The MCP peers resolve normally when installed alongside this package, but
15
+ * `npx -p fullstackgtm -p @modelcontextprotocol/sdk -p zod` skips installing
16
+ * peers into the npx cache when the invoking project's node_modules already
17
+ * satisfies them — and the cache can't reach the project's tree. Fall back to
18
+ * resolving from the working directory: peer dependencies' natural home.
19
+ */
20
+ async function importPeer(specifier) {
21
+ try {
22
+ return (await import(__rewriteRelativeImportExtension(specifier)));
23
+ }
24
+ catch (error) {
25
+ try {
26
+ const projectRequire = createRequire(join(process.cwd(), "package.json"));
27
+ return (await import(__rewriteRelativeImportExtension(pathToFileURL(projectRequire.resolve(specifier)).href)));
28
+ }
29
+ catch {
30
+ throw error; // the original error carries the missing-peer signal mcp-bin reports on
31
+ }
32
+ }
33
+ }
34
+ const { McpServer } = await importPeer("@modelcontextprotocol/sdk/server/mcp.js");
35
+ const { StdioServerTransport } = await importPeer("@modelcontextprotocol/sdk/server/stdio.js");
36
+ const { z } = await importPeer("zod/v4");
6
37
  import { auditSnapshot, defaultPolicy } from "./audit.js";
7
38
  import { loadConfig, mergePolicy, resolveConfiguredRules } from "./config.js";
8
39
  import { applyPatchPlan } from "./connector.js";
@@ -14,6 +45,7 @@ import { generateDemoSnapshot } from "./demo.js";
14
45
  import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
15
46
  import { builtinAuditRules } from "./rules.js";
16
47
  import { sampleSnapshot } from "./sampleData.js";
48
+ import { suggestValues } from "./suggest.js";
17
49
  function content(value) {
18
50
  return {
19
51
  content: [
@@ -84,7 +116,8 @@ export async function startMcpServer() {
84
116
  server.registerTool("fullstackgtm_audit", {
85
117
  title: "GTM Ops Audit",
86
118
  description: "Run a dry-run GTM hygiene audit and return a reviewable patch plan. " +
87
- "Reads from the sample dataset, a snapshot file, or a live provider.",
119
+ "Sources: the realistic zero-credential demo CRM (provider: \"demo\" richest test data), " +
120
+ "the minimal sample dataset, a snapshot file, or a live provider.",
88
121
  inputSchema: {
89
122
  provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
90
123
  inputPath: z.string().optional(),
@@ -108,6 +141,21 @@ export async function startMcpServer() {
108
141
  const plan = auditSnapshot(await readSnapshot(provider, inputPath), policy, selected);
109
142
  return content(output === "markdown" ? patchPlanToMarkdown(plan) : plan);
110
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
+ });
111
159
  server.registerTool("fullstackgtm_rules", {
112
160
  title: "List Audit Rules",
113
161
  description: "List the built-in deterministic audit rules with ids and descriptions.",
@@ -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("&", "&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;