fullstackgtm 0.34.0 → 0.38.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 +141 -0
- package/INSTALL_FOR_AGENTS.md +8 -0
- package/README.md +39 -0
- package/dist/acquireLinkedIn.d.ts +41 -0
- package/dist/acquireLinkedIn.js +57 -0
- package/dist/acquireMeter.d.ts +67 -0
- package/dist/acquireMeter.js +145 -0
- package/dist/acquireSeen.d.ts +5 -0
- package/dist/acquireSeen.js +54 -0
- package/dist/assign.d.ts +83 -0
- package/dist/assign.js +146 -0
- package/dist/bin.js +14 -2
- package/dist/cli.js +817 -26
- package/dist/connectors/hubspot.js +140 -0
- package/dist/connectors/linkedin.d.ts +78 -0
- package/dist/connectors/linkedin.js +199 -0
- package/dist/connectors/prospectSources.d.ts +91 -0
- package/dist/connectors/prospectSources.js +227 -0
- package/dist/enrich.d.ts +107 -0
- package/dist/enrich.js +315 -5
- package/dist/format.d.ts +3 -1
- package/dist/format.js +14 -2
- package/dist/health.d.ts +71 -0
- package/dist/health.js +172 -0
- package/dist/icp.d.ts +96 -0
- package/dist/icp.js +256 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mappings.js +3 -0
- package/dist/reassign.d.ts +11 -2
- package/dist/reassign.js +13 -6
- package/dist/runReport.d.ts +9 -0
- package/dist/runReport.js +66 -0
- package/dist/types.d.ts +25 -1
- package/docs/api.md +53 -3
- package/docs/architecture.md +11 -1
- package/docs/dx-punch-list.md +87 -0
- package/docs/linkedin-connector-spec.md +87 -0
- package/llms.txt +38 -1
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +5 -3
- package/src/acquireLinkedIn.ts +83 -0
- package/src/acquireMeter.ts +186 -0
- package/src/acquireSeen.ts +57 -0
- package/src/assign.ts +193 -0
- package/src/bin.ts +17 -4
- package/src/cli.ts +965 -25
- package/src/connectors/hubspot.ts +145 -0
- package/src/connectors/linkedin.ts +272 -0
- package/src/connectors/prospectSources.ts +324 -0
- package/src/enrich.ts +411 -5
- package/src/format.ts +20 -2
- package/src/health.ts +238 -0
- package/src/icp.ts +313 -0
- package/src/index.ts +18 -0
- package/src/mappings.ts +3 -0
- package/src/reassign.ts +24 -8
- package/src/runReport.ts +76 -0
- package/src/types.ts +32 -0
package/dist/enrich.js
CHANGED
|
@@ -2,16 +2,17 @@ import { mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.js";
|
|
4
4
|
import { HUBSPOT_DEFAULT_FIELD_MAPPINGS } from "./mappings.js";
|
|
5
|
+
import { parseAssignmentPolicy, resolveAssignment } from "./assign.js";
|
|
5
6
|
export const ENRICH_CONFIG_FILE_NAME = "enrich.config.json";
|
|
6
7
|
export const DEFAULT_STALE_DAYS = 90;
|
|
7
8
|
const OBJECT_TYPES = ["company", "contact"];
|
|
8
9
|
/** Match keys the matcher knows how to read off canonical snapshot records. */
|
|
9
10
|
const MATCH_KEYS = {
|
|
10
11
|
company: ["domain", "name"],
|
|
11
|
-
contact: ["email", "name"],
|
|
12
|
+
contact: ["email", "name", "linkedin"],
|
|
12
13
|
};
|
|
13
14
|
/** API source ids the MVP can pull from. */
|
|
14
|
-
export const SUPPORTED_API_SOURCES = ["apollo"];
|
|
15
|
+
export const SUPPORTED_API_SOURCES = ["apollo", "explorium", "pipe0"];
|
|
15
16
|
/**
|
|
16
17
|
* Canonical fields enrich may target, plus the HubSpot property spellings the
|
|
17
18
|
* config may use for them (so `"crm": "numberofemployees"` and
|
|
@@ -171,10 +172,113 @@ export function parseEnrichConfig(raw) {
|
|
|
171
172
|
}
|
|
172
173
|
}
|
|
173
174
|
}
|
|
174
|
-
|
|
175
|
-
|
|
175
|
+
// `fields` drives append/refresh. An acquire-only config (net-new lead
|
|
176
|
+
// creation, no blank-filling) legitimately maps no fields — accept it when
|
|
177
|
+
// an acquire.create mapping is present.
|
|
178
|
+
const hasAcquireCreate = Boolean(config.acquire?.create && Object.keys(config.acquire.create).length > 0);
|
|
179
|
+
if (!anyField && !hasAcquireCreate) {
|
|
180
|
+
fail('"fields" maps nothing — add at least one field entry (or an "acquire.create" mapping)');
|
|
181
|
+
}
|
|
182
|
+
// Normalize + validate the assignment policy (fails loud on shape errors so a
|
|
183
|
+
// misconfigured rule never silently mis-routes a created lead).
|
|
184
|
+
if (config.acquire?.assign !== undefined) {
|
|
185
|
+
try {
|
|
186
|
+
config.acquire.assign = parseAssignmentPolicy(config.acquire.assign);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
176
192
|
return config;
|
|
177
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Built-in zero-config presets for well-known sources, so the common Mode-A
|
|
196
|
+
* loop — "feed a Clay export, get the dedup/routing verdict" — needs no
|
|
197
|
+
* hand-authored config. These are **match-only** (empty `fields`): the value is
|
|
198
|
+
* the matched / unmatched / ambiguous routing and collision detection, not field
|
|
199
|
+
* writes. Drop an `enrich.config.json` next to your run to map fields for actual
|
|
200
|
+
* field enrichment; an explicit config always wins over a preset. Constructed
|
|
201
|
+
* directly (not via `parseEnrichConfig`) so the match-only shape is allowed.
|
|
202
|
+
*/
|
|
203
|
+
export const BUILTIN_ENRICH_PRESETS = {
|
|
204
|
+
clay: {
|
|
205
|
+
sources: { clay: { kind: "ingest", format: "csv" } },
|
|
206
|
+
policy: { overwrite: "never", defaultStaleDays: 90 },
|
|
207
|
+
match: {
|
|
208
|
+
company: { keys: ["domain"], onAmbiguous: "skip" },
|
|
209
|
+
contact: { keys: ["email"], onAmbiguous: "skip" },
|
|
210
|
+
},
|
|
211
|
+
fields: {},
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
export function builtinEnrichPreset(source) {
|
|
215
|
+
return source ? BUILTIN_ENRICH_PRESETS[source] : undefined;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Zero-config `enrich acquire` preset so every client gets targeted, deduped,
|
|
219
|
+
* metered lead-fill out of the box — no hand-authored enrich.config.json. The
|
|
220
|
+
* ICP (icp.json) supplies the targeting; this supplies sensible plumbing. The
|
|
221
|
+
* create mapping writes the prospect's LinkedIn URL into hs_linkedin_url, so
|
|
222
|
+
* each created contact strengthens future dedup. An explicit config always wins.
|
|
223
|
+
*/
|
|
224
|
+
export function builtinAcquirePreset(source) {
|
|
225
|
+
const provider = source ?? "pipe0";
|
|
226
|
+
if (provider === "linkedin" || provider === "heyreach") {
|
|
227
|
+
// LinkedIn discovery reads our OWN HeyReach lead list, so it costs $0/record
|
|
228
|
+
// (no spend budget) and keys on the LinkedIn URL — leads are sparse on email,
|
|
229
|
+
// so email-keying would drop most of them. listId is supplied via --list.
|
|
230
|
+
return {
|
|
231
|
+
sources: { linkedin: { kind: "api" } },
|
|
232
|
+
match: { contact: { keys: ["linkedin", "email"], onAmbiguous: "skip" } },
|
|
233
|
+
fields: {},
|
|
234
|
+
policy: { overwrite: "never" },
|
|
235
|
+
acquire: {
|
|
236
|
+
budget: { records: { perDay: 50, perMonth: 500 } },
|
|
237
|
+
costPerRecord: { linkedin: 0 },
|
|
238
|
+
discovery: { linkedin: { provider: "linkedin", size: 25 } },
|
|
239
|
+
create: {
|
|
240
|
+
contact: {
|
|
241
|
+
matchKey: "linkedin",
|
|
242
|
+
properties: {
|
|
243
|
+
hs_linkedin_url: "linkedin",
|
|
244
|
+
firstname: "firstName",
|
|
245
|
+
lastname: "lastName",
|
|
246
|
+
jobtitle: "jobTitle",
|
|
247
|
+
company: "companyName",
|
|
248
|
+
email: "email",
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
if (provider !== "pipe0" && provider !== "explorium")
|
|
256
|
+
return undefined;
|
|
257
|
+
return {
|
|
258
|
+
sources: { [provider]: { kind: "api" } },
|
|
259
|
+
match: { contact: { keys: ["email"], onAmbiguous: "skip" } },
|
|
260
|
+
fields: {},
|
|
261
|
+
policy: { overwrite: "never" },
|
|
262
|
+
acquire: {
|
|
263
|
+
budget: { records: { perDay: 50, perMonth: 500 }, spend: { perDay: 25, perMonth: 250 } },
|
|
264
|
+
costPerRecord: { pipe0: 0.1, explorium: 0.4 },
|
|
265
|
+
discovery: { [provider]: { provider, size: 25 } },
|
|
266
|
+
create: {
|
|
267
|
+
contact: {
|
|
268
|
+
matchKey: "email",
|
|
269
|
+
properties: {
|
|
270
|
+
email: "email",
|
|
271
|
+
firstname: "firstName",
|
|
272
|
+
lastname: "lastName",
|
|
273
|
+
jobtitle: "jobTitle",
|
|
274
|
+
company: "companyName",
|
|
275
|
+
hs_linkedin_url: "linkedin",
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
178
282
|
export function loadEnrichConfig(path) {
|
|
179
283
|
let raw;
|
|
180
284
|
try {
|
|
@@ -323,6 +427,10 @@ function normalizeKeyValue(key, value) {
|
|
|
323
427
|
.replace(/^www\./, "")
|
|
324
428
|
.replace(/\/.*$/, "");
|
|
325
429
|
}
|
|
430
|
+
if (key === "linkedin") {
|
|
431
|
+
// Match crmContactKeys' `li:` normalization (lowercased, trailing slash stripped).
|
|
432
|
+
return text.trim().replace(/\/+$/, "");
|
|
433
|
+
}
|
|
326
434
|
return text.replace(/\s+/g, " ");
|
|
327
435
|
}
|
|
328
436
|
function crmKeyValue(objectType, record, key) {
|
|
@@ -335,6 +443,8 @@ function crmKeyValue(objectType, record, key) {
|
|
|
335
443
|
}
|
|
336
444
|
if (key === "email")
|
|
337
445
|
return normalizeKeyValue("email", record.email);
|
|
446
|
+
if (key === "linkedin")
|
|
447
|
+
return normalizeKeyValue("linkedin", record.linkedin);
|
|
338
448
|
if (key === "name") {
|
|
339
449
|
return normalizeKeyValue("name", `${record.firstName ?? ""} ${record.lastName ?? ""}`.trim());
|
|
340
450
|
}
|
|
@@ -429,7 +539,12 @@ export function buildEnrichPlan(options) {
|
|
|
429
539
|
for (const record of records) {
|
|
430
540
|
const match = config.match[record.objectType];
|
|
431
541
|
const fields = (config.fields[record.objectType] ?? []).filter((field) => field.from[source] !== undefined);
|
|
432
|
-
|
|
542
|
+
// Run the matcher whenever the object type has match keys; field mappings
|
|
543
|
+
// only gate whether we EMIT field ops. With no fields (a match-only config,
|
|
544
|
+
// e.g. the built-in Clay preset) this still reports the matched / ambiguous
|
|
545
|
+
// / unmatched routing verdict and records collisions — the Mode-A hygiene
|
|
546
|
+
// gate — it just proposes zero field writes.
|
|
547
|
+
if (!match) {
|
|
433
548
|
counts.unmatched += 1;
|
|
434
549
|
unmatchedSourceIds.push(record.id);
|
|
435
550
|
continue;
|
|
@@ -583,6 +698,201 @@ export function buildEnrichPlan(options) {
|
|
|
583
698
|
};
|
|
584
699
|
return { plan, counts, stamps, ambiguities, unmatchedSourceIds };
|
|
585
700
|
}
|
|
701
|
+
/** First non-empty value among several candidate paths in a source payload. */
|
|
702
|
+
function firstSourceValue(payload, paths) {
|
|
703
|
+
for (const path of paths) {
|
|
704
|
+
const value = sourceValueAt(payload, path);
|
|
705
|
+
if (!isEmptyValue(value))
|
|
706
|
+
return String(value).trim();
|
|
707
|
+
}
|
|
708
|
+
return undefined;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Lift a prospect/source payload into the routing attributes a territory rule
|
|
712
|
+
* reads. Tolerant of provider spellings (explorium vs pipe0); absent attributes
|
|
713
|
+
* stay undefined, so a rule that routes on them simply won't match (falling to
|
|
714
|
+
* the policy fallback) instead of mis-routing.
|
|
715
|
+
*/
|
|
716
|
+
export function acquireAssignmentContext(payload, companyName, accountOwnerByName) {
|
|
717
|
+
const ctx = {};
|
|
718
|
+
const title = firstSourceValue(payload, ["jobTitle", "job_title", "title"]);
|
|
719
|
+
if (title)
|
|
720
|
+
ctx.title = title.toLowerCase();
|
|
721
|
+
const geo = firstSourceValue(payload, ["geo", "country", "company_country_code", "companyCountry"]);
|
|
722
|
+
if (geo)
|
|
723
|
+
ctx.geo = geo.toLowerCase();
|
|
724
|
+
const industry = firstSourceValue(payload, ["industry", "company_industry", "companyIndustry"]);
|
|
725
|
+
if (industry)
|
|
726
|
+
ctx.industry = industry.toLowerCase();
|
|
727
|
+
const employeeBand = firstSourceValue(payload, ["employeeBand", "company_size", "companySize"]);
|
|
728
|
+
if (employeeBand)
|
|
729
|
+
ctx.employeeBand = employeeBand;
|
|
730
|
+
const department = firstSourceValue(payload, ["department", "job_department", "jobDepartment"]);
|
|
731
|
+
if (department)
|
|
732
|
+
ctx.department = department.toLowerCase();
|
|
733
|
+
if (companyName) {
|
|
734
|
+
const owner = accountOwnerByName.get(companyName.trim().toLowerCase());
|
|
735
|
+
if (owner)
|
|
736
|
+
ctx.accountOwnerId = owner;
|
|
737
|
+
}
|
|
738
|
+
return ctx;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Match each sourced record against the snapshot and route it: matched =
|
|
742
|
+
* already in the CRM (skip), ambiguous = a possible duplicate exists (skip —
|
|
743
|
+
* resolve-first never creates over ambiguity), unmatched = a net-new lead.
|
|
744
|
+
* Unmatched rows with a dedupe key and at least one mapped property become
|
|
745
|
+
* `create_record` operations, capped at `maxRecords` (the meter's headroom).
|
|
746
|
+
* Always a dry-run plan; nothing is written until apply.
|
|
747
|
+
*/
|
|
748
|
+
export function buildAcquirePlan(options) {
|
|
749
|
+
const { config, source, snapshot, records, runLabel } = options;
|
|
750
|
+
const nowIso = (options.now ?? (() => new Date()))().toISOString();
|
|
751
|
+
const sourceConfig = config.sources[source];
|
|
752
|
+
if (!sourceConfig)
|
|
753
|
+
throw new Error(`enrich: source "${source}" is not declared in the config`);
|
|
754
|
+
const acquire = config.acquire;
|
|
755
|
+
if (!acquire)
|
|
756
|
+
throw new Error('enrich acquire: config has no "acquire" section');
|
|
757
|
+
const cap = options.maxRecords ?? null;
|
|
758
|
+
const costPerRecord = acquire.costPerRecord?.[source] ?? 0;
|
|
759
|
+
const operations = [];
|
|
760
|
+
const evidence = [];
|
|
761
|
+
const counts = {
|
|
762
|
+
fetched: records.length,
|
|
763
|
+
matched: 0,
|
|
764
|
+
ambiguous: 0,
|
|
765
|
+
unmatched: 0,
|
|
766
|
+
created: 0,
|
|
767
|
+
withheldByMeter: 0,
|
|
768
|
+
assigned: 0,
|
|
769
|
+
unassigned: 0,
|
|
770
|
+
};
|
|
771
|
+
let estCostUsd = 0;
|
|
772
|
+
// Assignment context (built once): the set of owner ids the CRM will accept
|
|
773
|
+
// (active users), and a company-name → account-owner lookup for the
|
|
774
|
+
// "account-owner" inherit strategy. Empty when no assign policy is set.
|
|
775
|
+
const assignPolicy = acquire.assign;
|
|
776
|
+
const knownOwnerIds = new Set();
|
|
777
|
+
const accountOwnerByName = new Map();
|
|
778
|
+
if (assignPolicy) {
|
|
779
|
+
for (const user of snapshot.users ?? []) {
|
|
780
|
+
if (user.active === false)
|
|
781
|
+
continue;
|
|
782
|
+
if (user.crmId)
|
|
783
|
+
knownOwnerIds.add(user.crmId);
|
|
784
|
+
if (user.id)
|
|
785
|
+
knownOwnerIds.add(user.id);
|
|
786
|
+
}
|
|
787
|
+
for (const account of snapshot.accounts ?? []) {
|
|
788
|
+
if (account.name && account.ownerId) {
|
|
789
|
+
accountOwnerByName.set(account.name.trim().toLowerCase(), account.ownerId);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
for (const record of records) {
|
|
794
|
+
const createMap = acquire.create[record.objectType];
|
|
795
|
+
const match = config.match[record.objectType];
|
|
796
|
+
// No create mapping or no match config for this type — can neither create
|
|
797
|
+
// nor dedupe it safely; count as unmatched and skip.
|
|
798
|
+
if (!createMap || !match) {
|
|
799
|
+
counts.unmatched += 1;
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
const outcome = matchSourceRecord(snapshot, record.objectType, match.keys, record.keys);
|
|
803
|
+
if (outcome.status === "matched") {
|
|
804
|
+
counts.matched += 1;
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
if (outcome.status === "ambiguous") {
|
|
808
|
+
// Resolve-first: a possible duplicate exists — never create over it.
|
|
809
|
+
counts.ambiguous += 1;
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
counts.unmatched += 1;
|
|
813
|
+
const matchValue = String(record.keys[createMap.matchKey] ?? "").trim();
|
|
814
|
+
if (!matchValue)
|
|
815
|
+
continue; // no dedupe key → refuse to create (can't resolve-first)
|
|
816
|
+
if (cap !== null && counts.created >= cap) {
|
|
817
|
+
counts.withheldByMeter += 1;
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
const properties = {};
|
|
821
|
+
for (const [crmProp, path] of Object.entries(createMap.properties)) {
|
|
822
|
+
const value = sourceValueAt(record.payload, path);
|
|
823
|
+
if (!isEmptyValue(value))
|
|
824
|
+
properties[crmProp] = String(value);
|
|
825
|
+
}
|
|
826
|
+
if (Object.keys(properties).length === 0)
|
|
827
|
+
continue; // nothing to write
|
|
828
|
+
let associateCompanyName;
|
|
829
|
+
if (createMap.associateCompanyFrom) {
|
|
830
|
+
const companyValue = sourceValueAt(record.payload, createMap.associateCompanyFrom);
|
|
831
|
+
if (!isEmptyValue(companyValue))
|
|
832
|
+
associateCompanyName = String(companyValue);
|
|
833
|
+
}
|
|
834
|
+
const recordEvidence = evidenceFor(source, sourceConfig.kind, sourceConfig.format, record, undefined, nowIso);
|
|
835
|
+
evidence.push(recordEvidence);
|
|
836
|
+
// Resolve ownership BEFORE the meter-charged create lands, so the lead is
|
|
837
|
+
// never born ownerless. `counts.created` is the within-run index, giving
|
|
838
|
+
// round-robin deterministic, even distribution with no persisted cursor.
|
|
839
|
+
let ownerId;
|
|
840
|
+
let assignedBy;
|
|
841
|
+
if (assignPolicy) {
|
|
842
|
+
const ctx = acquireAssignmentContext(record.payload, associateCompanyName, accountOwnerByName);
|
|
843
|
+
const resolved = resolveAssignment(assignPolicy, ctx, counts.created, knownOwnerIds);
|
|
844
|
+
if (resolved.ownerId) {
|
|
845
|
+
ownerId = resolved.ownerId;
|
|
846
|
+
assignedBy = resolved.rule;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
const payload = {
|
|
850
|
+
properties,
|
|
851
|
+
matchKey: createMap.matchKey,
|
|
852
|
+
matchValue,
|
|
853
|
+
source,
|
|
854
|
+
estCostUsd: costPerRecord,
|
|
855
|
+
associateCompanyName,
|
|
856
|
+
...(ownerId ? { ownerId, assignedBy } : {}),
|
|
857
|
+
};
|
|
858
|
+
operations.push({
|
|
859
|
+
id: `op_acq_${fnv1a(`${source}:${record.objectType}:${matchValue}`)}`,
|
|
860
|
+
objectType: canonicalObjectType(record.objectType),
|
|
861
|
+
objectId: `create:${matchValue}`,
|
|
862
|
+
operation: "create_record",
|
|
863
|
+
beforeValue: null,
|
|
864
|
+
afterValue: payload,
|
|
865
|
+
reason: `${source} sourced net-new ${record.objectType} "${describeSourceRecord(record)}" ` +
|
|
866
|
+
`(${createMap.matchKey}=${matchValue}); no CRM match — create as a lead.`,
|
|
867
|
+
sourceRuleOrPolicy: `acquire:${source}`,
|
|
868
|
+
riskLevel: "medium",
|
|
869
|
+
approvalRequired: true,
|
|
870
|
+
rollback: "Archive the created record (it was net-new).",
|
|
871
|
+
evidenceIds: [recordEvidence.id],
|
|
872
|
+
});
|
|
873
|
+
if (ownerId)
|
|
874
|
+
counts.assigned += 1;
|
|
875
|
+
else
|
|
876
|
+
counts.unassigned += 1;
|
|
877
|
+
counts.created += 1;
|
|
878
|
+
estCostUsd += costPerRecord;
|
|
879
|
+
}
|
|
880
|
+
const plan = {
|
|
881
|
+
id: `patch_plan_acq_${fnv1a(`${source}:${runLabel}:${nowIso}`)}`,
|
|
882
|
+
title: `Acquire leads — ${source}`,
|
|
883
|
+
createdAt: nowIso,
|
|
884
|
+
status: operations.length > 0 ? "needs_approval" : "draft",
|
|
885
|
+
dryRun: true,
|
|
886
|
+
summary: `${counts.created} net-new lead(s) proposed from ${source} (${counts.fetched} sourced: ` +
|
|
887
|
+
`${counts.matched} already in CRM, ${counts.ambiguous} ambiguous skipped, ${counts.unmatched} unmatched` +
|
|
888
|
+
`${counts.withheldByMeter > 0 ? `, ${counts.withheldByMeter} withheld by meter` : ""}). ` +
|
|
889
|
+
`Est. cost $${estCostUsd.toFixed(2)}.`,
|
|
890
|
+
findings: [],
|
|
891
|
+
evidence,
|
|
892
|
+
operations,
|
|
893
|
+
};
|
|
894
|
+
return { plan, counts, estCostUsd };
|
|
895
|
+
}
|
|
586
896
|
// ---------------------------------------------------------------------------
|
|
587
897
|
// Staleness: compute the refresh work set from run-store stamps.
|
|
588
898
|
/** Latest stamp per (objectType, objectId, field) across a source's runs. */
|
package/dist/format.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import type { PatchPlan, PatchPlanRun } from "./types.ts";
|
|
2
|
-
export declare function patchPlanToMarkdown(plan: PatchPlan
|
|
2
|
+
export declare function patchPlanToMarkdown(plan: PatchPlan, opts?: {
|
|
3
|
+
summary?: boolean;
|
|
4
|
+
}): string;
|
|
3
5
|
export declare function formatPatchPlanRun(run: PatchPlanRun): string;
|
package/dist/format.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
// `summary: true` renders only the header + the Findings-by-Rule table + an
|
|
2
|
+
// operation count, for read verbs like `audit` where the full per-operation
|
|
3
|
+
// dump (1,400+ lines on a real portal) buries the signal. Defaults to the full
|
|
4
|
+
// view so write-preview callers (bulk-update, fix, dedupe, plans show, MCP) —
|
|
5
|
+
// where you approve specific operations — keep every operation's detail.
|
|
6
|
+
export function patchPlanToMarkdown(plan, opts = {}) {
|
|
2
7
|
const lines = [
|
|
3
8
|
`# ${plan.title}`,
|
|
4
9
|
"",
|
|
@@ -20,6 +25,13 @@ export function patchPlanToMarkdown(plan) {
|
|
|
20
25
|
});
|
|
21
26
|
lines.push("");
|
|
22
27
|
}
|
|
28
|
+
if (opts.summary) {
|
|
29
|
+
const ops = plan.operations.length;
|
|
30
|
+
lines.push(ops === 0
|
|
31
|
+
? "No patch operations proposed."
|
|
32
|
+
: `${ops} dry-run patch operation${ops === 1 ? "" : "s"} proposed — nothing written.`, "", "Full plan detail: re-run with `--full`.", "Client-ready report: `fullstackgtm report` (add `--format html` for a printable file).");
|
|
33
|
+
return `${lines.join("\n")}\n`;
|
|
34
|
+
}
|
|
23
35
|
lines.push("## Findings", "");
|
|
24
36
|
const findings = plan.pipelineFindings ?? [];
|
|
25
37
|
if (findings.length > 0) {
|
|
@@ -58,7 +70,7 @@ export function patchPlanToMarkdown(plan) {
|
|
|
58
70
|
lines.push(`- **${operation.operation}** ${operation.objectType}/${operation.objectId}`, ` - ID: ${operation.id}`, ` - Field/action: ${operation.field ?? operation.operation}`, ` - Findings: ${formatRefs(operation.findingIds)}`, ` - Evidence refs: ${formatRefs(operation.evidenceIds)}`, ` - Before: ${formatValue(operation.beforeValue)}`, ` - After: ${formatValue(operation.afterValue)}`, ` - Reason: ${operation.reason}`, ` - Source rule/policy: ${operation.sourceRuleOrPolicy ?? "unspecified"}`, ` - Risk: ${operation.riskLevel}`, ` - Approval required: ${operation.approvalRequired ? "yes" : "no"}`, ` - Rollback: ${operation.rollback ?? "restore prior value if apply is rejected or verification fails"}`, ` - Verification: ${formatVerification(operation.verification)}`);
|
|
59
71
|
}
|
|
60
72
|
}
|
|
61
|
-
lines.push("", ">
|
|
73
|
+
lines.push("", "> Dry-run plan — read-only. No provider write happens until you approve specific operations and run `apply`; placeholder values are refused without an explicit override. These safety invariants are not beta.");
|
|
62
74
|
return `${lines.join("\n")}\n`;
|
|
63
75
|
}
|
|
64
76
|
export function formatPatchPlanRun(run) {
|
package/dist/health.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { AuditFindingSeverity, CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
|
|
2
|
+
export type HealthEntry = {
|
|
3
|
+
/** ISO timestamp of the audit that produced this entry. */
|
|
4
|
+
at: string;
|
|
5
|
+
/** The saved plan this entry summarizes (correlates to plans/ and snapshots/). */
|
|
6
|
+
planId: string;
|
|
7
|
+
/** Deterministic 0–100 hygiene score (higher is healthier). */
|
|
8
|
+
score: number;
|
|
9
|
+
/** Total finding count across all rules. */
|
|
10
|
+
findings: number;
|
|
11
|
+
/** Severity-weighted finding total (drives the score). */
|
|
12
|
+
weightedFindings: number;
|
|
13
|
+
/** Record counts at audit time — the denominator that normalizes the score. */
|
|
14
|
+
records: {
|
|
15
|
+
accounts: number;
|
|
16
|
+
contacts: number;
|
|
17
|
+
deals: number;
|
|
18
|
+
total: number;
|
|
19
|
+
};
|
|
20
|
+
/** Finding count per rule id. */
|
|
21
|
+
byRule: Record<string, number>;
|
|
22
|
+
/** Finding count per severity. */
|
|
23
|
+
severityCounts: Record<AuditFindingSeverity, number>;
|
|
24
|
+
};
|
|
25
|
+
export type HealthRuleDelta = {
|
|
26
|
+
ruleId: string;
|
|
27
|
+
current: number;
|
|
28
|
+
previous: number;
|
|
29
|
+
/** current − previous: positive = more findings (worse), negative = fewer (better). */
|
|
30
|
+
delta: number;
|
|
31
|
+
};
|
|
32
|
+
export type HealthRollup = {
|
|
33
|
+
profile: string;
|
|
34
|
+
auditCount: number;
|
|
35
|
+
first: string;
|
|
36
|
+
latest: string;
|
|
37
|
+
current: HealthEntry;
|
|
38
|
+
previous: HealthEntry | null;
|
|
39
|
+
/** current.score − previous.score: positive = improving. null on the first audit. */
|
|
40
|
+
scoreDelta: number | null;
|
|
41
|
+
ruleDeltas: HealthRuleDelta[];
|
|
42
|
+
history: Array<{
|
|
43
|
+
at: string;
|
|
44
|
+
score: number;
|
|
45
|
+
findings: number;
|
|
46
|
+
}>;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Deterministically score a saved audit. score = 100 / (1 + weighted findings
|
|
50
|
+
* per record): 0 findings → 100; one weighted finding per record → 50; the
|
|
51
|
+
* curve is bounded (0, 100], monotonic, and needs no clamping or magic
|
|
52
|
+
* constants. A messy 50-record CRM scores worse than the same finding count
|
|
53
|
+
* across 5,000 records, which is the point.
|
|
54
|
+
*/
|
|
55
|
+
export declare function computeHealth(plan: PatchPlan, snapshot: CanonicalGtmSnapshot, at: string): HealthEntry;
|
|
56
|
+
/** Roll a timeline up into the current state, the change since last time, and per-rule deltas. */
|
|
57
|
+
export declare function summarizeHealth(entries: HealthEntry[], profile: string): HealthRollup | null;
|
|
58
|
+
/** Human-readable rollup: headline score + trend, then per-rule change since last audit. */
|
|
59
|
+
export declare function healthToMarkdown(rollup: HealthRollup): string;
|
|
60
|
+
/** `$FSGTM_HOME[/profiles/<name>]/health.jsonl` — append-only, one entry per audit-save. */
|
|
61
|
+
export declare function healthFilePath(): string;
|
|
62
|
+
/** `$FSGTM_HOME[/profiles/<name>]/snapshots/` — canonical snapshots correlated by plan id. */
|
|
63
|
+
export declare function snapshotsDir(): string;
|
|
64
|
+
/** Append a health entry to the active profile's timeline (0600, owner-only). */
|
|
65
|
+
export declare function appendHealthEntry(entry: HealthEntry): void;
|
|
66
|
+
/** Read the active profile's health timeline; tolerant of partial/corrupt lines. */
|
|
67
|
+
export declare function readHealthTimeline(): HealthEntry[];
|
|
68
|
+
/** Persist the snapshot behind a saved audit so the timeline can diff/attribute later. */
|
|
69
|
+
export declare function saveWorkspaceSnapshot(planId: string, snapshot: CanonicalGtmSnapshot): string;
|
|
70
|
+
/** The active profile name, for rollup labeling. */
|
|
71
|
+
export declare function activeWorkspaceProfile(): string;
|
package/dist/health.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { activeProfile, credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.js";
|
|
4
|
+
/**
|
|
5
|
+
* The engagement workspace: a per-client health timeline that accrues from the
|
|
6
|
+
* verb people already run (`audit --save`). State lives in the profile dir
|
|
7
|
+
* (`$FSGTM_HOME[/profiles/<name>]`) alongside credentials and plans, so a
|
|
8
|
+
* consultant working `--profile <client>` builds a continuous record of one
|
|
9
|
+
* org's CRM health over time — not just episodic one-off audits.
|
|
10
|
+
*
|
|
11
|
+
* The score is DETERMINISTIC (no LLM): same plan + same snapshot → same score,
|
|
12
|
+
* so it is stable in CI and comparable run-over-run.
|
|
13
|
+
*/
|
|
14
|
+
// Severity weights: a critical finding costs ~3× a warning, ~10× an info.
|
|
15
|
+
const SEVERITY_WEIGHT = { info: 1, warning: 3, critical: 10 };
|
|
16
|
+
/**
|
|
17
|
+
* Deterministically score a saved audit. score = 100 / (1 + weighted findings
|
|
18
|
+
* per record): 0 findings → 100; one weighted finding per record → 50; the
|
|
19
|
+
* curve is bounded (0, 100], monotonic, and needs no clamping or magic
|
|
20
|
+
* constants. A messy 50-record CRM scores worse than the same finding count
|
|
21
|
+
* across 5,000 records, which is the point.
|
|
22
|
+
*/
|
|
23
|
+
export function computeHealth(plan, snapshot, at) {
|
|
24
|
+
const byRule = {};
|
|
25
|
+
const severityCounts = { info: 0, warning: 0, critical: 0 };
|
|
26
|
+
let weightedFindings = 0;
|
|
27
|
+
for (const finding of plan.findings) {
|
|
28
|
+
byRule[finding.ruleId] = (byRule[finding.ruleId] ?? 0) + 1;
|
|
29
|
+
severityCounts[finding.severity] += 1;
|
|
30
|
+
weightedFindings += SEVERITY_WEIGHT[finding.severity];
|
|
31
|
+
}
|
|
32
|
+
const records = {
|
|
33
|
+
accounts: snapshot.accounts.length,
|
|
34
|
+
contacts: snapshot.contacts.length,
|
|
35
|
+
deals: snapshot.deals.length,
|
|
36
|
+
total: snapshot.accounts.length + snapshot.contacts.length + snapshot.deals.length,
|
|
37
|
+
};
|
|
38
|
+
const penalty = weightedFindings / Math.max(records.total, 1);
|
|
39
|
+
const score = Math.round(100 / (1 + penalty));
|
|
40
|
+
return {
|
|
41
|
+
at,
|
|
42
|
+
planId: plan.id,
|
|
43
|
+
score,
|
|
44
|
+
findings: plan.findings.length,
|
|
45
|
+
weightedFindings,
|
|
46
|
+
records,
|
|
47
|
+
byRule,
|
|
48
|
+
severityCounts,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Roll a timeline up into the current state, the change since last time, and per-rule deltas. */
|
|
52
|
+
export function summarizeHealth(entries, profile) {
|
|
53
|
+
if (entries.length === 0)
|
|
54
|
+
return null;
|
|
55
|
+
// Oldest → newest, so `current` is the last entry.
|
|
56
|
+
const sorted = [...entries].sort((a, b) => a.at.localeCompare(b.at));
|
|
57
|
+
const current = sorted[sorted.length - 1];
|
|
58
|
+
const previous = sorted.length > 1 ? sorted[sorted.length - 2] : null;
|
|
59
|
+
const ruleIds = new Set([
|
|
60
|
+
...Object.keys(current.byRule),
|
|
61
|
+
...(previous ? Object.keys(previous.byRule) : []),
|
|
62
|
+
]);
|
|
63
|
+
const ruleDeltas = [...ruleIds]
|
|
64
|
+
.map((ruleId) => {
|
|
65
|
+
const cur = current.byRule[ruleId] ?? 0;
|
|
66
|
+
const prev = previous?.byRule[ruleId] ?? 0;
|
|
67
|
+
return { ruleId, current: cur, previous: prev, delta: cur - prev };
|
|
68
|
+
})
|
|
69
|
+
.sort((a, b) => b.current - a.current || a.ruleId.localeCompare(b.ruleId));
|
|
70
|
+
return {
|
|
71
|
+
profile,
|
|
72
|
+
auditCount: sorted.length,
|
|
73
|
+
first: sorted[0].at,
|
|
74
|
+
latest: current.at,
|
|
75
|
+
current,
|
|
76
|
+
previous,
|
|
77
|
+
scoreDelta: previous ? current.score - previous.score : null,
|
|
78
|
+
ruleDeltas,
|
|
79
|
+
history: sorted.map((entry) => ({ at: entry.at, score: entry.score, findings: entry.findings })),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// Sign-based and uniform: ▲ = the number went up, ▼ = down. The reader supplies
|
|
83
|
+
// the meaning (score up = good; findings up = bad) — mixing arrow direction with
|
|
84
|
+
// good/bad reads as contradictory ("▲ -3").
|
|
85
|
+
function arrow(delta) {
|
|
86
|
+
if (delta === 0)
|
|
87
|
+
return "·";
|
|
88
|
+
return delta > 0 ? "▲" : "▼";
|
|
89
|
+
}
|
|
90
|
+
/** Human-readable rollup: headline score + trend, then per-rule change since last audit. */
|
|
91
|
+
export function healthToMarkdown(rollup) {
|
|
92
|
+
const { current, scoreDelta } = rollup;
|
|
93
|
+
const lines = [`# GTM health — profile \`${rollup.profile}\``, ""];
|
|
94
|
+
const deltaText = scoreDelta === null
|
|
95
|
+
? "(first audit — no prior reading)"
|
|
96
|
+
: `${arrow(scoreDelta)} ${scoreDelta >= 0 ? "+" : ""}${scoreDelta} since the previous audit`;
|
|
97
|
+
lines.push(`Score: **${current.score}/100** ${deltaText}`, `Audits: ${rollup.auditCount} over ${shortDate(rollup.first)} → ${shortDate(rollup.latest)}`, `Findings: ${current.findings} (${current.severityCounts.critical} critical, ${current.severityCounts.warning} warning, ${current.severityCounts.info} info)`, `Records: ${current.records.accounts} accounts, ${current.records.contacts} contacts, ${current.records.deals} deals`, "");
|
|
98
|
+
if (rollup.history.length > 1) {
|
|
99
|
+
lines.push("## Trend", "");
|
|
100
|
+
for (const point of rollup.history) {
|
|
101
|
+
const marker = point.at === rollup.latest ? " ← latest" : "";
|
|
102
|
+
lines.push(`- ${shortDate(point.at)} score ${point.score} (${point.findings} findings)${marker}`);
|
|
103
|
+
}
|
|
104
|
+
lines.push("");
|
|
105
|
+
}
|
|
106
|
+
if (rollup.ruleDeltas.length > 0) {
|
|
107
|
+
lines.push("## By rule (Δ since previous audit)", "", "| Rule | Findings | Δ |", "| --- | --- | --- |");
|
|
108
|
+
for (const rule of rollup.ruleDeltas) {
|
|
109
|
+
const sign = rule.delta > 0 ? `+${rule.delta}` : `${rule.delta}`;
|
|
110
|
+
const cell = rule.delta === 0 ? "·" : `${arrow(rule.delta)} ${sign}`;
|
|
111
|
+
lines.push(`| ${rule.ruleId} | ${rule.current} | ${cell} |`);
|
|
112
|
+
}
|
|
113
|
+
lines.push("");
|
|
114
|
+
}
|
|
115
|
+
lines.push("> Score is deterministic: 100 / (1 + severity-weighted findings per record). Lower findings or more clean records ⇒ higher score.");
|
|
116
|
+
return `${lines.join("\n")}\n`;
|
|
117
|
+
}
|
|
118
|
+
function shortDate(iso) {
|
|
119
|
+
return iso.slice(0, 10);
|
|
120
|
+
}
|
|
121
|
+
// ── Profile-scoped storage ──────────────────────────────────────────────────
|
|
122
|
+
/** `$FSGTM_HOME[/profiles/<name>]/health.jsonl` — append-only, one entry per audit-save. */
|
|
123
|
+
export function healthFilePath() {
|
|
124
|
+
return join(credentialsDir(), "health.jsonl");
|
|
125
|
+
}
|
|
126
|
+
/** `$FSGTM_HOME[/profiles/<name>]/snapshots/` — canonical snapshots correlated by plan id. */
|
|
127
|
+
export function snapshotsDir() {
|
|
128
|
+
return join(credentialsDir(), "snapshots");
|
|
129
|
+
}
|
|
130
|
+
/** Append a health entry to the active profile's timeline (0600, owner-only). */
|
|
131
|
+
export function appendHealthEntry(entry) {
|
|
132
|
+
ensureSecureHomeDir();
|
|
133
|
+
appendFileSync(healthFilePath(), `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
134
|
+
}
|
|
135
|
+
/** Read the active profile's health timeline; tolerant of partial/corrupt lines. */
|
|
136
|
+
export function readHealthTimeline() {
|
|
137
|
+
let raw;
|
|
138
|
+
try {
|
|
139
|
+
raw = readFileSync(healthFilePath(), "utf8");
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
const entries = [];
|
|
145
|
+
for (const line of raw.split("\n")) {
|
|
146
|
+
const trimmed = line.trim();
|
|
147
|
+
if (!trimmed)
|
|
148
|
+
continue;
|
|
149
|
+
try {
|
|
150
|
+
entries.push(JSON.parse(trimmed));
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Skip a torn line rather than failing the whole rollup.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return entries;
|
|
157
|
+
}
|
|
158
|
+
/** Persist the snapshot behind a saved audit so the timeline can diff/attribute later. */
|
|
159
|
+
export function saveWorkspaceSnapshot(planId, snapshot) {
|
|
160
|
+
if (!/^[\w.-]+$/.test(planId))
|
|
161
|
+
throw new Error(`Invalid plan id: ${planId}`);
|
|
162
|
+
ensureSecureHomeDir();
|
|
163
|
+
const dir = snapshotsDir();
|
|
164
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
165
|
+
const path = join(dir, `${planId}.json`);
|
|
166
|
+
writeSecureFile(path, `${JSON.stringify(snapshot)}\n`);
|
|
167
|
+
return path;
|
|
168
|
+
}
|
|
169
|
+
/** The active profile name, for rollup labeling. */
|
|
170
|
+
export function activeWorkspaceProfile() {
|
|
171
|
+
return activeProfile();
|
|
172
|
+
}
|