fullstackgtm 0.38.0 → 0.39.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 CHANGED
@@ -7,6 +7,44 @@ The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md)
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.39.0] — 2026-06-23
11
+
12
+ ### Added
13
+
14
+ - **LinkedIn acquire: configurable + email-resolving.** `linkedin` is now a
15
+ first-class `SUPPORTED_API_SOURCES` entry, so an explicit `enrich.config.json`
16
+ can drive it (set discovery `size` to read a whole list, not the preset's 25).
17
+ Email resolution is no longer gated on the dedupe key being `email`: any source
18
+ can opt in with `acquire.discovery.<src>.resolveEmailsWith: "pipe0"`, so a
19
+ LinkedIn source (which keys on the profile URL) can still resolve real work
20
+ emails (name + company → pipe0 waterfall) and create outreach-ready contacts.
21
+ - **Company-domain resolution for email enrichment** (`pipe0ResolveCompanyDomains`,
22
+ `hostFromUrl`). The work-email waterfall requires a company domain, but a
23
+ LinkedIn/HeyReach list supplies only company names — so the email step now
24
+ first resolves each unique company's domain via pipe0 `company:identity@1`
25
+ (name → website → bare host) before the waterfall. Unresolved companies are
26
+ left untouched (never a guessed domain), and the lookup is deduped per company.
27
+
28
+ ### Changed
29
+
30
+ - **ICP scoring reads the LinkedIn headline, not just the formal title.**
31
+ `scoreProspectAgainstIcp` now matches title keywords across `jobTitle` *and*
32
+ `headline`. On LinkedIn-sourced prospects the role signal often lives only in
33
+ the headline (position "Founder", headline "… | RevOps | …"), which scoring on
34
+ the title alone would miss. `Prospect` gains a `headline` field.
35
+
36
+ ## [0.38.1] — 2026-06-23
37
+
38
+ ### Fixed
39
+
40
+ - **`enrich acquire` LinkedIn creates failed with HubSpot 400.** The resolve-first
41
+ search before a `create_record` filtered on the *canonical* match key
42
+ (`linkedin`) instead of the HubSpot property name (`hs_linkedin_url`); HubSpot
43
+ 400s on a filter against a property it doesn't have, so every LinkedIn-sourced
44
+ lead failed at apply. The connector now translates the match key through
45
+ `HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts` before searching (email is unaffected
46
+ — its canonical name already equals the property name). Regression test added.
47
+
10
48
  ## [0.38.0] — 2026-06-23
11
49
 
12
50
  ### Added
@@ -25,6 +25,9 @@ export function linkedInProspectToProspect(lp) {
25
25
  fullName: lp.fullName,
26
26
  // Title drives ICP scoring; fall back to the headline when no title is set.
27
27
  jobTitle: lp.jobTitle ?? lp.headline,
28
+ // Keep the headline distinct too: the scorer matches keywords across both,
29
+ // so a role named only in the headline (not the position) still scores.
30
+ headline: lp.headline,
28
31
  companyName: lp.company,
29
32
  linkedin: lp.profileUrl || undefined,
30
33
  email: lp.email,
package/dist/cli.js CHANGED
@@ -32,7 +32,7 @@ import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
32
32
  import { DEFAULT_RUBRIC, classifyCallLlm, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
33
33
  import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, builtinAcquirePreset, builtinEnrichPreset, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor, } from "./enrich.js";
34
34
  import { loadMeter, recordConsumption, remaining, } from "./acquireMeter.js";
35
- import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspects, partitionFreshProspects, pipe0ResolveWorkEmails, prospectIdentityKeys, } from "./connectors/prospectSources.js";
35
+ import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspects, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys, } from "./connectors/prospectSources.js";
36
36
  import { loadSeen, recordSeen } from "./acquireSeen.js";
37
37
  import { reportCounts, reportEvent } from "./runReport.js";
38
38
  import { createLinkedInProvider, discoverLinkedInProspects } from "./acquireLinkedIn.js";
@@ -2096,9 +2096,18 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen) {
2096
2096
  // and apply-time resolve-first remain the precise backstop.
2097
2097
  const { fresh, skippedCrm, skippedSeen } = partitionFreshProspects(prospects, crmContactKeys(snapshot), seen);
2098
2098
  prospects = fresh;
2099
- // 3. Resolve real work emails (both providers need it: Explorium's email is
2100
- // hashed, Crustdata search returns none). pipe0 waterfall, chunked.
2101
- if (matchKey === "email") {
2099
+ // 3. Resolve real work emails. Triggered either when email IS the dedupe key
2100
+ // (Explorium's email is hashed, Crustdata returns none) or when a source
2101
+ // that keys on something else opts in via `resolveEmailsWith: "pipe0"`
2102
+ // (e.g. LinkedIn keys on the profile URL but still wants outreach emails).
2103
+ // pipe0 waterfall, chunked; resolves from name + company domain/name.
2104
+ if (matchKey === "email" || disc.resolveEmailsWith === "pipe0") {
2105
+ // The waterfall needs a company DOMAIN; LinkedIn/HeyReach lists carry only
2106
+ // names. Resolve domains first (pipe0 company:identity) so resolution can
2107
+ // actually land — without it, name-only resolution fails for every lead.
2108
+ if (prospects.some((p) => !p.companyDomain && p.companyName)) {
2109
+ prospects = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects });
2110
+ }
2102
2111
  prospects = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects });
2103
2112
  }
2104
2113
  const processedKeys = prospects.flatMap(prospectIdentityKeys);
@@ -493,13 +493,17 @@ export function createHubspotConnector(options) {
493
493
  }
494
494
  return { operationId: operation.id, status: "applied", detail: `Resolved/created company "${matchValue}" (${id}).`, providerData: { id } };
