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.
Files changed (59) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/INSTALL_FOR_AGENTS.md +8 -0
  3. package/README.md +39 -0
  4. package/dist/acquireLinkedIn.d.ts +41 -0
  5. package/dist/acquireLinkedIn.js +57 -0
  6. package/dist/acquireMeter.d.ts +67 -0
  7. package/dist/acquireMeter.js +145 -0
  8. package/dist/acquireSeen.d.ts +5 -0
  9. package/dist/acquireSeen.js +54 -0
  10. package/dist/assign.d.ts +83 -0
  11. package/dist/assign.js +146 -0
  12. package/dist/bin.js +14 -2
  13. package/dist/cli.js +817 -26
  14. package/dist/connectors/hubspot.js +140 -0
  15. package/dist/connectors/linkedin.d.ts +78 -0
  16. package/dist/connectors/linkedin.js +199 -0
  17. package/dist/connectors/prospectSources.d.ts +91 -0
  18. package/dist/connectors/prospectSources.js +227 -0
  19. package/dist/enrich.d.ts +107 -0
  20. package/dist/enrich.js +315 -5
  21. package/dist/format.d.ts +3 -1
  22. package/dist/format.js +14 -2
  23. package/dist/health.d.ts +71 -0
  24. package/dist/health.js +172 -0
  25. package/dist/icp.d.ts +96 -0
  26. package/dist/icp.js +256 -0
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.js +2 -0
  29. package/dist/mappings.js +3 -0
  30. package/dist/reassign.d.ts +11 -2
  31. package/dist/reassign.js +13 -6
  32. package/dist/runReport.d.ts +9 -0
  33. package/dist/runReport.js +66 -0
  34. package/dist/types.d.ts +25 -1
  35. package/docs/api.md +53 -3
  36. package/docs/architecture.md +11 -1
  37. package/docs/dx-punch-list.md +87 -0
  38. package/docs/linkedin-connector-spec.md +87 -0
  39. package/llms.txt +38 -1
  40. package/package.json +1 -1
  41. package/skills/fullstackgtm/SKILL.md +5 -3
  42. package/src/acquireLinkedIn.ts +83 -0
  43. package/src/acquireMeter.ts +186 -0
  44. package/src/acquireSeen.ts +57 -0
  45. package/src/assign.ts +193 -0
  46. package/src/bin.ts +17 -4
  47. package/src/cli.ts +965 -25
  48. package/src/connectors/hubspot.ts +145 -0
  49. package/src/connectors/linkedin.ts +272 -0
  50. package/src/connectors/prospectSources.ts +324 -0
  51. package/src/enrich.ts +411 -5
  52. package/src/format.ts +20 -2
  53. package/src/health.ts +238 -0
  54. package/src/icp.ts +313 -0
  55. package/src/index.ts +18 -0
  56. package/src/mappings.ts +3 -0
  57. package/src/reassign.ts +24 -8
  58. package/src/runReport.ts +76 -0
  59. package/src/types.ts +32 -0
