fullstackgtm 0.45.0 → 0.47.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 +115 -1
- package/INSTALL_FOR_AGENTS.md +13 -11
- package/README.md +27 -18
- package/dist/backfill.d.ts +86 -0
- package/dist/backfill.js +251 -0
- package/dist/bin.js +4 -1
- package/dist/cli/auth.d.ts +2 -0
- package/dist/cli/auth.js +119 -12
- package/dist/cli/backfill.d.ts +1 -0
- package/dist/cli/backfill.js +125 -0
- package/dist/cli/backfillRuns.d.ts +1 -0
- package/dist/cli/backfillRuns.js +187 -0
- package/dist/cli/call.js +23 -9
- package/dist/cli/enrich.js +28 -10
- package/dist/cli/fix.js +9 -7
- package/dist/cli/help.js +60 -23
- package/dist/cli/plans.js +13 -11
- package/dist/cli/shared.d.ts +4 -3
- package/dist/cli/shared.js +31 -20
- package/dist/cli/ui.d.ts +21 -0
- package/dist/cli/ui.js +53 -1
- package/dist/cli.js +37 -41
- package/dist/connector.d.ts +8 -0
- package/dist/connector.js +104 -21
- package/dist/connectors/hubspot.d.ts +8 -1
- package/dist/connectors/hubspot.js +406 -13
- package/dist/connectors/hubspotAuth.js +5 -1
- package/dist/connectors/linkedin.js +14 -14
- package/dist/connectors/salesforce.d.ts +8 -1
- package/dist/connectors/salesforce.js +163 -2
- package/dist/connectors/stripe.d.ts +37 -0
- package/dist/connectors/stripe.js +103 -31
- package/dist/enrich.d.ts +7 -0
- package/dist/enrich.js +7 -0
- package/dist/health.d.ts +11 -69
- package/dist/health.js +4 -134
- package/dist/healthScore.d.ts +71 -0
- package/dist/healthScore.js +143 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/llm.d.ts +29 -0
- package/dist/llm.js +206 -0
- package/dist/market.d.ts +39 -1
- package/dist/market.js +116 -44
- package/dist/marketClassify.d.ts +9 -1
- package/dist/marketClassify.js +10 -1
- package/dist/marketTaxonomy.d.ts +6 -1
- package/dist/marketTaxonomy.js +4 -2
- package/dist/mcp-bin.js +29 -0
- package/dist/mcp.js +117 -4
- package/dist/progress.d.ts +96 -0
- package/dist/progress.js +142 -0
- package/dist/runReport.d.ts +24 -0
- package/dist/runReport.js +139 -4
- package/dist/types.d.ts +33 -1
- package/docs/api.md +4 -2
- package/docs/architecture.md +2 -0
- package/docs/linkedin-connector-spec.md +1 -1
- package/llms.txt +3 -3
- package/package.json +10 -3
- package/skills/fullstackgtm/SKILL.md +1 -0
- package/src/backfill.ts +340 -0
- package/src/bin.ts +5 -1
- package/src/cli/auth.ts +135 -15
- package/src/cli/backfill.ts +156 -0
- package/src/cli/backfillRuns.ts +198 -0
- package/src/cli/call.ts +26 -10
- package/src/cli/enrich.ts +33 -10
- package/src/cli/fix.ts +11 -10
- package/src/cli/help.ts +61 -23
- package/src/cli/plans.ts +15 -14
- package/src/cli/shared.ts +44 -27
- package/src/cli/ui.ts +72 -1
- package/src/cli.ts +38 -41
- package/src/connector.ts +110 -16
- package/src/connectors/hubspot.ts +423 -14
- package/src/connectors/hubspotAuth.ts +5 -1
- package/src/connectors/linkedin.ts +14 -14
- package/src/connectors/salesforce.ts +168 -3
- package/src/connectors/stripe.ts +154 -34
- package/src/enrich.ts +13 -0
- package/src/health.ts +14 -213
- package/src/healthScore.ts +223 -0
- package/src/index.ts +28 -1
- package/src/llm.ts +239 -0
- package/src/market.ts +157 -44
- package/src/marketClassify.ts +18 -1
- package/src/marketTaxonomy.ts +10 -2
- package/src/mcp-bin.ts +32 -0
- package/src/mcp.ts +140 -6
- package/src/progress.ts +197 -0
- package/src/runReport.ts +159 -4
- package/src/types.ts +35 -2
- package/docs/dx-punch-list.md +0 -87
- package/docs/roadmap-to-1.0.md +0 -211
package/src/health.ts
CHANGED
|
@@ -2,7 +2,20 @@ import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { activeProfile, credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.ts";
|
|
5
|
-
import type {
|
|
5
|
+
import type { CanonicalGtmSnapshot } from "./types.ts";
|
|
6
|
+
import type { HealthEntry } from "./healthScore.ts";
|
|
7
|
+
|
|
8
|
+
// Pure scoring lives in healthScore.ts (node-free, importable from V8 runtimes
|
|
9
|
+
// like the hosted app's Convex queries); this module owns the fs-backed
|
|
10
|
+
// profile timeline. Re-exported so existing imports keep working.
|
|
11
|
+
export {
|
|
12
|
+
computeHealth,
|
|
13
|
+
summarizeHealth,
|
|
14
|
+
healthToMarkdown,
|
|
15
|
+
type HealthEntry,
|
|
16
|
+
type HealthRuleDelta,
|
|
17
|
+
type HealthRollup,
|
|
18
|
+
} from "./healthScore.ts";
|
|
6
19
|
|
|
7
20
|
/**
|
|
8
21
|
* The engagement workspace: a per-client health timeline that accrues from the
|
|
@@ -15,218 +28,6 @@ import type { AuditFindingSeverity, CanonicalGtmSnapshot, PatchPlan } from "./ty
|
|
|
15
28
|
* so it is stable in CI and comparable run-over-run.
|
|
16
29
|
*/
|
|
17
30
|
|
|
18
|
-
// Severity weights: a critical finding costs ~3× a warning, ~10× an info.
|
|
19
|
-
const SEVERITY_WEIGHT: Record<AuditFindingSeverity, number> = { info: 1, warning: 3, critical: 10 };
|
|
20
|
-
|
|
21
|
-
export type HealthEntry = {
|
|
22
|
-
/** ISO timestamp of the audit that produced this entry. */
|
|
23
|
-
at: string;
|
|
24
|
-
/** The saved plan this entry summarizes (correlates to plans/ and snapshots/). */
|
|
25
|
-
planId: string;
|
|
26
|
-
/** Deterministic 0–100 hygiene score (higher is healthier). */
|
|
27
|
-
score: number;
|
|
28
|
-
/** Total finding count across all rules. */
|
|
29
|
-
findings: number;
|
|
30
|
-
/** Severity-weighted finding total (drives the score). */
|
|
31
|
-
weightedFindings: number;
|
|
32
|
-
/** Record counts at audit time — the denominator that normalizes the score. */
|
|
33
|
-
records: { accounts: number; contacts: number; deals: number; total: number };
|
|
34
|
-
/** Finding count per rule id. */
|
|
35
|
-
byRule: Record<string, number>;
|
|
36
|
-
/** Finding count per severity. */
|
|
37
|
-
severityCounts: Record<AuditFindingSeverity, number>;
|
|
38
|
-
/**
|
|
39
|
-
* Per-object-type breakdown — so "is my contact data clean but my pipeline
|
|
40
|
-
* messy?" is answerable without re-auditing. Each type carries its own
|
|
41
|
-
* record-normalized score (same curve as the overall score, scoped to that
|
|
42
|
-
* type's records + findings).
|
|
43
|
-
*/
|
|
44
|
-
byObjectType: Record<"account" | "contact" | "deal", {
|
|
45
|
-
records: number;
|
|
46
|
-
findings: number;
|
|
47
|
-
weightedFindings: number;
|
|
48
|
-
score: number;
|
|
49
|
-
}>;
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
export type HealthRuleDelta = {
|
|
53
|
-
ruleId: string;
|
|
54
|
-
current: number;
|
|
55
|
-
previous: number;
|
|
56
|
-
/** current − previous: positive = more findings (worse), negative = fewer (better). */
|
|
57
|
-
delta: number;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
export type HealthRollup = {
|
|
61
|
-
profile: string;
|
|
62
|
-
auditCount: number;
|
|
63
|
-
first: string;
|
|
64
|
-
latest: string;
|
|
65
|
-
current: HealthEntry;
|
|
66
|
-
previous: HealthEntry | null;
|
|
67
|
-
/** current.score − previous.score: positive = improving. null on the first audit. */
|
|
68
|
-
scoreDelta: number | null;
|
|
69
|
-
ruleDeltas: HealthRuleDelta[];
|
|
70
|
-
history: Array<{ at: string; score: number; findings: number }>;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Deterministically score a saved audit. score = 100 / (1 + weighted findings
|
|
75
|
-
* per record): 0 findings → 100; one weighted finding per record → 50; the
|
|
76
|
-
* curve is bounded (0, 100], monotonic, and needs no clamping or magic
|
|
77
|
-
* constants. A messy 50-record CRM scores worse than the same finding count
|
|
78
|
-
* across 5,000 records, which is the point.
|
|
79
|
-
*/
|
|
80
|
-
export function computeHealth(plan: PatchPlan, snapshot: CanonicalGtmSnapshot, at: string): HealthEntry {
|
|
81
|
-
const byRule: Record<string, number> = {};
|
|
82
|
-
const severityCounts: Record<AuditFindingSeverity, number> = { info: 0, warning: 0, critical: 0 };
|
|
83
|
-
const typeTally: Record<"account" | "contact" | "deal", { findings: number; weightedFindings: number }> = {
|
|
84
|
-
account: { findings: 0, weightedFindings: 0 },
|
|
85
|
-
contact: { findings: 0, weightedFindings: 0 },
|
|
86
|
-
deal: { findings: 0, weightedFindings: 0 },
|
|
87
|
-
};
|
|
88
|
-
let weightedFindings = 0;
|
|
89
|
-
for (const finding of plan.findings) {
|
|
90
|
-
byRule[finding.ruleId] = (byRule[finding.ruleId] ?? 0) + 1;
|
|
91
|
-
severityCounts[finding.severity] += 1;
|
|
92
|
-
weightedFindings += SEVERITY_WEIGHT[finding.severity];
|
|
93
|
-
const t = finding.objectType;
|
|
94
|
-
if (t === "account" || t === "contact" || t === "deal") {
|
|
95
|
-
typeTally[t].findings += 1;
|
|
96
|
-
typeTally[t].weightedFindings += SEVERITY_WEIGHT[finding.severity];
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const records = {
|
|
101
|
-
accounts: snapshot.accounts.length,
|
|
102
|
-
contacts: snapshot.contacts.length,
|
|
103
|
-
deals: snapshot.deals.length,
|
|
104
|
-
total: snapshot.accounts.length + snapshot.contacts.length + snapshot.deals.length,
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
const scoreFor = (weighted: number, recordCount: number) =>
|
|
108
|
-
Math.round(100 / (1 + weighted / Math.max(recordCount, 1)));
|
|
109
|
-
const recordsByType = { account: records.accounts, contact: records.contacts, deal: records.deals };
|
|
110
|
-
const byObjectType = (["account", "contact", "deal"] as const).reduce(
|
|
111
|
-
(acc, t) => {
|
|
112
|
-
acc[t] = {
|
|
113
|
-
records: recordsByType[t],
|
|
114
|
-
findings: typeTally[t].findings,
|
|
115
|
-
weightedFindings: typeTally[t].weightedFindings,
|
|
116
|
-
score: scoreFor(typeTally[t].weightedFindings, recordsByType[t]),
|
|
117
|
-
};
|
|
118
|
-
return acc;
|
|
119
|
-
},
|
|
120
|
-
{} as HealthEntry["byObjectType"],
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
const score = scoreFor(weightedFindings, records.total);
|
|
124
|
-
|
|
125
|
-
return {
|
|
126
|
-
at,
|
|
127
|
-
planId: plan.id,
|
|
128
|
-
score,
|
|
129
|
-
findings: plan.findings.length,
|
|
130
|
-
weightedFindings,
|
|
131
|
-
records,
|
|
132
|
-
byRule,
|
|
133
|
-
severityCounts,
|
|
134
|
-
byObjectType,
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Roll a timeline up into the current state, the change since last time, and per-rule deltas. */
|
|
139
|
-
export function summarizeHealth(entries: HealthEntry[], profile: string): HealthRollup | null {
|
|
140
|
-
if (entries.length === 0) return null;
|
|
141
|
-
// Oldest → newest, so `current` is the last entry.
|
|
142
|
-
const sorted = [...entries].sort((a, b) => a.at.localeCompare(b.at));
|
|
143
|
-
const current = sorted[sorted.length - 1];
|
|
144
|
-
const previous = sorted.length > 1 ? sorted[sorted.length - 2] : null;
|
|
145
|
-
|
|
146
|
-
const ruleIds = new Set<string>([
|
|
147
|
-
...Object.keys(current.byRule),
|
|
148
|
-
...(previous ? Object.keys(previous.byRule) : []),
|
|
149
|
-
]);
|
|
150
|
-
const ruleDeltas: HealthRuleDelta[] = [...ruleIds]
|
|
151
|
-
.map((ruleId) => {
|
|
152
|
-
const cur = current.byRule[ruleId] ?? 0;
|
|
153
|
-
const prev = previous?.byRule[ruleId] ?? 0;
|
|
154
|
-
return { ruleId, current: cur, previous: prev, delta: cur - prev };
|
|
155
|
-
})
|
|
156
|
-
.sort((a, b) => b.current - a.current || a.ruleId.localeCompare(b.ruleId));
|
|
157
|
-
|
|
158
|
-
return {
|
|
159
|
-
profile,
|
|
160
|
-
auditCount: sorted.length,
|
|
161
|
-
first: sorted[0].at,
|
|
162
|
-
latest: current.at,
|
|
163
|
-
current,
|
|
164
|
-
previous,
|
|
165
|
-
scoreDelta: previous ? current.score - previous.score : null,
|
|
166
|
-
ruleDeltas,
|
|
167
|
-
history: sorted.map((entry) => ({ at: entry.at, score: entry.score, findings: entry.findings })),
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Sign-based and uniform: ▲ = the number went up, ▼ = down. The reader supplies
|
|
172
|
-
// the meaning (score up = good; findings up = bad) — mixing arrow direction with
|
|
173
|
-
// good/bad reads as contradictory ("▲ -3").
|
|
174
|
-
function arrow(delta: number): string {
|
|
175
|
-
if (delta === 0) return "·";
|
|
176
|
-
return delta > 0 ? "▲" : "▼";
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** Human-readable rollup: headline score + trend, then per-rule change since last audit. */
|
|
180
|
-
export function healthToMarkdown(rollup: HealthRollup): string {
|
|
181
|
-
const { current, scoreDelta } = rollup;
|
|
182
|
-
const lines = [`# GTM health — profile \`${rollup.profile}\``, ""];
|
|
183
|
-
|
|
184
|
-
const deltaText =
|
|
185
|
-
scoreDelta === null
|
|
186
|
-
? "(first audit — no prior reading)"
|
|
187
|
-
: `${arrow(scoreDelta)} ${scoreDelta >= 0 ? "+" : ""}${scoreDelta} since the previous audit`;
|
|
188
|
-
lines.push(
|
|
189
|
-
`Score: **${current.score}/100** ${deltaText}`,
|
|
190
|
-
`Audits: ${rollup.auditCount} over ${shortDate(rollup.first)} → ${shortDate(rollup.latest)}`,
|
|
191
|
-
`Findings: ${current.findings} (${current.severityCounts.critical} critical, ${current.severityCounts.warning} warning, ${current.severityCounts.info} info)`,
|
|
192
|
-
`Records: ${current.records.accounts} accounts, ${current.records.contacts} contacts, ${current.records.deals} deals`,
|
|
193
|
-
"",
|
|
194
|
-
);
|
|
195
|
-
|
|
196
|
-
lines.push("## By object type", "", "| Type | Score | Findings | Records |", "| --- | --- | --- | --- |");
|
|
197
|
-
for (const t of ["account", "contact", "deal"] as const) {
|
|
198
|
-
const b = current.byObjectType[t];
|
|
199
|
-
lines.push(`| ${t} | ${b.score}/100 | ${b.findings} | ${b.records} |`);
|
|
200
|
-
}
|
|
201
|
-
lines.push("");
|
|
202
|
-
|
|
203
|
-
if (rollup.history.length > 1) {
|
|
204
|
-
lines.push("## Trend", "");
|
|
205
|
-
for (const point of rollup.history) {
|
|
206
|
-
const marker = point.at === rollup.latest ? " ← latest" : "";
|
|
207
|
-
lines.push(`- ${shortDate(point.at)} score ${point.score} (${point.findings} findings)${marker}`);
|
|
208
|
-
}
|
|
209
|
-
lines.push("");
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if (rollup.ruleDeltas.length > 0) {
|
|
213
|
-
lines.push("## By rule (Δ since previous audit)", "", "| Rule | Findings | Δ |", "| --- | --- | --- |");
|
|
214
|
-
for (const rule of rollup.ruleDeltas) {
|
|
215
|
-
const sign = rule.delta > 0 ? `+${rule.delta}` : `${rule.delta}`;
|
|
216
|
-
const cell = rule.delta === 0 ? "·" : `${arrow(rule.delta)} ${sign}`;
|
|
217
|
-
lines.push(`| ${rule.ruleId} | ${rule.current} | ${cell} |`);
|
|
218
|
-
}
|
|
219
|
-
lines.push("");
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
lines.push("> Score is deterministic: 100 / (1 + severity-weighted findings per record). Lower findings or more clean records ⇒ higher score.");
|
|
223
|
-
return `${lines.join("\n")}\n`;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function shortDate(iso: string): string {
|
|
227
|
-
return iso.slice(0, 10);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
31
|
// ── Profile-scoped storage ──────────────────────────────────────────────────
|
|
231
32
|
|
|
232
33
|
/** `$FSGTM_HOME[/profiles/<name>]/health.jsonl` — append-only, one entry per audit-save. */
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { AuditFindingSeverity, CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure, node-free health scoring. Split from health.ts (which owns the
|
|
5
|
+
* fs-backed profile timeline) so V8-runtime consumers — the hosted app's
|
|
6
|
+
* Convex queries — can import the scoring math without pulling node built-ins,
|
|
7
|
+
* the same pattern as audit.ts/format.ts.
|
|
8
|
+
*
|
|
9
|
+
* The score is DETERMINISTIC (no LLM): same plan + same snapshot → same score,
|
|
10
|
+
* so it is stable in CI and comparable run-over-run.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Severity weights: a critical finding costs ~3× a warning, ~10× an info.
|
|
14
|
+
const SEVERITY_WEIGHT: Record<AuditFindingSeverity, number> = { info: 1, warning: 3, critical: 10 };
|
|
15
|
+
|
|
16
|
+
export type HealthEntry = {
|
|
17
|
+
/** ISO timestamp of the audit that produced this entry. */
|
|
18
|
+
at: string;
|
|
19
|
+
/** The saved plan this entry summarizes (correlates to plans/ and snapshots/). */
|
|
20
|
+
planId: string;
|
|
21
|
+
/** Deterministic 0–100 hygiene score (higher is healthier). */
|
|
22
|
+
score: number;
|
|
23
|
+
/** Total finding count across all rules. */
|
|
24
|
+
findings: number;
|
|
25
|
+
/** Severity-weighted finding total (drives the score). */
|
|
26
|
+
weightedFindings: number;
|
|
27
|
+
/** Record counts at audit time — the denominator that normalizes the score. */
|
|
28
|
+
records: { accounts: number; contacts: number; deals: number; total: number };
|
|
29
|
+
/** Finding count per rule id. */
|
|
30
|
+
byRule: Record<string, number>;
|
|
31
|
+
/** Finding count per severity. */
|
|
32
|
+
severityCounts: Record<AuditFindingSeverity, number>;
|
|
33
|
+
/**
|
|
34
|
+
* Per-object-type breakdown — so "is my contact data clean but my pipeline
|
|
35
|
+
* messy?" is answerable without re-auditing. Each type carries its own
|
|
36
|
+
* record-normalized score (same curve as the overall score, scoped to that
|
|
37
|
+
* type's records + findings).
|
|
38
|
+
*/
|
|
39
|
+
byObjectType: Record<"account" | "contact" | "deal", {
|
|
40
|
+
records: number;
|
|
41
|
+
findings: number;
|
|
42
|
+
weightedFindings: number;
|
|
43
|
+
score: number;
|
|
44
|
+
}>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type HealthRuleDelta = {
|
|
48
|
+
ruleId: string;
|
|
49
|
+
current: number;
|
|
50
|
+
previous: number;
|
|
51
|
+
/** current − previous: positive = more findings (worse), negative = fewer (better). */
|
|
52
|
+
delta: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type HealthRollup = {
|
|
56
|
+
profile: string;
|
|
57
|
+
auditCount: number;
|
|
58
|
+
first: string;
|
|
59
|
+
latest: string;
|
|
60
|
+
current: HealthEntry;
|
|
61
|
+
previous: HealthEntry | null;
|
|
62
|
+
/** current.score − previous.score: positive = improving. null on the first audit. */
|
|
63
|
+
scoreDelta: number | null;
|
|
64
|
+
ruleDeltas: HealthRuleDelta[];
|
|
65
|
+
history: Array<{ at: string; score: number; findings: number }>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Deterministically score a saved audit. score = 100 / (1 + weighted findings
|
|
70
|
+
* per record): 0 findings → 100; one weighted finding per record → 50; the
|
|
71
|
+
* curve is bounded (0, 100], monotonic, and needs no clamping or magic
|
|
72
|
+
* constants. A messy 50-record CRM scores worse than the same finding count
|
|
73
|
+
* across 5,000 records, which is the point.
|
|
74
|
+
*/
|
|
75
|
+
export function computeHealth(plan: PatchPlan, snapshot: CanonicalGtmSnapshot, at: string): HealthEntry {
|
|
76
|
+
const byRule: Record<string, number> = {};
|
|
77
|
+
const severityCounts: Record<AuditFindingSeverity, number> = { info: 0, warning: 0, critical: 0 };
|
|
78
|
+
const typeTally: Record<"account" | "contact" | "deal", { findings: number; weightedFindings: number }> = {
|
|
79
|
+
account: { findings: 0, weightedFindings: 0 },
|
|
80
|
+
contact: { findings: 0, weightedFindings: 0 },
|
|
81
|
+
deal: { findings: 0, weightedFindings: 0 },
|
|
82
|
+
};
|
|
83
|
+
let weightedFindings = 0;
|
|
84
|
+
for (const finding of plan.findings) {
|
|
85
|
+
byRule[finding.ruleId] = (byRule[finding.ruleId] ?? 0) + 1;
|
|
86
|
+
severityCounts[finding.severity] += 1;
|
|
87
|
+
weightedFindings += SEVERITY_WEIGHT[finding.severity];
|
|
88
|
+
const t = finding.objectType;
|
|
89
|
+
if (t === "account" || t === "contact" || t === "deal") {
|
|
90
|
+
typeTally[t].findings += 1;
|
|
91
|
+
typeTally[t].weightedFindings += SEVERITY_WEIGHT[finding.severity];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const records = {
|
|
96
|
+
accounts: snapshot.accounts.length,
|
|
97
|
+
contacts: snapshot.contacts.length,
|
|
98
|
+
deals: snapshot.deals.length,
|
|
99
|
+
total: snapshot.accounts.length + snapshot.contacts.length + snapshot.deals.length,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const scoreFor = (weighted: number, recordCount: number) =>
|
|
103
|
+
Math.round(100 / (1 + weighted / Math.max(recordCount, 1)));
|
|
104
|
+
const recordsByType = { account: records.accounts, contact: records.contacts, deal: records.deals };
|
|
105
|
+
const byObjectType = (["account", "contact", "deal"] as const).reduce(
|
|
106
|
+
(acc, t) => {
|
|
107
|
+
acc[t] = {
|
|
108
|
+
records: recordsByType[t],
|
|
109
|
+
findings: typeTally[t].findings,
|
|
110
|
+
weightedFindings: typeTally[t].weightedFindings,
|
|
111
|
+
score: scoreFor(typeTally[t].weightedFindings, recordsByType[t]),
|
|
112
|
+
};
|
|
113
|
+
return acc;
|
|
114
|
+
},
|
|
115
|
+
{} as HealthEntry["byObjectType"],
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const score = scoreFor(weightedFindings, records.total);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
at,
|
|
122
|
+
planId: plan.id,
|
|
123
|
+
score,
|
|
124
|
+
findings: plan.findings.length,
|
|
125
|
+
weightedFindings,
|
|
126
|
+
records,
|
|
127
|
+
byRule,
|
|
128
|
+
severityCounts,
|
|
129
|
+
byObjectType,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Roll a timeline up into the current state, the change since last time, and per-rule deltas. */
|
|
134
|
+
export function summarizeHealth(entries: HealthEntry[], profile: string): HealthRollup | null {
|
|
135
|
+
if (entries.length === 0) return null;
|
|
136
|
+
// Oldest → newest, so `current` is the last entry.
|
|
137
|
+
const sorted = [...entries].sort((a, b) => a.at.localeCompare(b.at));
|
|
138
|
+
const current = sorted[sorted.length - 1];
|
|
139
|
+
const previous = sorted.length > 1 ? sorted[sorted.length - 2] : null;
|
|
140
|
+
|
|
141
|
+
const ruleIds = new Set<string>([
|
|
142
|
+
...Object.keys(current.byRule),
|
|
143
|
+
...(previous ? Object.keys(previous.byRule) : []),
|
|
144
|
+
]);
|
|
145
|
+
const ruleDeltas: HealthRuleDelta[] = [...ruleIds]
|
|
146
|
+
.map((ruleId) => {
|
|
147
|
+
const cur = current.byRule[ruleId] ?? 0;
|
|
148
|
+
const prev = previous?.byRule[ruleId] ?? 0;
|
|
149
|
+
return { ruleId, current: cur, previous: prev, delta: cur - prev };
|
|
150
|
+
})
|
|
151
|
+
.sort((a, b) => b.current - a.current || a.ruleId.localeCompare(b.ruleId));
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
profile,
|
|
155
|
+
auditCount: sorted.length,
|
|
156
|
+
first: sorted[0].at,
|
|
157
|
+
latest: current.at,
|
|
158
|
+
current,
|
|
159
|
+
previous,
|
|
160
|
+
scoreDelta: previous ? current.score - previous.score : null,
|
|
161
|
+
ruleDeltas,
|
|
162
|
+
history: sorted.map((entry) => ({ at: entry.at, score: entry.score, findings: entry.findings })),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Sign-based and uniform: ▲ = the number went up, ▼ = down. The reader supplies
|
|
167
|
+
// the meaning (score up = good; findings up = bad) — mixing arrow direction with
|
|
168
|
+
// good/bad reads as contradictory ("▲ -3").
|
|
169
|
+
function arrow(delta: number): string {
|
|
170
|
+
if (delta === 0) return "·";
|
|
171
|
+
return delta > 0 ? "▲" : "▼";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Human-readable rollup: headline score + trend, then per-rule change since last audit. */
|
|
175
|
+
export function healthToMarkdown(rollup: HealthRollup): string {
|
|
176
|
+
const { current, scoreDelta } = rollup;
|
|
177
|
+
const lines = [`# GTM health — profile \`${rollup.profile}\``, ""];
|
|
178
|
+
|
|
179
|
+
const deltaText =
|
|
180
|
+
scoreDelta === null
|
|
181
|
+
? "(first audit — no prior reading)"
|
|
182
|
+
: `${arrow(scoreDelta)} ${scoreDelta >= 0 ? "+" : ""}${scoreDelta} since the previous audit`;
|
|
183
|
+
lines.push(
|
|
184
|
+
`Score: **${current.score}/100** ${deltaText}`,
|
|
185
|
+
`Audits: ${rollup.auditCount} over ${shortDate(rollup.first)} → ${shortDate(rollup.latest)}`,
|
|
186
|
+
`Findings: ${current.findings} (${current.severityCounts.critical} critical, ${current.severityCounts.warning} warning, ${current.severityCounts.info} info)`,
|
|
187
|
+
`Records: ${current.records.accounts} accounts, ${current.records.contacts} contacts, ${current.records.deals} deals`,
|
|
188
|
+
"",
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
lines.push("## By object type", "", "| Type | Score | Findings | Records |", "| --- | --- | --- | --- |");
|
|
192
|
+
for (const t of ["account", "contact", "deal"] as const) {
|
|
193
|
+
const b = current.byObjectType[t];
|
|
194
|
+
lines.push(`| ${t} | ${b.score}/100 | ${b.findings} | ${b.records} |`);
|
|
195
|
+
}
|
|
196
|
+
lines.push("");
|
|
197
|
+
|
|
198
|
+
if (rollup.history.length > 1) {
|
|
199
|
+
lines.push("## Trend", "");
|
|
200
|
+
for (const point of rollup.history) {
|
|
201
|
+
const marker = point.at === rollup.latest ? " ← latest" : "";
|
|
202
|
+
lines.push(`- ${shortDate(point.at)} score ${point.score} (${point.findings} findings)${marker}`);
|
|
203
|
+
}
|
|
204
|
+
lines.push("");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (rollup.ruleDeltas.length > 0) {
|
|
208
|
+
lines.push("## By rule (Δ since previous audit)", "", "| Rule | Findings | Δ |", "| --- | --- | --- |");
|
|
209
|
+
for (const rule of rollup.ruleDeltas) {
|
|
210
|
+
const sign = rule.delta > 0 ? `+${rule.delta}` : `${rule.delta}`;
|
|
211
|
+
const cell = rule.delta === 0 ? "·" : `${arrow(rule.delta)} ${sign}`;
|
|
212
|
+
lines.push(`| ${rule.ruleId} | ${rule.current} | ${cell} |`);
|
|
213
|
+
}
|
|
214
|
+
lines.push("");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
lines.push("> Score is deterministic: 100 / (1 + severity-weighted findings per record). Lower findings or more clean records ⇒ higher score.");
|
|
218
|
+
return `${lines.join("\n")}\n`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function shortDate(iso: string): string {
|
|
222
|
+
return iso.slice(0, 10);
|
|
223
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,20 @@ export {
|
|
|
21
21
|
type LoadedConfig,
|
|
22
22
|
} from "./config.ts";
|
|
23
23
|
export { applyPatchPlan, type ApplyPatchPlanOptions } from "./connector.ts";
|
|
24
|
+
export {
|
|
25
|
+
APPLY_STAGES,
|
|
26
|
+
BACKFILL_STRIPE_STAGES,
|
|
27
|
+
composeListeners,
|
|
28
|
+
createProgressEmitter,
|
|
29
|
+
CRM_SYNC_STAGES,
|
|
30
|
+
nullProgressEmitter,
|
|
31
|
+
SNAPSHOT_PULL_STAGES,
|
|
32
|
+
STRIPE_SNAPSHOT_STAGES,
|
|
33
|
+
type ProgressEmitter,
|
|
34
|
+
type ProgressEvent,
|
|
35
|
+
type ProgressListener,
|
|
36
|
+
type ProgressSnapshot,
|
|
37
|
+
} from "./progress.ts";
|
|
24
38
|
export { createHubspotConnector, type HubspotConnectorOptions } from "./connectors/hubspot.ts";
|
|
25
39
|
export {
|
|
26
40
|
DEFAULT_LOOPBACK_PORT,
|
|
@@ -45,7 +59,20 @@ export {
|
|
|
45
59
|
type SalesforceDeviceAuthorization,
|
|
46
60
|
type SalesforceTokenSet,
|
|
47
61
|
} from "./connectors/salesforceAuth.ts";
|
|
48
|
-
export {
|
|
62
|
+
export {
|
|
63
|
+
createStripeConnector,
|
|
64
|
+
fetchStripePaidInvoices,
|
|
65
|
+
type StripeConnectorOptions,
|
|
66
|
+
type StripePaidInvoice,
|
|
67
|
+
} from "./connectors/stripe.ts";
|
|
68
|
+
export {
|
|
69
|
+
buildStripeBackfillPlan,
|
|
70
|
+
DEFAULT_BACKFILL_MATCH_PROPERTY,
|
|
71
|
+
type StripeBackfillCounts,
|
|
72
|
+
type StripeBackfillOptions,
|
|
73
|
+
type StripeBackfillResult,
|
|
74
|
+
type StripeBackfillUnmatched,
|
|
75
|
+
} from "./backfill.ts";
|
|
49
76
|
export {
|
|
50
77
|
activeProfile,
|
|
51
78
|
credentialsDir,
|