495
495
  }
496
- // contact — resolve-first on the dedupe key (email by default)
496
+ // contact — resolve-first on the dedupe key (email by default). The match
497
+ // key is canonical (e.g. "linkedin"); translate it to the HubSpot property
498
+ // name ("hs_linkedin_url") before searching — HubSpot 400s on a filter
499
+ // against a property it doesn't have (there is no property named "linkedin").
497
500
  const matchKey = payload.matchKey || "email";
501
+ const searchProperty = HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts[matchKey] ?? matchKey;
498
502
  const matchKeyLower = `${matchKey}:${matchValue.toLowerCase()}`;
499
503
  if (createdContactsByMatch.has(matchKeyLower)) {
500
504
  return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: createdContactsByMatch.get(matchKeyLower), existing: true } };
501
505
  }
502
- const existing = await searchContactsBy(matchKey, matchValue);
506
+ const existing = await searchContactsBy(searchProperty, matchValue);
503
507
  if (existing.length > 0) {
504
508
  createdContactsByMatch.set(matchKeyLower, existing[0]);
505
509
  return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already exists (${existing.join(", ")}); resolve-first declined to create.`, providerData: { id: existing[0], existing: true } };
@@ -781,7 +785,16 @@ export function createHubspotConnector(options) {
781
785
  const defaults = HUBSPOT_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
782
786
  const property = mappedField(mappings, mappingType, field, defaults[field] ?? field);
783
787
  const data = await request(`/crm/v3/objects/${objectPath}/${encodeURIComponent(objectId)}?properties=${encodeURIComponent(property)}`);
784
- return data?.properties?.[property] ?? null;
788
+ const value = data?.properties?.[property] ?? null;
789
+ // fetchSnapshot normalizes closeDate to a date with `?.split("T")[0]`, so the
790
+ // value baked into an op's beforeValue is date-only ("2026-03-07"). The
791
+ // single-object read here returns HubSpot's raw "2026-03-07T00:00:00Z", which
792
+ // made compare-and-set see a spurious drift and refuse every date-field write.
793
+ // Mirror the snapshot's normalization so the comparison is apples-to-apples.
794
+ if (field === "closeDate" && typeof value === "string") {
795
+ return value.split("T")[0];
796
+ }
797
+ return value;
785
798
  }
786
799
  return {
787
800
  provider: "hubspot",
@@ -19,6 +19,9 @@ export type Prospect = {
19
19
  lastName?: string;
20
20
  fullName?: string;
21
21
  jobTitle?: string;
22
+ /** LinkedIn headline — secondary role signal for ICP scoring (the title
23
+ * keyword may live here, not in the formal jobTitle). */
24
+ headline?: string;
22
25
  /** normalized seniority, e.g. "cxo","vp","director" (Explorium job_level_main) */
23
26
  jobLevel?: string;
24
27
  /** normalized department, e.g. "sales" (Explorium job_department_main) */
@@ -68,6 +71,23 @@ export declare function pipe0ResolveWorkEmails(opts: {
68
71
  * for every row, so one big batch is all-or-nothing). Default 3. */
69
72
  chunkSize?: number;
70
73
  }): Promise<Prospect[]>;
74
+ /** Bare registrable host from a URL or domain string ("https://www.x.com/a" → "x.com"). */
75
+ export declare function hostFromUrl(url: string | undefined): string | undefined;
76
+ /**
77
+ * Fill `companyDomain` for prospects that have a company NAME but no domain,
78
+ * using pipe0's `company:identity@1` pipe (name → website URL). The work-email
79
+ * waterfall requires a domain — a LinkedIn/HeyReach list supplies only names, so
80
+ * without this step every resolution fails. Resolves each unique company once
81
+ * (companies repeat across a list); leaves a prospect untouched when the domain
82
+ * can't be found, so the waterfall simply skips it rather than guessing.
83
+ */
84
+ export declare function pipe0ResolveCompanyDomains(opts: {
85
+ apiKey: string;
86
+ prospects: Prospect[];
87
+ apiBaseUrl?: string;
88
+ fetchImpl?: FetchImpl;
89
+ chunkSize?: number;
90
+ }): Promise<Prospect[]>;
71
91
  /**
72
92
  * Stable identity keys for a prospect (prefixed by kind). A match on ANY key
73
93
  * means "same person". LinkedIn is strongest; name+domain is the only key
@@ -144,6 +144,73 @@ export async function pipe0ResolveWorkEmails(opts) {
144
144
  function personKey(name, company) {
145
145
  return `${(name ?? "").trim().toLowerCase()}|${(company ?? "").trim().toLowerCase()}`;
146
146
  }
147
+ /** Bare registrable host from a URL or domain string ("https://www.x.com/a" → "x.com"). */
148
+ export function hostFromUrl(url) {
149
+ if (!url || !url.trim())
150
+ return undefined;
151
+ try {
152
+ const u = url.includes("://") ? url : `https://${url}`;
153
+ const host = new URL(u).hostname.replace(/^www\./, "");
154
+ return host || undefined;
155
+ }
156
+ catch {
157
+ return undefined;
158
+ }
159
+ }
160
+ /**
161
+ * Fill `companyDomain` for prospects that have a company NAME but no domain,
162
+ * using pipe0's `company:identity@1` pipe (name → website URL). The work-email
163
+ * waterfall requires a domain — a LinkedIn/HeyReach list supplies only names, so
164
+ * without this step every resolution fails. Resolves each unique company once
165
+ * (companies repeat across a list); leaves a prospect untouched when the domain
166
+ * can't be found, so the waterfall simply skips it rather than guessing.
167
+ */
168
+ export async function pipe0ResolveCompanyDomains(opts) {
169
+ const fetchImpl = opts.fetchImpl ?? fetch;
170
+ const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
171
+ const chunkSize = Math.max(1, opts.chunkSize ?? 5);
172
+ const uniqueNames = [
173
+ ...new Set(opts.prospects
174
+ .filter((p) => !p.companyDomain && p.companyName)
175
+ .map((p) => p.companyName.trim())
176
+ .filter(Boolean)),
177
+ ];
178
+ if (uniqueNames.length === 0)
179
+ return opts.prospects;
180
+ const domainByName = new Map();
181
+ for (let i = 0; i < uniqueNames.length; i += chunkSize) {
182
+ const chunk = uniqueNames.slice(i, i + chunkSize);
183
+ try {
184
+ const response = await fetchImpl(`${base}/v1/pipes/run/sync`, {
185
+ method: "POST",
186
+ headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
187
+ body: JSON.stringify({
188
+ pipes: [{ pipe_id: "company:identity@1" }],
189
+ input: chunk.map((company_name) => ({ company_name })),
190
+ }),
191
+ });
192
+ if (!response.ok)
193
+ continue;
194
+ const body = (await response.json());
195
+ for (const recordId of body.order ?? []) {
196
+ const fields = body.records?.[recordId]?.fields ?? {};
197
+ const name = fieldValue(fields.company_name) ?? fieldValue(fields.cleaned_company_name);
198
+ const domain = hostFromUrl(fieldValue(fields.company_website_url));
199
+ if (name && domain)
200
+ domainByName.set(name.trim().toLowerCase(), domain);
201
+ }
202
+ }
203
+ catch {
204
+ continue;
205
+ }
206
+ }
207
+ return opts.prospects.map((p) => {
208
+ if (p.companyDomain || !p.companyName)
209
+ return p;
210
+ const domain = domainByName.get(p.companyName.trim().toLowerCase());
211
+ return domain ? { ...p, companyDomain: domain } : p;
212
+ });
213
+ }
147
214
  function fieldValue(field) {
148
215
  const v = field?.value;
149
216
  return typeof v === "string" && v.trim() ? v : undefined;
@@ -31,6 +31,37 @@ export function createSalesforceConnector(options) {
31
31
  const mappings = options.fieldMappings;
32
32
  // create:<Name> dedup within one connector lifetime (one apply run).
33
33
  const createdAccountsByName = new Map();
34
+ // create_record contact dedup (keyed by matchKey:matchValue), same lifetime.
35
+ const createdContactsByMatch = new Map();
36
+ // Resolve an Account by exact name: a unique existing match is reused, a
37
+ // confirmed miss is created, and an ambiguous name (>1 match) is refused.
38
+ // Caches within the run so the same name is never created twice — shared by
39
+ // `link_record`'s create:<Name> path and `create_record`'s company linking.
40
+ async function resolveOrCreateAccountByName(name) {
41
+ const nameKey = name.toLowerCase();
42
+ const cached = createdAccountsByName.get(nameKey);
43
+ if (cached)
44
+ return { id: cached, createdNew: false };
45
+ const soqlName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
46
+ const matches = await query(`SELECT Id FROM Account WHERE Name = '${soqlName}' LIMIT 3`);
47
+ if (matches.length > 1)
48
+ return { ambiguous: matches.length };
49
+ let id;
50
+ let createdNew = false;
51
+ if (matches.length === 1) {
52
+ id = String(matches[0].Id);
53
+ }
54
+ else {
55
+ const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
56
+ method: "POST",
57
+ body: JSON.stringify({ Name: name }),
58
+ });
59
+ id = String(created.id);
60
+ createdNew = true;
61
+ }
62
+ createdAccountsByName.set(nameKey, id);
63
+ return { id, createdNew };
64
+ }
34
65
  async function request(path, init = {}) {
35
66
  const connection = await options.getConnection();
36
67
  const url = path.startsWith("http")
@@ -379,6 +410,85 @@ export function createSalesforceConnector(options) {
379
410
  providerData: { survivorId: master, mergedRecordIds: merged },
380
411
  };
381
412
  }
