fullstackgtm 0.37.0 → 0.38.1

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.
@@ -0,0 +1,272 @@
1
+ /**
2
+ * LinkedIn connector — Phase 1 discovery (read-only).
3
+ *
4
+ * A provider-agnostic surface for pulling prospects out of LinkedIn via a
5
+ * rented execution layer (default: HeyReach; Unipile is a drop-in alternative).
6
+ * Deliberately decoupled from the acquire spine — the `Icp`→filter and
7
+ * `LinkedInProspect`→`Prospect` bridges live in the `enrich acquire --source
8
+ * linkedin` wiring, not here — so this layer can land and be tested on its own.
9
+ *
10
+ * Phase 2 (future) extends `LinkedInProvider` with send actions
11
+ * (connect/message) routed through the plan/apply gate. Spec:
12
+ * docs/linkedin-connector-spec.md. Nothing here ever writes or sends.
13
+ */
14
+
15
+ export type LinkedInProspect = {
16
+ firstName: string;
17
+ lastName: string;
18
+ fullName?: string;
19
+ headline?: string;
20
+ jobTitle?: string;
21
+ company?: string;
22
+ /** Canonical LinkedIn profile URL — the identity key dedup uses downstream. */
23
+ profileUrl: string;
24
+ location?: string;
25
+ email?: string;
26
+ emailStatus?: string;
27
+ /** The raw provider payload, preserved for debugging and later mapping. */
28
+ raw?: unknown;
29
+ };
30
+
31
+ export type LinkedInSource = {
32
+ /** Provider-native id of a prospect source (HeyReach: a lead-list id). */
33
+ id: string;
34
+ name: string;
35
+ /** Approximate prospect count, when the provider reports it. */
36
+ count?: number;
37
+ };
38
+
39
+ export type FetchProspectsOptions = {
40
+ /** Which source to read (HeyReach lead-list id); provider may have a default. */
41
+ sourceId?: string;
42
+ /** Hard cap on prospects returned; also bounds pagination. */
43
+ max?: number;
44
+ };
45
+
46
+ export type ConnectionStatus = { ok: boolean; detail: string };
47
+
48
+ /**
49
+ * Provider-agnostic LinkedIn discovery surface (Phase 1). Read-only: it
50
+ * enumerates prospect sources and pulls normalized prospects, never sends.
51
+ */
52
+ export interface LinkedInProvider {
53
+ readonly name: string;
54
+ /** Cheap auth/connectivity check; never throws — returns ok:false instead. */
55
+ checkConnection(): Promise<ConnectionStatus>;
56
+ /** Enumerate prospect sources (HeyReach lead lists). */
57
+ listSources(): Promise<LinkedInSource[]>;
58
+ /** Pull prospects from a source, normalized + capped. */
59
+ fetchProspects(options?: FetchProspectsOptions): Promise<LinkedInProspect[]>;
60
+ }
61
+
62
+ // ── HeyReach adapter ────────────────────────────────────────────────────────
63
+ // Base + auth verified 2026-06-20: POST endpoints, `X-API-KEY` header, 300
64
+ // req/min (429). Confirmed live: /auth/CheckApiKey, /list/GetAll. The
65
+ // leads-from-list path has a documented gap in HeyReach's public docs and is
66
+ // marked to reconcile at live validation (step 3, needs a real key).
67
+
68
+ export const HEYREACH_BASE = "https://api.heyreach.io/api/public";
69
+ const HEYREACH_MAX_PAGE = 100;
70
+
71
+ export type HeyReachProviderOptions = {
72
+ apiKey: string;
73
+ fetchImpl?: typeof fetch;
74
+ baseUrl?: string;
75
+ /** Lead-list id used when fetchProspects is called without an explicit sourceId. */
76
+ defaultListId?: string;
77
+ /** Page size for paginated reads (≤ server max). Lower in tests. */
78
+ pageSize?: number;
79
+ };
80
+
81
+ export function createHeyReachProvider(options: HeyReachProviderOptions): LinkedInProvider {
82
+ const apiKey = options.apiKey;
83
+ if (!apiKey) {
84
+ throw new Error("HeyReach API key is required (`login heyreach` or HEYREACH_API_KEY).");
85
+ }
86
+ const fetchImpl = options.fetchImpl ?? fetch;
87
+ const baseUrl = (options.baseUrl ?? HEYREACH_BASE).replace(/\/$/, "");
88
+ const pageSize = Math.max(1, Math.min(options.pageSize ?? HEYREACH_MAX_PAGE, HEYREACH_MAX_PAGE));
89
+
90
+ async function call(path: string, method: "GET" | "POST", body?: unknown): Promise<any> {
91
+ let response: Response;
92
+ try {
93
+ response = await fetchImpl(`${baseUrl}${path}`, {
94
+ method,
95
+ // Secret travels in the header, never the URL (no leak via logs/history).
96
+ headers: {
97
+ "X-API-KEY": apiKey,
98
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
99
+ },
100
+ body: body !== undefined ? JSON.stringify(body) : undefined,
101
+ });
102
+ } catch {
103
+ throw new Error(`Cannot reach HeyReach at ${baseUrl}${path}. Check network access.`);
104
+ }
105
+ if (response.status === 429) {
106
+ const retry = response.headers.get("Retry-After");
107
+ throw new Error(
108
+ `HeyReach rate limit (429)${retry ? `; retry after ${retry}s` : ""}. The cap is 300 requests/min.`,
109
+ );
110
+ }
111
+ if (!response.ok) {
112
+ await response.text().catch(() => undefined);
113
+ throw new Error(`HeyReach API error ${response.status} on ${path}. Check the API key and request.`);
114
+ }
115
+ if (response.status === 204) return undefined;
116
+ return response.json().catch(() => undefined);
117
+ }
118
+
119
+ return {
120
+ name: "heyreach",
121
+
122
+ async checkConnection() {
123
+ try {
124
+ await call("/auth/CheckApiKey", "GET");
125
+ return { ok: true, detail: "HeyReach API key valid." };
126
+ } catch (error) {
127
+ return { ok: false, detail: error instanceof Error ? error.message : String(error) };
128
+ }
129
+ },
130
+
131
+ async listSources() {
132
+ const data = await call("/list/GetAll", "POST", { offset: 0, limit: HEYREACH_MAX_PAGE });
133
+ return toArray(data).map((item: any) => ({
134
+ id: String(item?.id ?? item?.listId ?? ""),
135
+ name: String(item?.name ?? item?.title ?? "(unnamed list)"),
136
+ count:
137
+ typeof item?.totalItemsCount === "number"
138
+ ? item.totalItemsCount
139
+ : typeof item?.count === "number"
140
+ ? item.count
141
+ : undefined,
142
+ }));
143
+ },
144
+
145
+ async fetchProspects(opts = {}) {
146
+ const listId = opts.sourceId ?? options.defaultListId;
147
+ if (!listId) {
148
+ throw new Error(
149
+ "HeyReach fetchProspects needs a sourceId (lead-list id) or a configured defaultListId — list them with listSources().",
150
+ );
151
+ }
152
+ const max = opts.max ?? Number.POSITIVE_INFINITY;
153
+ const out: LinkedInProspect[] = [];
154
+ let offset = 0;
155
+ while (out.length < max) {
156
+ const data = await call("/list/GetLeadsFromList", "POST", { listId, offset, limit: pageSize });
157
+ const items = toArray(data);
158
+ if (items.length === 0) break;
159
+ for (const item of items) {
160
+ out.push(normalizeHeyReachLead(item));
161
+ if (out.length >= max) break;
162
+ }
163
+ if (items.length < pageSize) break;
164
+ offset += items.length;
165
+ }
166
+ return out;
167
+ },
168
+ };
169
+ }
170
+
171
+ /** First value that is a non-empty string, else undefined (treats "" as absent). */
172
+ function firstNonEmpty(...values: unknown[]): string | undefined {
173
+ for (const value of values) {
174
+ if (typeof value === "string" && value.trim() !== "") return value;
175
+ }
176
+ return undefined;
177
+ }
178
+
179
+ /** HeyReach wraps list payloads as `{ items: [...] }` on some routes, bare arrays on others. */
180
+ function toArray(data: any): any[] {
181
+ if (Array.isArray(data)) return data;
182
+ if (Array.isArray(data?.items)) return data.items;
183
+ return [];
184
+ }
185
+
186
+ /** Map a HeyReach lead (nested `profile` or flat) into the normalized shape. */
187
+ export function normalizeHeyReachLead(raw: any): LinkedInProspect {
188
+ const p = (raw && typeof raw === "object" && raw.profile) || raw || {};
189
+ const first = p.firstName ?? raw?.firstName ?? "";
190
+ const last = p.lastName ?? raw?.lastName ?? "";
191
+ const full = (p.fullName ?? raw?.fullName ?? `${first} ${last}`.trim()) || undefined;
192
+ return {
193
+ firstName: String(first ?? ""),
194
+ lastName: String(last ?? ""),
195
+ fullName: full,
196
+ headline: p.headline ?? raw?.headline ?? undefined,
197
+ jobTitle: p.position ?? p.jobTitle ?? raw?.position ?? undefined,
198
+ company: p.companyName ?? p.company ?? raw?.companyName ?? undefined,
199
+ profileUrl: String(p.profileUrl ?? p.linkedInUrl ?? raw?.profileUrl ?? raw?.linkedin_url ?? ""),
200
+ location: p.location ?? raw?.location ?? undefined,
201
+ // HeyReach exposes the raw `emailAddress` plus its own enrichment outputs
202
+ // (`enrichedEmailAddress`, `customEmailAddress`) — verified against the live
203
+ // /list/GetLeadsFromList schema 2026-06-20. Unresolved emails come back as
204
+ // "", so pick the first NON-EMPTY value (?? would keep the empty string).
205
+ email: firstNonEmpty(p.emailAddress, p.enrichedEmailAddress, p.customEmailAddress, p.email, raw?.emailAddress),
206
+ emailStatus: p.emailStatus ?? raw?.emailStatus ?? undefined,
207
+ raw,
208
+ };
209
+ }
210
+
211
+ // ── Fake provider (tests / dry-run with no key, no network) ─────────────────
212
+
213
+ export const FAKE_LINKEDIN_PROSPECTS: LinkedInProspect[] = [
214
+ {
215
+ firstName: "Dana",
216
+ lastName: "Okafor",
217
+ fullName: "Dana Okafor",
218
+ headline: "VP RevOps",
219
+ jobTitle: "VP Revenue Operations",
220
+ company: "Northwind Logistics",
221
+ profileUrl: "https://www.linkedin.com/in/dana-okafor",
222
+ location: "Chicago, IL",
223
+ email: "dana@northwind.example",
224
+ emailStatus: "verified",
225
+ },
226
+ {
227
+ firstName: "Priya",
228
+ lastName: "Raman",
229
+ fullName: "Priya Raman",
230
+ headline: "Head of Sales Ops",
231
+ jobTitle: "Head of Sales Operations",
232
+ company: "Cobalt Health",
233
+ profileUrl: "https://www.linkedin.com/in/priya-raman",
234
+ location: "Austin, TX",
235
+ },
236
+ {
237
+ firstName: "Marco",
238
+ lastName: "Bianchi",
239
+ fullName: "Marco Bianchi",
240
+ headline: "RevOps Manager",
241
+ jobTitle: "Revenue Operations Manager",
242
+ company: "Ferro Systems",
243
+ profileUrl: "https://www.linkedin.com/in/marco-bianchi",
244
+ location: "Remote",
245
+ email: "marco@ferro.example",
246
+ emailStatus: "guessed",
247
+ },
248
+ ];
249
+
250
+ export type FakeLinkedInOptions = {
251
+ prospects?: LinkedInProspect[];
252
+ sources?: LinkedInSource[];
253
+ connection?: ConnectionStatus;
254
+ };
255
+
256
+ export function createFakeLinkedInProvider(opts: FakeLinkedInOptions = {}): LinkedInProvider {
257
+ const prospects = opts.prospects ?? FAKE_LINKEDIN_PROSPECTS;
258
+ const sources = opts.sources ?? [{ id: "list_demo", name: "Demo ICP list", count: prospects.length }];
259
+ return {
260
+ name: "fake",
261
+ async checkConnection() {
262
+ return opts.connection ?? { ok: true, detail: "fake LinkedIn provider" };
263
+ },
264
+ async listSources() {
265
+ return sources.map((s) => ({ ...s }));
266
+ },
267
+ async fetchProspects(options = {}) {
268
+ const capped = options.max != null ? prospects.slice(0, options.max) : prospects;
269
+ return capped.map((p) => ({ ...p }));
270
+ },
271
+ };
272
+ }
package/src/enrich.ts CHANGED
@@ -3,6 +3,8 @@ 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
5
  import type { AcquireBudget } from "./acquireMeter.ts";