package/src/enrich.ts CHANGED
@@ -2,8 +2,12 @@ import { mkdirSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.ts";
4
4
  import { HUBSPOT_DEFAULT_FIELD_MAPPINGS } from "./mappings.ts";
5
+ import type { AcquireBudget } from "./acquireMeter.ts";
6
+ import { parseAssignmentPolicy, resolveAssignment } from "./assign.ts";
7
+ import type { AssignmentContext, AssignmentPolicy } from "./assign.ts";
5
8
  import type {
6
9
  CanonicalGtmSnapshot,
10
+ CreateRecordPayload,
7
11
  GtmEvidence,
8
12
  PatchOperation,
9
13
  PatchPlan,
@@ -76,6 +80,50 @@ export type EnrichConfig = {
76
80
  match: Partial<Record<EnrichObjectType, EnrichMatchConfig>>;
77
81
  fields: Partial<Record<EnrichObjectType, EnrichFieldConfig[]>>;
78
82
  policy: EnrichPolicyConfig;
83
+ /** Net-new lead creation (`enrich acquire`). Optional; absent = acquire off. */
84
+ acquire?: AcquireConfig;
85
+ };
86
+
87
+ /** Maps a sourced record to the CRM properties a net-new record is created with. */
88
+ export type AcquireCreateMap = {
89
+ /** CRM property name → source payload path (read via sourceValueAt). */
90
+ properties: Record<string, string>;
91
+ /** Source path whose value becomes the dedupe key (e.g. "email"). */
92
+ matchKey: string;
93
+ /** Optional source path to a company name; the connector resolves-or-creates it. */
94
+ associateCompanyFrom?: string;
95
+ };
96
+
97
+ /**
98
+ * Net-new discovery for an API acquire source. `provider` selects the adapter
99
+ * (explorium = prospect search; pipe0 = work-email waterfall). `filters` is the
100
+ * provider-native query; `resolveEmailsWith` chains a second provider to fill
101
+ * real emails (e.g. explorium discovers, pipe0 resolves the work email).
102
+ */
103
+ export type AcquireDiscoveryConfig = {
104
+ provider: "explorium" | "pipe0" | "linkedin" | "heyreach";
105
+ filters?: Record<string, unknown>;
106
+ size?: number;
107
+ resolveEmailsWith?: "pipe0";
108
+ /** LinkedIn sources only: the provider-native list id to read (HeyReach lead-list id). */
109
+ listId?: string;
110
+ };
111
+
112
+ export type AcquireConfig = {
113
+ /** Windowed budget enforced by the acquire meter; absent = unmetered. */
114
+ budget?: AcquireBudget;
115
+ /** Estimated provider cost (USD) per created record, by source id. */
116
+ costPerRecord?: Record<string, number>;
117
+ /** How to build a net-new record from a sourced row, per object type. */
118
+ create: Partial<Record<EnrichObjectType, AcquireCreateMap>>;
119
+ /** Net-new discovery params for API sources, by source id. */
120
+ discovery?: Record<string, AcquireDiscoveryConfig>;
121
+ /**
122
+ * Ownership rule stamped onto every created lead so it is never born
123
+ * ownerless. Absent = no auto-assignment (the CLI may still default to the
124
+ * portal's sole active owner). Shared with `reassign --assign-unowned`.
125
+ */
126
+ assign?: AssignmentPolicy;
79
127
  };
80
128
 
81
129
  export const ENRICH_CONFIG_FILE_NAME = "enrich.config.json";
@@ -87,11 +135,11 @@ const OBJECT_TYPES: EnrichObjectType[] = ["company", "contact"];
87
135
  /** Match keys the matcher knows how to read off canonical snapshot records. */
88
136
  const MATCH_KEYS: Record<EnrichObjectType, string[]> = {
89
137
  company: ["domain", "name"],
90
- contact: ["email", "name"],
138
+ contact: ["email", "name", "linkedin"],
91
139
  };
92
140
 
93
141
  /** API source ids the MVP can pull from. */
94
- export const SUPPORTED_API_SOURCES = ["apollo"];
142
+ export const SUPPORTED_API_SOURCES = ["apollo", "explorium", "pipe0"];
95
143
 
96
144
  /**
97
145
  * Canonical fields enrich may target, plus the HubSpot property spellings the
@@ -263,11 +311,119 @@ export function parseEnrichConfig(raw: string): EnrichConfig {
263
311
  }
264
312
  }
265
313
  }
266
- if (!anyField) fail('"fields" maps nothing add at least one field entry');
314
+ // `fields` drives append/refresh. An acquire-only config (net-new lead
315
+ // creation, no blank-filling) legitimately maps no fields — accept it when
316
+ // an acquire.create mapping is present.
317
+ const hasAcquireCreate = Boolean(
318
+ config.acquire?.create && Object.keys(config.acquire.create).length > 0,
319
+ );
320
+ if (!anyField && !hasAcquireCreate) {
321
+ fail('"fields" maps nothing — add at least one field entry (or an "acquire.create" mapping)');
322
+ }
323
+
324
+ // Normalize + validate the assignment policy (fails loud on shape errors so a
325
+ // misconfigured rule never silently mis-routes a created lead).
326
+ if (config.acquire?.assign !== undefined) {
327
+ try {
328
+ config.acquire.assign = parseAssignmentPolicy(config.acquire.assign);
329
+ } catch (error) {
330
+ fail(error instanceof Error ? error.message : String(error));
331
+ }
332
+ }
267
333
 
268
334
  return config as EnrichConfig;
269
335
  }
270
336
 
337
+ /**
338
+ * Built-in zero-config presets for well-known sources, so the common Mode-A
339
+ * loop — "feed a Clay export, get the dedup/routing verdict" — needs no
340
+ * hand-authored config. These are **match-only** (empty `fields`): the value is
341
+ * the matched / unmatched / ambiguous routing and collision detection, not field
342
+ * writes. Drop an `enrich.config.json` next to your run to map fields for actual
343
+ * field enrichment; an explicit config always wins over a preset. Constructed
344
+ * directly (not via `parseEnrichConfig`) so the match-only shape is allowed.
345
+ */
346
+ export const BUILTIN_ENRICH_PRESETS: Record<string, EnrichConfig> = {
347
+ clay: {
348
+ sources: { clay: { kind: "ingest", format: "csv" } },
349
+ policy: { overwrite: "never", defaultStaleDays: 90 },
350
+ match: {
351
+ company: { keys: ["domain"], onAmbiguous: "skip" },
352
+ contact: { keys: ["email"], onAmbiguous: "skip" },
353
+ },
354
+ fields: {},
355
+ },
356
+ };
357
+
358
+ export function builtinEnrichPreset(source: string | undefined): EnrichConfig | undefined {
359
+ return source ? BUILTIN_ENRICH_PRESETS[source] : undefined;
360
+ }
361
+
362
+ /**
363
+ * Zero-config `enrich acquire` preset so every client gets targeted, deduped,
364
+ * metered lead-fill out of the box — no hand-authored enrich.config.json. The
365
+ * ICP (icp.json) supplies the targeting; this supplies sensible plumbing. The
366
+ * create mapping writes the prospect's LinkedIn URL into hs_linkedin_url, so
367
+ * each created contact strengthens future dedup. An explicit config always wins.
368
+ */
369
+ export function builtinAcquirePreset(source: string | undefined): EnrichConfig | undefined {
370
+ const provider = source ?? "pipe0";
371
+ if (provider === "linkedin" || provider === "heyreach") {
372
+ // LinkedIn discovery reads our OWN HeyReach lead list, so it costs $0/record
373
+ // (no spend budget) and keys on the LinkedIn URL — leads are sparse on email,
374
+ // so email-keying would drop most of them. listId is supplied via --list.
375
+ return {
376
+ sources: { linkedin: { kind: "api" } },
377
+ match: { contact: { keys: ["linkedin", "email"], onAmbiguous: "skip" } },
378
+ fields: {},
379
+ policy: { overwrite: "never" },
380
+ acquire: {
381
+ budget: { records: { perDay: 50, perMonth: 500 } },
382
+ costPerRecord: { linkedin: 0 },
383
+ discovery: { linkedin: { provider: "linkedin", size: 25 } },
384
+ create: {
385
+ contact: {
386
+ matchKey: "linkedin",
387
+ properties: {
388
+ hs_linkedin_url: "linkedin",
389
+ firstname: "firstName",
390
+ lastname: "lastName",
391
+ jobtitle: "jobTitle",
392
+ company: "companyName",
393
+ email: "email",
394
+ },
395
+ },
396
+ },
397
+ },
398
+ };
399
+ }
400
+ if (provider !== "pipe0" && provider !== "explorium") return undefined;
401
+ return {
402
+ sources: { [provider]: { kind: "api" } },
403
+ match: { contact: { keys: ["email"], onAmbiguous: "skip" } },
404
+ fields: {},
405
+ policy: { overwrite: "never" },
406
+ acquire: {
407
+ budget: { records: { perDay: 50, perMonth: 500 }, spend: { perDay: 25, perMonth: 250 } },
408
+ costPerRecord: { pipe0: 0.1, explorium: 0.4 },
409
+ discovery: { [provider]: { provider, size: 25 } },
410
+ create: {
411
+ contact: {
412
+ matchKey: "email",
413
+ properties: {
414
+ email: "email",
415
+ firstname: "firstName",
416
+ lastname: "lastName",
417
+ jobtitle: "jobTitle",
418
+ company: "companyName",
419
+ hs_linkedin_url: "linkedin",
420
+ },
421
+ },
422
+ },
423
+ },
424
+ };
425
+ }
426
+
271
427
  export function loadEnrichConfig(path: string): EnrichConfig {
272
428
  let raw: string;
273
429
  try {
@@ -435,12 +591,16 @@ function normalizeKeyValue(key: string, value: unknown): string {
435
591
  .replace(/^www\./, "")
436
592
  .replace(/\/.*$/, "");
437
593
  }
594
+ if (key === "linkedin") {
595
+ // Match crmContactKeys' `li:` normalization (lowercased, trailing slash stripped).
596
+ return text.trim().replace(/\/+$/, "");
597
+ }
438
598
  return text.replace(/\s+/g, " ");
439
599
  }
440
600
 
441
601
  function crmKeyValue(
442
602
  objectType: EnrichObjectType,
443
- record: { name?: string; domain?: string; email?: string; firstName?: string; lastName?: string },
603
+ record: { name?: string; domain?: string; email?: string; firstName?: string; lastName?: string; linkedin?: string },
444
604
  key: string,
445
605
  ): string {
446
606
  if (objectType === "company") {
@@ -449,6 +609,7 @@ function crmKeyValue(
449
609
  return "";
450
610
  }
451
611
  if (key === "email") return normalizeKeyValue("email", record.email);
612
+ if (key === "linkedin") return normalizeKeyValue("linkedin", record.linkedin);
452
613
  if (key === "name") {
453
614
  return normalizeKeyValue("name", `${record.firstName ?? ""} ${record.lastName ?? ""}`.trim());
454
615
  }
@@ -634,7 +795,12 @@ export function buildEnrichPlan(options: BuildEnrichPlanOptions): EnrichPlanResu
634
795
  const fields = (config.fields[record.objectType] ?? []).filter(
635
796
  (field) => field.from[source] !== undefined,
636
797
  );
637
- if (!match || fields.length === 0) {
798
+ // Run the matcher whenever the object type has match keys; field mappings
799
+ // only gate whether we EMIT field ops. With no fields (a match-only config,
800
+ // e.g. the built-in Clay preset) this still reports the matched / ambiguous
801
+ // / unmatched routing verdict and records collisions — the Mode-A hygiene
802
+ // gate — it just proposes zero field writes.
803
+ if (!match) {
638
804
  counts.unmatched += 1;
639
805
  unmatchedSourceIds.push(record.id);
640
806
  continue;
@@ -798,6 +964,246 @@ export function buildEnrichPlan(options: BuildEnrichPlanOptions): EnrichPlanResu
798
964
  return { plan, counts, stamps, ambiguities, unmatchedSourceIds };
799
965
  }
800
966
 
967
+ // ---------------------------------------------------------------------------
968
+ // Acquire: turn sourced-but-unmatched prospects into net-new lead creates.
969
+
970
+ export type AcquireCounts = {
971
+ fetched: number;
972
+ matched: number;
973
+ ambiguous: number;
974
+ unmatched: number;
975
+ /** create_record ops emitted. */
976
+ created: number;
977
+ /** unmatched rows that would have been created but for the meter ceiling. */
978
+ withheldByMeter: number;
979
+ /** created ops that got an owner stamped (a subset of `created`). */
980
+ assigned: number;
981
+ /** created ops left ownerless (no policy, or policy could not place them). */
982
+ unassigned: number;
983
+ };
984
+
985
+ export type AcquirePlanResult = {
986
+ plan: PatchPlan;
987
+ counts: AcquireCounts;
988
+ estCostUsd: number;
989
+ };
990
+
991
+ export type BuildAcquirePlanOptions = {
992
+ config: EnrichConfig;
993
+ source: string;
994
+ snapshot: CanonicalGtmSnapshot;
995
+ records: EnrichSourceRecord[];
996
+ runLabel: string;
997
+ /** Meter ceiling: max create ops to emit. null/undefined = unlimited. */
998
+ maxRecords?: number | null;
999
+ now?: () => Date;
1000
+ };
1001
+
1002
+ /** First non-empty value among several candidate paths in a source payload. */
1003
+ function firstSourceValue(payload: Record<string, unknown>, paths: string[]): string | undefined {
1004
+ for (const path of paths) {
1005
+ const value = sourceValueAt(payload, path);
1006
+ if (!isEmptyValue(value)) return String(value).trim();
1007
+ }
1008
+ return undefined;
1009
+ }
1010
+
1011
+ /**
1012
+ * Lift a prospect/source payload into the routing attributes a territory rule
1013
+ * reads. Tolerant of provider spellings (explorium vs pipe0); absent attributes
1014
+ * stay undefined, so a rule that routes on them simply won't match (falling to
1015
+ * the policy fallback) instead of mis-routing.
1016
+ */
1017
+ export function acquireAssignmentContext(
1018
+ payload: Record<string, unknown>,
1019
+ companyName: string | undefined,
1020
+ accountOwnerByName: ReadonlyMap<string, string>,
1021
+ ): AssignmentContext {
1022
+ const ctx: AssignmentContext = {};
1023
+ const title = firstSourceValue(payload, ["jobTitle", "job_title", "title"]);
1024
+ if (title) ctx.title = title.toLowerCase();
1025
+ const geo = firstSourceValue(payload, ["geo", "country", "company_country_code", "companyCountry"]);
1026
+ if (geo) ctx.geo = geo.toLowerCase();
1027
+ const industry = firstSourceValue(payload, ["industry", "company_industry", "companyIndustry"]);
1028
+ if (industry) ctx.industry = industry.toLowerCase();
1029
+ const employeeBand = firstSourceValue(payload, ["employeeBand", "company_size", "companySize"]);
1030
+ if (employeeBand) ctx.employeeBand = employeeBand;
1031
+ const department = firstSourceValue(payload, ["department", "job_department", "jobDepartment"]);
1032
+ if (department) ctx.department = department.toLowerCase();
1033
+ if (companyName) {
1034
+ const owner = accountOwnerByName.get(companyName.trim().toLowerCase());
1035
+ if (owner) ctx.accountOwnerId = owner;
1036
+ }
1037
+ return ctx;
1038
+ }
1039
+
1040
+ /**
1041
+ * Match each sourced record against the snapshot and route it: matched =
1042
+ * already in the CRM (skip), ambiguous = a possible duplicate exists (skip —
1043
+ * resolve-first never creates over ambiguity), unmatched = a net-new lead.
1044
+ * Unmatched rows with a dedupe key and at least one mapped property become
1045
+ * `create_record` operations, capped at `maxRecords` (the meter's headroom).
1046
+ * Always a dry-run plan; nothing is written until apply.
1047
+ */
1048
+ export function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanResult {
1049
+ const { config, source, snapshot, records, runLabel } = options;
1050
+ const nowIso = (options.now ?? (() => new Date()))().toISOString();
1051
+ const sourceConfig = config.sources[source];
1052
+ if (!sourceConfig) throw new Error(`enrich: source "${source}" is not declared in the config`);
1053
+ const acquire = config.acquire;
1054
+ if (!acquire) throw new Error('enrich acquire: config has no "acquire" section');
1055
+
1056
+ const cap = options.maxRecords ?? null;
1057
+ const costPerRecord = acquire.costPerRecord?.[source] ?? 0;
1058
+
1059
+ const operations: PatchOperation[] = [];
1060
+ const evidence: GtmEvidence[] = [];
1061
+ const counts: AcquireCounts = {
1062
+ fetched: records.length,
1063
+ matched: 0,
1064
+ ambiguous: 0,
1065
+ unmatched: 0,
1066
+ created: 0,
1067
+ withheldByMeter: 0,
1068
+ assigned: 0,
1069
+ unassigned: 0,
1070
+ };
1071
+ let estCostUsd = 0;
1072
+
1073
+ // Assignment context (built once): the set of owner ids the CRM will accept
1074
+ // (active users), and a company-name → account-owner lookup for the
1075
+ // "account-owner" inherit strategy. Empty when no assign policy is set.
1076
+ const assignPolicy = acquire.assign;
1077
+ const knownOwnerIds = new Set<string>();
1078
+ const accountOwnerByName = new Map<string, string>();
1079
+ if (assignPolicy) {
1080
+ for (const user of snapshot.users ?? []) {
1081
+ if (user.active === false) continue;
1082
+ if (user.crmId) knownOwnerIds.add(user.crmId);
1083
+ if (user.id) knownOwnerIds.add(user.id);
1084
+ }
1085
+ for (const account of snapshot.accounts ?? []) {
1086
+ if (account.name && account.ownerId) {
1087
+ accountOwnerByName.set(account.name.trim().toLowerCase(), account.ownerId);
1088
+ }
1089
+ }
1090
+ }
1091
+
1092
+ for (const record of records) {
1093
+ const createMap = acquire.create[record.objectType];
1094
+ const match = config.match[record.objectType];
1095
+ // No create mapping or no match config for this type — can neither create
1096
+ // nor dedupe it safely; count as unmatched and skip.
1097
+ if (!createMap || !match) {
1098
+ counts.unmatched += 1;
1099
+ continue;
1100
+ }
1101
+ const outcome = matchSourceRecord(snapshot, record.objectType, match.keys, record.keys);
1102
+ if (outcome.status === "matched") {
1103
+ counts.matched += 1;
1104
+ continue;
1105
+ }
1106
+ if (outcome.status === "ambiguous") {
1107
+ // Resolve-first: a possible duplicate exists — never create over it.
1108
+ counts.ambiguous += 1;
1109
+ continue;
1110
+ }
1111
+
1112
+ counts.unmatched += 1;
1113
+ const matchValue = String(record.keys[createMap.matchKey] ?? "").trim();
1114
+ if (!matchValue) continue; // no dedupe key → refuse to create (can't resolve-first)
1115
+
1116
+ if (cap !== null && counts.created >= cap) {
1117
+ counts.withheldByMeter += 1;
1118
+ continue;
1119
+ }
1120
+
1121
+ const properties: Record<string, string> = {};
1122
+ for (const [crmProp, path] of Object.entries(createMap.properties)) {
1123
+ const value = sourceValueAt(record.payload, path);
1124
+ if (!isEmptyValue(value)) properties[crmProp] = String(value);
1125
+ }
1126
+ if (Object.keys(properties).length === 0) continue; // nothing to write
1127
+
1128
+ let associateCompanyName: string | undefined;
1129
+ if (createMap.associateCompanyFrom) {
1130
+ const companyValue = sourceValueAt(record.payload, createMap.associateCompanyFrom);
1131
+ if (!isEmptyValue(companyValue)) associateCompanyName = String(companyValue);
1132
+ }
1133
+
1134
+ const recordEvidence = evidenceFor(
1135
+ source,
1136
+ sourceConfig.kind,
1137
+ sourceConfig.format,
1138
+ record,
1139
+ undefined,
1140
+ nowIso,
1141
+ );
1142
+ evidence.push(recordEvidence);
1143
+
1144
+ // Resolve ownership BEFORE the meter-charged create lands, so the lead is
1145
+ // never born ownerless. `counts.created` is the within-run index, giving
1146
+ // round-robin deterministic, even distribution with no persisted cursor.
1147
+ let ownerId: string | undefined;
1148
+ let assignedBy: string | undefined;
1149
+ if (assignPolicy) {
1150
+ const ctx = acquireAssignmentContext(record.payload, associateCompanyName, accountOwnerByName);
1151
+ const resolved = resolveAssignment(assignPolicy, ctx, counts.created, knownOwnerIds);
1152
+ if (resolved.ownerId) {
1153
+ ownerId = resolved.ownerId;
1154
+ assignedBy = resolved.rule;
1155
+ }
1156
+ }
1157
+
1158
+ const payload: CreateRecordPayload = {
1159
+ properties,
1160
+ matchKey: createMap.matchKey,
1161
+ matchValue,
1162
+ source,
1163
+ estCostUsd: costPerRecord,
1164
+ associateCompanyName,
1165
+ ...(ownerId ? { ownerId, assignedBy } : {}),
1166
+ };
1167
+ operations.push({
1168
+ id: `op_acq_${fnv1a(`${source}:${record.objectType}:${matchValue}`)}`,
1169
+ objectType: canonicalObjectType(record.objectType),
1170
+ objectId: `create:${matchValue}`,
1171
+ operation: "create_record",
1172
+ beforeValue: null,
1173
+ afterValue: payload,
1174
+ reason:
1175
+ `${source} sourced net-new ${record.objectType} "${describeSourceRecord(record)}" ` +
1176
+ `(${createMap.matchKey}=${matchValue}); no CRM match — create as a lead.`,
1177
+ sourceRuleOrPolicy: `acquire:${source}`,
1178
+ riskLevel: "medium",
1179
+ approvalRequired: true,
1180
+ rollback: "Archive the created record (it was net-new).",
1181
+ evidenceIds: [recordEvidence.id],
1182
+ });
1183
+ if (ownerId) counts.assigned += 1;
1184
+ else counts.unassigned += 1;
1185
+ counts.created += 1;
1186
+ estCostUsd += costPerRecord;
1187
+ }
1188
+
1189
+ const plan: PatchPlan = {
1190
+ id: `patch_plan_acq_${fnv1a(`${source}:${runLabel}:${nowIso}`)}`,
1191
+ title: `Acquire leads — ${source}`,
1192
+ createdAt: nowIso,
1193
+ status: operations.length > 0 ? "needs_approval" : "draft",
1194
+ dryRun: true,
1195
+ summary:
1196
+ `${counts.created} net-new lead(s) proposed from ${source} (${counts.fetched} sourced: ` +
1197
+ `${counts.matched} already in CRM, ${counts.ambiguous} ambiguous skipped, ${counts.unmatched} unmatched` +
1198
+ `${counts.withheldByMeter > 0 ? `, ${counts.withheldByMeter} withheld by meter` : ""}). ` +
1199
+ `Est. cost $${estCostUsd.toFixed(2)}.`,
1200
+ findings: [],
1201
+ evidence,
1202
+ operations,
1203
+ };
1204
+ return { plan, counts, estCostUsd };
1205
+ }
1206
+
801
1207
  // ---------------------------------------------------------------------------
802
1208
  // Staleness: compute the refresh work set from run-store stamps.
803
1209
 
package/src/format.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import type { PatchPlan, PatchPlanRun } from "./types.ts";
2
2
 
3
- export function patchPlanToMarkdown(plan: PatchPlan) {
3
+ // `summary: true` renders only the header + the Findings-by-Rule table + an
4
+ // operation count, for read verbs like `audit` where the full per-operation
5
+ // dump (1,400+ lines on a real portal) buries the signal. Defaults to the full
6
+ // view so write-preview callers (bulk-update, fix, dedupe, plans show, MCP) —
7
+ // where you approve specific operations — keep every operation's detail.
8
+ export function patchPlanToMarkdown(plan: PatchPlan, opts: { summary?: boolean } = {}) {
4
9
  const lines = [
5
10
  `# ${plan.title}`,
6
11
  "",
@@ -24,6 +29,19 @@ export function patchPlanToMarkdown(plan: PatchPlan) {
24
29
  lines.push("");
25
30
  }
26
31
 
32
+ if (opts.summary) {
33
+ const ops = plan.operations.length;
34
+ lines.push(
35
+ ops === 0
36
+ ? "No patch operations proposed."
37
+ : `${ops} dry-run patch operation${ops === 1 ? "" : "s"} proposed — nothing written.`,
38
+ "",
39
+ "Full plan detail: re-run with `--full`.",
40
+ "Client-ready report: `fullstackgtm report` (add `--format html` for a printable file).",
41
+ );
42
+ return `${lines.join("\n")}\n`;
43
+ }
44
+
27
45
  lines.push("## Findings", "");
28
46
 
29
47
  const findings = plan.pipelineFindings ?? [];
@@ -107,7 +125,7 @@ export function patchPlanToMarkdown(plan: PatchPlan) {
107
125
 
108
126
  lines.push(
109
127
  "",
110
- "> This prototype is dry-run only. Real CRM writes must require explicit human approval before connector adapters apply any operation.",
128
+ "> 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.",
111
129
  );
112
130
  return `${lines.join("\n")}\n`;
113
131
  }