413
+ // Resolve-first net-new lead creation (the `enrich acquire` path). Re-resolves
414
+ // on matchKey/matchValue at apply time — search is the source of truth — and
415
+ // creates only on a confirmed miss, so concurrent writers never get duplicated.
416
+ // Mirrors the HubSpot connector's createRecord contract.
417
+ async function createRecord(operation) {
418
+ const payload = operation.afterValue;
419
+ if (!payload || typeof payload !== "object" || !payload.properties) {
420
+ return { operationId: operation.id, status: "skipped", detail: "create_record needs a CreateRecordPayload afterValue." };
421
+ }
422
+ if (operation.objectType !== "contact" && operation.objectType !== "account") {
423
+ return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and accounts." };
424
+ }
425
+ const matchValue = String(payload.matchValue ?? "").trim();
426
+ if (!matchValue) {
427
+ return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
428
+ }
429
+ if (operation.objectType === "account") {
430
+ const resolved = await resolveOrCreateAccountByName(matchValue);
431
+ if ("ambiguous" in resolved) {
432
+ return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — ${resolved.ambiguous} accounts named "${matchValue}". Not creating.` };
433
+ }
434
+ return { operationId: operation.id, status: "applied", detail: `Resolved/created account "${matchValue}" (${resolved.id}).`, providerData: { id: resolved.id, created: resolved.createdNew } };
435
+ }
436
+ // contact — resolve-first on the dedupe key (email by default). matchKey is
437
+ // canonical; translate it to the Salesforce field before the SOQL search.
438
+ const matchKey = payload.matchKey || "email";
439
+ const searchField = mappedField(mappings, "contacts", matchKey, SALESFORCE_DEFAULT_FIELD_MAPPINGS.contacts[matchKey] ?? matchKey);
440
+ const matchKeyLower = `${matchKey}:${matchValue.toLowerCase()}`;
441
+ if (createdContactsByMatch.has(matchKeyLower)) {
442
+ return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: createdContactsByMatch.get(matchKeyLower), existing: true } };
443
+ }
444
+ const soqlValue = matchValue.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
445
+ const existing = await query(`SELECT Id FROM Contact WHERE ${searchField} = '${soqlValue}' LIMIT 5`);
446
+ if (existing.length > 0) {
447
+ const existingId = String(existing[0].Id);
448
+ createdContactsByMatch.set(matchKeyLower, existingId);
449
+ return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already exists (${existing.map((r) => String(r.Id)).join(", ")}); resolve-first declined to create.`, providerData: { id: existingId, existing: true } };
450
+ }
451
+ const fields = { ...payload.properties };
452
+ // Stamp ownership at create time so the lead is never born ownerless. The
453
+ // canonical ownerId maps to Salesforce's OwnerId.
454
+ if (payload.ownerId) {
455
+ const ownerField = mappedField(mappings, "contacts", "ownerId", SALESFORCE_DEFAULT_FIELD_MAPPINGS.contacts.ownerId ?? "OwnerId");
456
+ if (!fields[ownerField])
457
+ fields[ownerField] = String(payload.ownerId);
458
+ }
459
+ const created = await request(`/services/data/${apiVersion}/sobjects/Contact`, {
460
+ method: "POST",
461
+ body: JSON.stringify(fields),
462
+ });
463
+ const contactId = String(created.id);
464
+ createdContactsByMatch.set(matchKeyLower, contactId);
465
+ let companyNote = "";
466
+ if (payload.associateCompanyName) {
467
+ try {
468
+ const resolved = await resolveOrCreateAccountByName(payload.associateCompanyName);
469
+ if ("ambiguous" in resolved) {
470
+ companyNote = ` Account "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
471
+ }
472
+ else {
473
+ await request(`/services/data/${apiVersion}/sobjects/Contact/${encodeURIComponent(contactId)}`, {
474
+ method: "PATCH",
475
+ body: JSON.stringify({ AccountId: resolved.id }),
476
+ });
477
+ companyNote = ` Linked to account "${payload.associateCompanyName}" (${resolved.id}).`;
478
+ }
479
+ }
480
+ catch (error) {
481
+ // Association is best-effort — the contact create already succeeded.
482
+ companyNote = ` (account link failed: ${error instanceof Error ? error.message : String(error)})`;
483
+ }
484
+ }
485
+ return {
486
+ operationId: operation.id,
487
+ status: "applied",
488
+ detail: `Created contact ${matchKey}=${matchValue} (${contactId}).${companyNote}`,
489
+ providerData: { id: contactId, created: true },
490
+ };
491
+ }
382
492
  async function applyOperation(operation) {
383
493
  try {
384
494
  switch (operation.operation) {
@@ -396,32 +506,15 @@ export function createSalesforceConnector(options) {
396
506
  if (!name) {
397
507
  return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
398
508
  }
399
- const nameKey = name.toLowerCase();
400
- let accountId = createdAccountsByName.get(nameKey);
401
- let createdNew = false;
402
- if (!accountId) {
403
- const soqlName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
404
- const matches = await query(`SELECT Id FROM Account WHERE Name = '${soqlName}' LIMIT 3`);
405
- if (matches.length > 1) {
406
- return {
407
- operationId: operation.id,
408
- status: "skipped",
409
- detail: `create:${name} is ambiguous — ${matches.length} accounts already named "${name}". Link an explicit account id instead.`,
410
- };
411
- }
412
- if (matches.length === 1) {
413
- accountId = String(matches[0].Id);
414
- }
415
- else {
416
- const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
417
- method: "POST",
418
- body: JSON.stringify({ Name: name }),
419
- });
420
- accountId = String(created.id);
421
- createdNew = true;
422
- }
423
- createdAccountsByName.set(nameKey, accountId);
509
+ const resolved = await resolveOrCreateAccountByName(name);
510
+ if ("ambiguous" in resolved) {
511
+ return {
512
+ operationId: operation.id,
513
+ status: "skipped",
514
+ detail: `create:${name} is ambiguous ${resolved.ambiguous} accounts already named "${name}". Link an explicit account id instead.`,
515
+ };
424
516
  }
517
+ const { id: accountId, createdNew } = resolved;
425
518
  const result = await setField({ ...operation, operation: "set_field", afterValue: accountId });
426
519
  return result.status === "applied"
427
520
  ? {
@@ -441,6 +534,8 @@ export function createSalesforceConnector(options) {
441
534
  return await mergeRecords(operation);
442
535
  case "archive_record":
443
536
  return await archiveRecord(operation);
537
+ case "create_record":
538
+ return await createRecord(operation);
444
539
  default:
445
540
  return {
446
541
  operationId: operation.id,
package/dist/enrich.js CHANGED
@@ -12,7 +12,7 @@ const MATCH_KEYS = {
12
12
  contact: ["email", "name", "linkedin"],
13
13
  };
14
14
  /** API source ids the MVP can pull from. */
15
- export const SUPPORTED_API_SOURCES = ["apollo", "explorium", "pipe0"];
15
+ export const SUPPORTED_API_SOURCES = ["apollo", "explorium", "pipe0", "linkedin"];
16
16
  /**
17
17
  * Canonical fields enrich may target, plus the HubSpot property spellings the
18
18
  * config may use for them (so `"crm": "numberofemployees"` and
package/dist/icp.d.ts CHANGED
@@ -69,6 +69,7 @@ export declare function scoreProspectAgainstIcp(prospect: {
69
69
  jobTitle?: string;
70
70
  jobLevel?: string;
71
71
  jobDepartment?: string;
72
+ headline?: string;
72
73
  }, icp: Icp): IcpFit;
73
74
  export declare function fitThreshold(icp: Icp): number;
74
75
  export type IcpInterviewQuestion = {
package/dist/icp.js CHANGED
@@ -131,7 +131,11 @@ function titleCase(value) {
131
131
  */
132
132
  export function scoreProspectAgainstIcp(prospect, icp) {
133
133
  const reasons = [];
134
- const title = (prospect.jobTitle ?? "").toLowerCase();
134
+ // Match title keywords across BOTH the formal title and the headline: on
135
+ // LinkedIn-sourced prospects the role signal often lives only in the headline
136
+ // (e.g. position "Founder" but headline "… | RevOps | …"), so scoring on the
137
+ // title alone would miss genuine in-persona leads.
138
+ const title = `${prospect.jobTitle ?? ""} ${prospect.headline ?? ""}`.trim().toLowerCase();
135
139
  const keywords = (icp.persona.titleKeywords ?? []).map((k) => k.toLowerCase());
136
140
  const levels = (icp.persona.jobLevels ?? []).map((l) => l.toLowerCase());
137
141
  const depts = (icp.persona.departments ?? []).map((d) => d.toLowerCase());
package/docs/api.md CHANGED
@@ -149,6 +149,14 @@ zero-config `EnrichConfig.acquire` (budget, provider, create-mapping) so
149
149
  pulls a HeyReach lead list and `linkedInProspectToProspect` maps each lead to the
150
150
  canonical `Prospect` (match key = LinkedIn URL), so ICP scoring / dedup / meter /
151
151
  apply are reused unchanged. Phase 1 is discovery-only — read-only, never sends.
152
+ `linkedin` is a `SUPPORTED_API_SOURCES` source, so an explicit config can set
153
+ discovery `size` (read a whole list, not the preset's 25). A LinkedIn list
154
+ carries no emails; opt into resolution with
155
+ `acquire.discovery.linkedin.resolveEmailsWith: "pipe0"` — email resolution is
156
+ no longer gated on the dedupe key being `email`, so a profile-URL-keyed source
157
+ still creates outreach-ready, emailed contacts. ICP scoring matches title
158
+ keywords across `Prospect.jobTitle` **and** `headline` (LinkedIn roles often
159
+ live in the headline, not the formal title).
152
160
  - **Meter** (`acquireMeter.ts`): `AcquireBudget`, `loadMeter` / `remaining` /
153
161
  `recordConsumption` — per-profile windowed record+spend budget, charged only
154
162
  for landed creates.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
@@ -33,6 +33,9 @@ export function linkedInProspectToProspect(lp: LinkedInProspect): Prospect {
33
33
  fullName: lp.fullName,
34
34
  // Title drives ICP scoring; fall back to the headline when no title is set.
35
35
  jobTitle: lp.jobTitle ?? lp.headline,
36
+ // Keep the headline distinct too: the scorer matches keywords across both,
37
+ // so a role named only in the headline (not the position) still scores.
38
+ headline: lp.headline,
36
39
  companyName: lp.company,
37
40
  linkedin: lp.profileUrl || undefined,
38
41
  email: lp.email,
package/src/cli.ts CHANGED
@@ -130,6 +130,7 @@ import {
130
130
  fetchExploriumProspects,
131
131
  fetchPipe0CrustdataProspects,
132
132
  partitionFreshProspects,
133
+ pipe0ResolveCompanyDomains,
133
134
  pipe0ResolveWorkEmails,
134
135
  prospectIdentityKeys,
135
136
  type Prospect,
@@ -2450,9 +2451,18 @@ async function acquireFromApi(
2450
2451
  const { fresh, skippedCrm, skippedSeen } = partitionFreshProspects(prospects, crmContactKeys(snapshot), seen);
2451
2452
  prospects = fresh;
2452
2453
 
2453
- // 3. Resolve real work emails (both providers need it: Explorium's email is
2454
- // hashed, Crustdata search returns none). pipe0 waterfall, chunked.
2455
- if (matchKey === "email") {
2454
+ // 3. Resolve real work emails. Triggered either when email IS the dedupe key
2455
+ // (Explorium's email is hashed, Crustdata returns none) or when a source
2456
+ // that keys on something else opts in via `resolveEmailsWith: "pipe0"`
2457
+ // (e.g. LinkedIn keys on the profile URL but still wants outreach emails).
2458
+ // pipe0 waterfall, chunked; resolves from name + company domain/name.
2459
+ if (matchKey === "email" || disc.resolveEmailsWith === "pipe0") {
2460
+ // The waterfall needs a company DOMAIN; LinkedIn/HeyReach lists carry only
2461
+ // names. Resolve domains first (pipe0 company:identity) so resolution can
2462
+ // actually land — without it, name-only resolution fails for every lead.
2463
+ if (prospects.some((p) => !p.companyDomain && p.companyName)) {
2464
+ prospects = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects });
2465
+ }
2456
2466
  prospects = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects });
2457
2467
  }
2458
2468
 
@@ -595,13 +595,17 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
595
595
  return { operationId: operation.id, status: "applied", detail: `Resolved/created company "${matchValue}" (${id}).`, providerData: { id } };
596
596
  }
597
597
 
598
- // contact — resolve-first on the dedupe key (email by default)
598
+ // contact — resolve-first on the dedupe key (email by default). The match
599
+ // key is canonical (e.g. "linkedin"); translate it to the HubSpot property
600
+ // name ("hs_linkedin_url") before searching — HubSpot 400s on a filter
601
+ // against a property it doesn't have (there is no property named "linkedin").
599
602
  const matchKey = payload.matchKey || "email";
603
+ const searchProperty = HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts[matchKey] ?? matchKey;
600
604
  const matchKeyLower = `${matchKey}:${matchValue.toLowerCase()}`;
601
605
  if (createdContactsByMatch.has(matchKeyLower)) {
602
606
  return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: createdContactsByMatch.get(matchKeyLower), existing: true } };
603
607
  }
604
- const existing = await searchContactsBy(matchKey, matchValue);
608
+ const existing = await searchContactsBy(searchProperty, matchValue);
605
609
  if (existing.length > 0) {
606
610
  createdContactsByMatch.set(matchKeyLower, existing[0]);
607
611
  return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already exists (${existing.join(", ")}); resolve-first declined to create.`, providerData: { id: existing[0], existing: true } };
@@ -905,7 +909,16 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
905
909
  const data = await request(
906
910
  `/crm/v3/objects/${objectPath}/${encodeURIComponent(objectId)}?properties=${encodeURIComponent(property)}`,
907
911
  );
908
- return data?.properties?.[property] ?? null;
912
+ const value = data?.properties?.[property] ?? null;
913
+ // fetchSnapshot normalizes closeDate to a date with `?.split("T")[0]`, so the
914
+ // value baked into an op's beforeValue is date-only ("2026-03-07"). The
915
+ // single-object read here returns HubSpot's raw "2026-03-07T00:00:00Z", which
916
+ // made compare-and-set see a spurious drift and refuse every date-field write.
917
+ // Mirror the snapshot's normalization so the comparison is apples-to-apples.
918
+ if (field === "closeDate" && typeof value === "string") {
919
+ return value.split("T")[0];
920
+ }
921
+ return value;
909
922
  }
910
923
 
911
924
  return {
@@ -20,6 +20,9 @@ export type Prospect = {
20
20
  lastName?: string;
21
21
  fullName?: string;
22
22
  jobTitle?: string;
23
+ /** LinkedIn headline — secondary role signal for ICP scoring (the title
24
+ * keyword may live here, not in the formal jobTitle). */
25
+ headline?: string;
23
26
  /** normalized seniority, e.g. "cxo","vp","director" (Explorium job_level_main) */
24
27
  jobLevel?: string;
25
28
  /** normalized department, e.g. "sales" (Explorium job_department_main) */
@@ -233,6 +236,78 @@ function personKey(name: string | undefined, company: string | undefined): strin
233
236
  return `${(name ?? "").trim().toLowerCase()}|${(company ?? "").trim().toLowerCase()}`;
234
237
  }
235
238
 
239
+ /** Bare registrable host from a URL or domain string ("https://www.x.com/a" → "x.com"). */
240
+ export function hostFromUrl(url: string | undefined): string | undefined {
241
+ if (!url || !url.trim()) return undefined;
242
+ try {
243
+ const u = url.includes("://") ? url : `https://${url}`;
244
+ const host = new URL(u).hostname.replace(/^www\./, "");
245
+ return host || undefined;
246
+ } catch {
247
+ return undefined;
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Fill `companyDomain` for prospects that have a company NAME but no domain,
253
+ * using pipe0's `company:identity@1` pipe (name → website URL). The work-email
254
+ * waterfall requires a domain — a LinkedIn/HeyReach list supplies only names, so
255
+ * without this step every resolution fails. Resolves each unique company once
256
+ * (companies repeat across a list); leaves a prospect untouched when the domain
257
+ * can't be found, so the waterfall simply skips it rather than guessing.
258
+ */
259
+ export async function pipe0ResolveCompanyDomains(opts: {
260
+ apiKey: string;
261
+ prospects: Prospect[];
262
+ apiBaseUrl?: string;
263
+ fetchImpl?: FetchImpl;
264
+ chunkSize?: number;
265
+ }): Promise<Prospect[]> {
266
+ const fetchImpl = opts.fetchImpl ?? fetch;
267
+ const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
268
+ const chunkSize = Math.max(1, opts.chunkSize ?? 5);
269
+
270
+ const uniqueNames = [
271
+ ...new Set(
272
+ opts.prospects
273
+ .filter((p) => !p.companyDomain && p.companyName)
274
+ .map((p) => p.companyName!.trim())
275
+ .filter(Boolean),
276
+ ),
277
+ ];
278
+ if (uniqueNames.length === 0) return opts.prospects;
279
+
280
+ const domainByName = new Map<string, string>();
281
+ for (let i = 0; i < uniqueNames.length; i += chunkSize) {
282
+ const chunk = uniqueNames.slice(i, i + chunkSize);
283
+ try {
284
+ const response = await fetchImpl(`${base}/v1/pipes/run/sync`, {
285
+ method: "POST",
286
+ headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
287
+ body: JSON.stringify({
288
+ pipes: [{ pipe_id: "company:identity@1" }],
289
+ input: chunk.map((company_name) => ({ company_name })),
290
+ }),
291
+ });
292
+ if (!response.ok) continue;
293
+ const body = (await response.json()) as Pipe0RunResponse;
294
+ for (const recordId of body.order ?? []) {
295
+ const fields = body.records?.[recordId]?.fields ?? {};
296
+ const name = fieldValue(fields.company_name) ?? fieldValue(fields.cleaned_company_name);
297
+ const domain = hostFromUrl(fieldValue(fields.company_website_url));
298
+ if (name && domain) domainByName.set(name.trim().toLowerCase(), domain);
299
+ }
300
+ } catch {
301
+ continue;
302
+ }
303
+ }
304
+ return opts.prospects.map((p) => {
305
+ if (p.companyDomain || !p.companyName) return p;
306
+ const domain = domainByName.get(p.companyName.trim().toLowerCase());
307
+ return domain ? { ...p, companyDomain: domain } : p;
308
+ });
309
+ }
310
+
236
311
  type Pipe0Field = { value?: unknown; status?: string };
237
312
  type Pipe0RunResponse = {
238
313
  order?: string[];
@@ -12,6 +12,7 @@ import type {
12
12
  CanonicalDeal,
13
13
  CanonicalGtmSnapshot,
14
14
  CanonicalUser,
15
+ CreateRecordPayload,
15
16
  GtmConnector,
16
17
  GtmObjectType,
17
18
  PatchOperation,
@@ -72,6 +73,37 @@ export function createSalesforceConnector(
72
73
  const mappings = options.fieldMappings;
73
74
  // create:<Name> dedup within one connector lifetime (one apply run).
74
75
  const createdAccountsByName = new Map<string, string>();
76
+ // create_record contact dedup (keyed by matchKey:matchValue), same lifetime.
77
+ const createdContactsByMatch = new Map<string, string>();
78
+
79
+ // Resolve an Account by exact name: a unique existing match is reused, a
80
+ // confirmed miss is created, and an ambiguous name (>1 match) is refused.
81
+ // Caches within the run so the same name is never created twice — shared by
82
+ // `link_record`'s create:<Name> path and `create_record`'s company linking.
83
+ async function resolveOrCreateAccountByName(
84
+ name: string,
85
+ ): Promise<{ id: string; createdNew: boolean } | { ambiguous: number }> {
86
+ const nameKey = name.toLowerCase();
87
+ const cached = createdAccountsByName.get(nameKey);
88
+ if (cached) return { id: cached, createdNew: false };
89
+ const soqlName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
90
+ const matches = await query(`SELECT Id FROM Account WHERE Name = '${soqlName}' LIMIT 3`);
91
+ if (matches.length > 1) return { ambiguous: matches.length };
92
+ let id: string;
93
+ let createdNew = false;
94
+ if (matches.length === 1) {
95
+ id = String(matches[0].Id);
96
+ } else {
97
+ const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
98
+ method: "POST",
99
+ body: JSON.stringify({ Name: name }),
100
+ });
101
+ id = String(created.id);
102
+ createdNew = true;
103
+ }
104
+ createdAccountsByName.set(nameKey, id);
105
+ return { id, createdNew };
106
+ }
75
107
 
76
108
  async function request(path: string, init: RequestInit = {}): Promise<any> {
77
109
  const connection = await options.getConnection();
@@ -484,6 +516,87 @@ export function createSalesforceConnector(
484
516
  };
485
517
  }
486
518
 
519
+ // Resolve-first net-new lead creation (the `enrich acquire` path). Re-resolves
520
+ // on matchKey/matchValue at apply time — search is the source of truth — and
521
+ // creates only on a confirmed miss, so concurrent writers never get duplicated.
522
+ // Mirrors the HubSpot connector's createRecord contract.
523
+ async function createRecord(operation: PatchOperation): Promise<PatchOperationResult> {
524
+ const payload = operation.afterValue as CreateRecordPayload | undefined;
525
+ if (!payload || typeof payload !== "object" || !payload.properties) {
526
+ return { operationId: operation.id, status: "skipped", detail: "create_record needs a CreateRecordPayload afterValue." };
527
+ }
528
+ if (operation.objectType !== "contact" && operation.objectType !== "account") {
529
+ return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and accounts." };
530
+ }
531
+ const matchValue = String(payload.matchValue ?? "").trim();
532
+ if (!matchValue) {
533
+ return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
534
+ }
535
+
536
+ if (operation.objectType === "account") {
537
+ const resolved = await resolveOrCreateAccountByName(matchValue);
538
+ if ("ambiguous" in resolved) {
539
+ return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — ${resolved.ambiguous} accounts named "${matchValue}". Not creating.` };
540
+ }
541
+ return { operationId: operation.id, status: "applied", detail: `Resolved/created account "${matchValue}" (${resolved.id}).`, providerData: { id: resolved.id, created: resolved.createdNew } };
542
+ }
543
+
544
+ // contact — resolve-first on the dedupe key (email by default). matchKey is
545
+ // canonical; translate it to the Salesforce field before the SOQL search.
546
+ const matchKey = payload.matchKey || "email";
547
+ const searchField = mappedField(mappings, "contacts", matchKey, SALESFORCE_DEFAULT_FIELD_MAPPINGS.contacts[matchKey] ?? matchKey);
548
+ const matchKeyLower = `${matchKey}:${matchValue.toLowerCase()}`;
549
+ if (createdContactsByMatch.has(matchKeyLower)) {
550
+ return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: createdContactsByMatch.get(matchKeyLower), existing: true } };
551
+ }
552
+ const soqlValue = matchValue.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
553
+ const existing = await query(`SELECT Id FROM Contact WHERE ${searchField} = '${soqlValue}' LIMIT 5`);
554
+ if (existing.length > 0) {
555
+ const existingId = String(existing[0].Id);
556
+ createdContactsByMatch.set(matchKeyLower, existingId);
557
+ return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already exists (${existing.map((r) => String(r.Id)).join(", ")}); resolve-first declined to create.`, providerData: { id: existingId, existing: true } };
558
+ }
559
+
560
+ const fields: Record<string, string> = { ...payload.properties };
561
+ // Stamp ownership at create time so the lead is never born ownerless. The
562
+ // canonical ownerId maps to Salesforce's OwnerId.
563
+ if (payload.ownerId) {
564
+ const ownerField = mappedField(mappings, "contacts", "ownerId", SALESFORCE_DEFAULT_FIELD_MAPPINGS.contacts.ownerId ?? "OwnerId");
565
+ if (!fields[ownerField]) fields[ownerField] = String(payload.ownerId);
566
+ }
567
+ const created = await request(`/services/data/${apiVersion}/sobjects/Contact`, {
568
+ method: "POST",
569
+ body: JSON.stringify(fields),
570
+ });
571
+ const contactId = String(created.id);
572
+ createdContactsByMatch.set(matchKeyLower, contactId);
573
+
574
+ let companyNote = "";
575
+ if (payload.associateCompanyName) {
576
+ try {
577
+ const resolved = await resolveOrCreateAccountByName(payload.associateCompanyName);
578
+ if ("ambiguous" in resolved) {
579
+ companyNote = ` Account "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
580
+ } else {
581
+ await request(`/services/data/${apiVersion}/sobjects/Contact/${encodeURIComponent(contactId)}`, {
582
+ method: "PATCH",
583
+ body: JSON.stringify({ AccountId: resolved.id }),
584
+ });
585
+ companyNote = ` Linked to account "${payload.associateCompanyName}" (${resolved.id}).`;
586
+ }
587
+ } catch (error) {
588
+ // Association is best-effort — the contact create already succeeded.
589
+ companyNote = ` (account link failed: ${error instanceof Error ? error.message : String(error)})`;
590
+ }
591
+ }
592
+ return {
593
+ operationId: operation.id,
594
+ status: "applied",
595
+ detail: `Created contact ${matchKey}=${matchValue} (${contactId}).${companyNote}`,
596
+ providerData: { id: contactId, created: true },
597
+ };
598
+ }
599
+
487
600
  async function applyOperation(operation: PatchOperation): Promise<PatchOperationResult> {
488
601
  try {
489
602
  switch (operation.operation) {
@@ -501,33 +614,15 @@ export function createSalesforceConnector(
501
614
  if (!name) {
502
615
  return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
503
616
  }
504
- const nameKey = name.toLowerCase();
505
- let accountId = createdAccountsByName.get(nameKey);
506
- let createdNew = false;
507
- if (!accountId) {
508
- const soqlName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
509
- const matches = await query(
510
- `SELECT Id FROM Account WHERE Name = '${soqlName}' LIMIT 3`,
511
- );
512
- if (matches.length > 1) {
513
- return {
514
- operationId: operation.id,
515
- status: "skipped",
516
- detail: `create:${name} is ambiguous — ${matches.length} accounts already named "${name}". Link an explicit account id instead.`,
517
- };
518
- }
519
- if (matches.length === 1) {
520
- accountId = String(matches[0].Id);
521
- } else {
522
- const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
523
- method: "POST",
524
- body: JSON.stringify({ Name: name }),
525
- });
526
- accountId = String(created.id);
527
- createdNew = true;
528
- }
529
- createdAccountsByName.set(nameKey, accountId);
617
+ const resolved = await resolveOrCreateAccountByName(name);
618
+ if ("ambiguous" in resolved) {
619
+ return {
620
+ operationId: operation.id,
621
+ status: "skipped",
622
+ detail: `create:${name} is ambiguous — ${resolved.ambiguous} accounts already named "${name}". Link an explicit account id instead.`,
623
+ };
530
624
  }
625
+ const { id: accountId, createdNew } = resolved;
531
626
  const result = await setField({ ...operation, operation: "set_field", afterValue: accountId });
532
627
  return result.status === "applied"
533
628
  ? {
@@ -547,6 +642,8 @@ export function createSalesforceConnector(
547
642
  return await mergeRecords(operation);
548
643
  case "archive_record":
549
644
  return await archiveRecord(operation);
645
+ case "create_record":
646
+ return await createRecord(operation);
550
647
  default:
551
648
  return {
552
649
  operationId: operation.id,
package/src/enrich.ts CHANGED
@@ -139,7 +139,7 @@ const MATCH_KEYS: Record<EnrichObjectType, string[]> = {
139
139
  };
140
140
 
141
141
  /** API source ids the MVP can pull from. */
142
- export const SUPPORTED_API_SOURCES = ["apollo", "explorium", "pipe0"];
142
+ export const SUPPORTED_API_SOURCES = ["apollo", "explorium", "pipe0", "linkedin"];
143
143
 
144
144
  /**
145
145
  * Canonical fields enrich may target, plus the HubSpot property spellings the
package/src/icp.ts CHANGED
@@ -161,11 +161,15 @@ export type IcpFit = { score: number; reasons: string[] };
161
161
  * Firmographics aren't re-scored here — discovery already filtered on them.
162
162
  */
163
163
  export function scoreProspectAgainstIcp(
164
- prospect: { jobTitle?: string; jobLevel?: string; jobDepartment?: string },
164
+ prospect: { jobTitle?: string; jobLevel?: string; jobDepartment?: string; headline?: string },
165
165
  icp: Icp,
166
166
  ): IcpFit {
167
167
  const reasons: string[] = [];
168
- const title = (prospect.jobTitle ?? "").toLowerCase();
168
+ // Match title keywords across BOTH the formal title and the headline: on
169
+ // LinkedIn-sourced prospects the role signal often lives only in the headline
170
+ // (e.g. position "Founder" but headline "… | RevOps | …"), so scoring on the
171
+ // title alone would miss genuine in-persona leads.
172
+ const title = `${prospect.jobTitle ?? ""} ${prospect.headline ?? ""}`.trim().toLowerCase();
169
173
  const keywords = (icp.persona.titleKeywords ?? []).map((k) => k.toLowerCase());
170
174
  const levels = (icp.persona.jobLevels ?? []).map((l) => l.toLowerCase());
171
175
  const depts = (icp.persona.departments ?? []).map((d) => d.toLowerCase());