6
+ import { parseAssignmentPolicy, resolveAssignment } from "./assign.ts";
7
+ import type { AssignmentContext, AssignmentPolicy } from "./assign.ts";
6
8
  import type {
7
9
  CanonicalGtmSnapshot,
8
10
  CreateRecordPayload,
@@ -99,10 +101,12 @@ export type AcquireCreateMap = {
99
101
  * real emails (e.g. explorium discovers, pipe0 resolves the work email).
100
102
  */
101
103
  export type AcquireDiscoveryConfig = {
102
- provider: "explorium" | "pipe0";
104
+ provider: "explorium" | "pipe0" | "linkedin" | "heyreach";
103
105
  filters?: Record<string, unknown>;
104
106
  size?: number;
105
107
  resolveEmailsWith?: "pipe0";
108
+ /** LinkedIn sources only: the provider-native list id to read (HeyReach lead-list id). */
109
+ listId?: string;
106
110
  };
107
111
 
108
112
  export type AcquireConfig = {
@@ -114,6 +118,12 @@ export type AcquireConfig = {
114
118
  create: Partial<Record<EnrichObjectType, AcquireCreateMap>>;
115
119
  /** Net-new discovery params for API sources, by source id. */
116
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;
117
127
  };
118
128
 
119
129
  export const ENRICH_CONFIG_FILE_NAME = "enrich.config.json";
@@ -125,7 +135,7 @@ const OBJECT_TYPES: EnrichObjectType[] = ["company", "contact"];
125
135
  /** Match keys the matcher knows how to read off canonical snapshot records. */
126
136
  const MATCH_KEYS: Record<EnrichObjectType, string[]> = {
127
137
  company: ["domain", "name"],
128
- contact: ["email", "name"],
138
+ contact: ["email", "name", "linkedin"],
129
139
  };
130
140
 
131
141
  /** API source ids the MVP can pull from. */
@@ -311,6 +321,16 @@ export function parseEnrichConfig(raw: string): EnrichConfig {
311
321
  fail('"fields" maps nothing — add at least one field entry (or an "acquire.create" mapping)');
312
322
  }
313
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
+ }
333
+
314
334
  return config as EnrichConfig;
315
335
  }
316
336
 
@@ -348,6 +368,35 @@ export function builtinEnrichPreset(source: string | undefined): EnrichConfig |
348
368
  */
349
369
  export function builtinAcquirePreset(source: string | undefined): EnrichConfig | undefined {
350
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
+ }
351
400
  if (provider !== "pipe0" && provider !== "explorium") return undefined;
352
401
  return {
353
402
  sources: { [provider]: { kind: "api" } },
@@ -542,12 +591,16 @@ function normalizeKeyValue(key: string, value: unknown): string {
542
591
  .replace(/^www\./, "")
543
592
  .replace(/\/.*$/, "");
544
593
  }
594
+ if (key === "linkedin") {
595
+ // Match crmContactKeys' `li:` normalization (lowercased, trailing slash stripped).
596
+ return text.trim().replace(/\/+$/, "");
597
+ }
545
598
  return text.replace(/\s+/g, " ");
546
599
  }
547
600
 
548
601
  function crmKeyValue(
549
602
  objectType: EnrichObjectType,
550
- 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 },
551
604
  key: string,
552
605
  ): string {
553
606
  if (objectType === "company") {
@@ -556,6 +609,7 @@ function crmKeyValue(
556
609
  return "";
557
610
  }
558
611
  if (key === "email") return normalizeKeyValue("email", record.email);
612
+ if (key === "linkedin") return normalizeKeyValue("linkedin", record.linkedin);
559
613
  if (key === "name") {
560
614
  return normalizeKeyValue("name", `${record.firstName ?? ""} ${record.lastName ?? ""}`.trim());
561
615
  }
@@ -922,6 +976,10 @@ export type AcquireCounts = {
922
976
  created: number;
923
977
  /** unmatched rows that would have been created but for the meter ceiling. */
924
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;
925
983
  };
926
984
 
927
985
  export type AcquirePlanResult = {
@@ -941,6 +999,44 @@ export type BuildAcquirePlanOptions = {
941
999
  now?: () => Date;
942
1000
  };
943
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
+
944
1040
  /**
945
1041
  * Match each sourced record against the snapshot and route it: matched =
946
1042
  * already in the CRM (skip), ambiguous = a possible duplicate exists (skip —
@@ -969,9 +1065,30 @@ export function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanR
969
1065
  unmatched: 0,
970
1066
  created: 0,
971
1067
  withheldByMeter: 0,
1068
+ assigned: 0,
1069
+ unassigned: 0,
972
1070
  };
973
1071
  let estCostUsd = 0;
974
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
+
975
1092
  for (const record of records) {
976
1093
  const createMap = acquire.create[record.objectType];
977
1094
  const match = config.match[record.objectType];
@@ -1024,6 +1141,20 @@ export function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanR
1024
1141
  );
1025
1142
  evidence.push(recordEvidence);
1026
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
+
1027
1158
  const payload: CreateRecordPayload = {
1028
1159
  properties,
1029
1160
  matchKey: createMap.matchKey,
@@ -1031,6 +1162,7 @@ export function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanR
1031
1162
  source,
1032
1163
  estCostUsd: costPerRecord,
1033
1164
  associateCompanyName,
1165
+ ...(ownerId ? { ownerId, assignedBy } : {}),
1034
1166
  };
1035
1167
  operations.push({
1036
1168
  id: `op_acq_${fnv1a(`${source}:${record.objectType}:${matchValue}`)}`,
@@ -1048,6 +1180,8 @@ export function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanR
1048
1180
  rollback: "Archive the created record (it was net-new).",
1049
1181
  evidenceIds: [recordEvidence.id],
1050
1182
  });
1183
+ if (ownerId) counts.assigned += 1;
1184
+ else counts.unassigned += 1;
1051
1185
  counts.created += 1;
1052
1186
  estCostUsd += costPerRecord;
1053
1187
  }
package/src/index.ts CHANGED
@@ -1,4 +1,14 @@
1
1
  export { auditSnapshot, defaultPolicy } from "./audit.ts";
2
+ export {
3
+ matchesTerritory,
4
+ parseAssignmentPolicy,
5
+ resolveAssignment,
6
+ type AssignmentContext,
7
+ type AssignmentPolicy,
8
+ type AssignmentResult,
9
+ type AssignmentStrategy,
10
+ type TerritoryRule,
11
+ } from "./assign.ts";
2
12
  export { buildBulkUpdatePlan, isFilterableField, parseWhere, type BulkUpdateOptions } from "./bulkUpdate.ts";
3
13
  export { buildDedupePlan, dedupeKey, type DedupeOptions } from "./dedupe.ts";
4
14
  export { buildReassignPlans, type ReassignObjectType, type ReassignOptions } from "./reassign.ts";
package/src/reassign.ts CHANGED
@@ -28,10 +28,19 @@ import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
28
28
  export type ReassignObjectType = "account" | "contact" | "deal";
29
29
 
30
30
  export type ReassignOptions = {
31
- /** the owner whose records are being handed off */
32
- fromOwnerId: string;
31
+ /**
32
+ * the owner whose records are being handed off. Ignored (and not required)
33
+ * when `assignUnowned` is set — that mode targets ownerless records instead.
34
+ */
35
+ fromOwnerId?: string;
33
36
  /** the receiving owner — must be a known user in the snapshot */
34
37
  toOwnerId: string;
38
+ /**
39
+ * Backfill mode: instead of moving records from one owner to another, claim
40
+ * every OWNERLESS record (ownerId:empty) for `toOwnerId`. Shares the create-
41
+ * time assignment intent with `enrich acquire`, applied to existing debt.
42
+ */
43
+ assignUnowned?: boolean;
35
44
  /** which object types to compile plans for (default all three) */
36
45
  objects?: ReassignObjectType[];
37
46
  /** extra --where scoping, AND-ed into every plan (account fields lifted) */
@@ -65,11 +74,16 @@ export function buildReassignPlans(
65
74
  snapshot: CanonicalGtmSnapshot,
66
75
  options: ReassignOptions,
67
76
  ): PatchPlan[] {
68
- if (!options.fromOwnerId || !options.toOwnerId) {
69
- throw new Error("reassign requires both --from <ownerId> and --to <ownerId>.");
77
+ if (!options.toOwnerId) {
78
+ throw new Error("reassign requires --to <ownerId>.");
70
79
  }
71
- if (options.fromOwnerId === options.toOwnerId) {
72
- throw new Error("reassign --from and --to are the same owner — nothing to hand off.");
80
+ if (!options.assignUnowned) {
81
+ if (!options.fromOwnerId) {
82
+ throw new Error("reassign requires --from <ownerId> (or --assign-unowned to claim ownerless records).");
83
+ }
84
+ if (options.fromOwnerId === options.toOwnerId) {
85
+ throw new Error("reassign --from and --to are the same owner — nothing to hand off.");
86
+ }
73
87
  }
74
88
  // The receiving owner must exist: a typo'd --to would otherwise write an
75
89
  // invalid owner onto every matched record.
@@ -88,7 +102,7 @@ export function buildReassignPlans(
88
102
  }
89
103
 
90
104
  return objects.map((objectType) => {
91
- const where = [`ownerId=${options.fromOwnerId}`];
105
+ const where = [options.assignUnowned ? "ownerId:empty" : `ownerId=${options.fromOwnerId}`];
92
106
  if (objectType === "deal" && !options.includeClosedDeals) {
93
107
  where.push("isClosed=false"); // closed deals keep their historical owner
94
108
  }
@@ -110,7 +124,9 @@ export function buildReassignPlans(
110
124
  set: { ownerId: options.toOwnerId },
111
125
  reason:
112
126
  options.reason ??
113
- `reassign: hand off ${objectType}s from owner ${options.fromOwnerId} to ${options.toOwnerId}`,
127
+ (options.assignUnowned
128
+ ? `reassign: claim ownerless ${objectType}s for owner ${options.toOwnerId}`
129
+ : `reassign: hand off ${objectType}s from owner ${options.fromOwnerId} to ${options.toOwnerId}`),
114
130
  maxOperations: options.maxOperations,
115
131
  });
116
132
  });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Opt-in CLI → hosted-app observability. When the user has paired the CLI
3
+ * (`login --via <url>` → broker token), each command fire-and-forgets a run
4
+ * record to the hosted app's `/api/cli/run`, authenticated by the broker token.
5
+ *
6
+ * Invariants: the CLI never requires web login; this does nothing when unpaired;
7
+ * it never throws and never changes the CLI's exit code or output. Reporting is
8
+ * pure added value for users who granted it.
9
+ */
10
+ import { getCredential } from "./credentials.ts";
11
+
12
+ type RunEvent = { ts: number; type: string; detail?: string };
13
+
14
+ let counts: Record<string, unknown> | undefined;
15
+ const events: RunEvent[] = [];
16
+
17
+ /** A command annotates its headline metrics (merged). */
18
+ export function reportCounts(values: Record<string, unknown>): void {
19
+ counts = { ...(counts ?? {}), ...values };
20
+ }
21
+
22
+ /** A command annotates a structured event (plan saved, meter charged, …). */
23
+ export function reportEvent(type: string, detail?: string): void {
24
+ events.push({ ts: Date.now(), type, detail });
25
+ }
26
+
27
+ // Setup/inspection verbs aren't interesting as "runs" — skip the noise.
28
+ const SKIP_COMMANDS = new Set([
29
+ "login",
30
+ "logout",
31
+ "doctor",
32
+ "help",
33
+ "profiles",
34
+ "--help",
35
+ "-h",
36
+ "--version",
37
+ "-v",
38
+ ]);
39
+
40
+ /**
41
+ * Send the run record if the CLI is paired. Best-effort: a 4s-capped POST that
42
+ * swallows every error. Call once per process from the entry point.
43
+ */
44
+ export async function flushRunReport(
45
+ args: string[],
46
+ status: "success" | "partial" | "error",
47
+ startedAt: number,
48
+ error?: string,
49
+ ): Promise<void> {
50
+ const command = args[0];
51
+ if (!command || SKIP_COMMANDS.has(command)) return;
52
+ const broker = getCredential("broker");
53
+ if (!broker?.baseUrl || !broker.accessToken) return; // opt-in: only when paired
54
+
55
+ const sub = args[1] && !args[1].startsWith("-") ? ` ${args[1]}` : "";
56
+ const finishedAt = Date.now();
57
+ try {
58
+ await fetch(`${broker.baseUrl.replace(/\/+$/, "")}/api/cli/run`, {
59
+ method: "POST",
60
+ headers: { Authorization: `Bearer ${broker.accessToken}`, "Content-Type": "application/json" },
61
+ body: JSON.stringify({
62
+ command: `${command}${sub}`,
63
+ status,
64
+ startedAt,
65
+ finishedAt,
66
+ durationMs: finishedAt - startedAt,
67
+ counts,
68
+ events: events.length ? events : undefined,
69
+ error,
70
+ }),
71
+ signal: AbortSignal.timeout(4000),
72
+ });
73
+ } catch {
74
+ // Observability is best-effort; never affect the CLI outcome.
75
+ }
76
+ }
package/src/types.ts CHANGED
@@ -68,6 +68,14 @@ export type CreateRecordPayload = {
68
68
  source: string;
69
69
  estCostUsd?: number;
70
70
  associateCompanyName?: string;
71
+ /**
72
+ * Owner to stamp on the new record (canonical owner id). The connector maps
73
+ * it to the CRM's owner property (HubSpot `hubspot_owner_id`) at create time
74
+ * so a lead is never born ownerless. Absent = leave unassigned.
75
+ */
76
+ ownerId?: string;
77
+ /** Audit label for how the owner was chosen (e.g. "fixed", "territory:0"). */
78
+ assignedBy?: string;
71
79
  };
72
80
 
73
81
  export type AuditFindingSeverity = "info" | "warning" | "critical";