fullstackgtm 0.52.4 → 0.53.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,23 @@ The path to 1.0 is planned in [docs/roadmap-to-1.0.md](https://github.com/fullst
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.53.0] — 2026-07-11
11
+
12
+ ### Added
13
+
14
+ - Clay acquisition progressively relaxes over-constrained searches through
15
+ exact, account-first, persona-first, title-first, and function-first routes;
16
+ every broader candidate still passes through local ICP fit scoring.
17
+
18
+ ### Changed
19
+
20
+ - Clay geography translation expands broad regions such as North America,
21
+ Europe, and APAC into valid country filters and treats global/worldwide as
22
+ an unconstrained geography rather than a fake country.
23
+ - Phrase-style model-derived titles can qualify equivalent buyer functions,
24
+ allowing roles such as `VP, Global Brand Strategy` to match `Head of Brand`
25
+ without weakening the configured fit threshold.
26
+
10
27
  ## [0.52.4] — 2026-07-11
11
28
 
12
29
  ### Fixed
@@ -17,7 +17,7 @@ import { uploadHostedPatchPlan } from "../hostedPatchPlan.js";
17
17
  import { progressReporter, reportCounts, reportEvent } from "../runReport.js";
18
18
  import { ACQUIRE_STAGES, composeListeners, createProgressEmitter } from "../progress.js";
19
19
  import { createLinkedInProvider, discoverLinkedInProspects } from "../acquireLinkedIn.js";
20
- import { fitThreshold, icpToClayPeopleFilters, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp } from "../icp.js";
20
+ import { clayPeopleFilterRoutes, fitThreshold, icpToClayPeopleFilters, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp } from "../icp.js";
21
21
  import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords } from "../enrichApollo.js";
22
22
  import { isSpoolPath, readSpoolPath } from "../spoolFiles.js";
23
23
  import { isOptionValue, loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.js";
@@ -508,7 +508,7 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
508
508
  if ((disc.provider === "linkedin" || disc.provider === "heyreach") && !listId) {
509
509
  throw new Error("enrich acquire --source linkedin needs a HeyReach lead-list id: pass --list <id> or set acquire.discovery.linkedin.listId.");
510
510
  }
511
- const filters = disc.provider === "explorium"
511
+ let filters = disc.provider === "explorium"
512
512
  ? (icp ? icpToExploriumFilters(icp) : (disc.filters ?? {}))
513
513
  : disc.provider === "pipe0"
514
514
  ? (icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {}))
@@ -559,6 +559,9 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
559
559
  const prospects = [];
560
560
  const crmKeys = crmContactKeys(snapshot);
561
561
  const runSeen = new Set(seen);
562
+ const clayRoutes = disc.provider === "clay" && icp ? clayPeopleFilterRoutes(icp) : [];
563
+ let clayRouteIndex = 0;
564
+ const allowClayFallback = !resume;
562
565
  // Pull successive pages until we have enough fresh candidates or hit the
563
566
  // bounded scan ceiling. Page size follows the remaining target, so advancing
564
567
  // a cursor never silently discards unused candidates from the final page.
@@ -578,18 +581,28 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
578
581
  exhausted = result.nextCursor === null;
579
582
  }
580
583
  else if (disc.provider === "clay") {
581
- if (!cursor) {
582
- progress.note("creating Clay people-search iterator");
583
- cursor = await createClaySearch({
584
- apiKey: providerKey("clay"), sourceType: "people", filters,
585
- });
584
+ while (true) {
585
+ if (!cursor) {
586
+ const route = clayRoutes[clayRouteIndex];
587
+ if (route)
588
+ filters = route.filters;
589
+ progress.note(`creating Clay people-search iterator · ${route?.id ?? "configured"}`);
590
+ cursor = await createClaySearch({ apiKey: providerKey("clay"), sourceType: "people", filters });
591
+ }
592
+ const result = await runClayPeopleSearchPage({ apiKey: providerKey("clay"), searchId: cursor, limit: requestSize });
593
+ page = result.prospects;
594
+ offset += page.length;
595
+ exhausted = !result.hasMore;
596
+ if (page.length > 0 || !allowClayFallback || clayRouteIndex >= clayRoutes.length - 1)
597
+ break;
598
+ const failed = clayRoutes[clayRouteIndex]?.id ?? "configured";
599
+ clayRouteIndex += 1;
600
+ const next = clayRoutes[clayRouteIndex];
601
+ progress.note(`Clay ${failed} route returned 0 · trying ${next.id}`);
602
+ cursor = null;
603
+ offset = 0;
604
+ exhausted = false;
586
605
  }
587
- const result = await runClayPeopleSearchPage({
588
- apiKey: providerKey("clay"), searchId: cursor, limit: requestSize,
589
- });
590
- page = result.prospects;
591
- offset += page.length;
592
- exhausted = !result.hasMore;
593
606
  }
594
607
  else if (disc.provider === "explorium") {
595
608
  const pageNumber = offset + 1;
@@ -639,12 +652,24 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
639
652
  runSeen.add(key);
640
653
  }
641
654
  progress.note(`${discovered} scanned · ${qualified} qualified · ${prospects.length}/${targetFresh} fresh`);
655
+ if (disc.provider === "clay" && allowClayFallback && exhausted &&
656
+ prospects.length < targetFresh && clayRouteIndex < clayRoutes.length - 1 && discovered < scanLimit) {
657
+ const failed = clayRoutes[clayRouteIndex]?.id ?? "configured";
658
+ clayRouteIndex += 1;
659
+ progress.note(`Clay ${failed} route did not fill the target · trying ${clayRoutes[clayRouteIndex].id}`);
660
+ cursor = null;
661
+ offset = 0;
662
+ exhausted = false;
663
+ }
642
664
  // Explorium is page-number based. Only advance after consuming every fresh
643
665
  // candidate on that page; otherwise the next run safely re-reads the page
644
666
  // and CRM/seen dedupe exposes the remainder instead of losing it.
645
667
  if (exploriumPage !== null && partition.fresh.length <= remainingTarget)
646
668
  offset = exploriumPage;
647
669
  }
670
+ if (disc.provider === "clay" && clayRouteIndex > 0 && discovered > 0) {
671
+ console.error(`Clay discovery relaxed to ${clayRoutes[clayRouteIndex].id} after narrower routes returned 0; all candidates still passed through local ICP fit scoring.`);
672
+ }
648
673
  if (discovered === 0 && !resume) {
649
674
  console.error(`enrich acquire: ${disc.provider} discovered 0 prospects for this ICP — the plan will be empty. ` +
650
675
  "This is usually an over-narrow filter, not an empty market: check the ICP's industry and seniority " +
package/dist/icp.d.ts CHANGED
@@ -106,6 +106,14 @@ export declare function icpToTheirStackFilters(icp: Icp): {
106
106
  export declare function icpToCrustdataFilters(icp: Icp): Record<string, unknown>;
107
107
  /** Clay people-search filters using only fields confirmed in the live catalog. */
108
108
  export declare function icpToClayPeopleFilters(icp: Icp): Record<string, unknown>;
109
+ export type ClayPeopleFilterRoute = {
110
+ id: "exact" | "account-first" | "persona-first" | "title-first" | "function-first";
111
+ filters: Record<string, unknown>;
112
+ };
113
+ /** Progressive Clay searches for an ICP. Each fallback removes only
114
+ * provider-side AND constraints; the canonical ICP still scores every person
115
+ * locally, so broader discovery does not become broader qualification. */
116
+ export declare function clayPeopleFilterRoutes(icp: Icp): ClayPeopleFilterRoute[];
109
117
  export type IcpFit = {
110
118
  score: number;
111
119
  reasons: string[];
package/dist/icp.js CHANGED
@@ -235,6 +235,12 @@ const CLAY_SENIORITY = {
235
235
  founder: "founder",
236
236
  senior: "senior",
237
237
  };
238
+ const CLAY_REGION_COUNTRIES = {
239
+ "north america": ["United States", "Canada"],
240
+ europe: ["United Kingdom", "Germany", "France", "Netherlands", "Spain", "Italy", "Ireland", "Sweden", "Switzerland"],
241
+ "asia pacific": ["Australia", "New Zealand", "Singapore", "Japan", "India"],
242
+ apac: ["Australia", "New Zealand", "Singapore", "Japan", "India"],
243
+ };
238
244
  /** Clay people-search filters using only fields confirmed in the live catalog. */
239
245
  export function icpToClayPeopleFilters(icp) {
240
246
  const filters = {};
@@ -247,7 +253,14 @@ export function icpToClayPeopleFilters(icp) {
247
253
  if (sizes.length)
248
254
  filters.company_sizes = sizes;
249
255
  if (icp.firmographics.geos?.length) {
250
- filters.location_countries_include = icp.firmographics.geos.map((geo) => COUNTRY_NAMES[geo.toLowerCase()] ?? geo);
256
+ const countries = [...new Set(icp.firmographics.geos.flatMap((geo) => {
257
+ const normalized = geo.toLowerCase().trim();
258
+ if (normalized === "global" || normalized === "worldwide")
259
+ return [];
260
+ return CLAY_REGION_COUNTRIES[normalized] ?? [COUNTRY_NAMES[normalized] ?? geo];
261
+ }))];
262
+ if (countries.length)
263
+ filters.location_countries_include = countries;
251
264
  }
252
265
  // Clay accepts a controlled industry catalog. Never invent a title-cased
253
266
  // enum for model-authored labels: unsupported values make the entire search
@@ -258,6 +271,35 @@ export function icpToClayPeopleFilters(icp) {
258
271
  filters.company_industries_include = industries;
259
272
  return filters;
260
273
  }
274
+ const ROLE_STOPWORDS = new Set(["chief", "head", "director", "manager", "senior", "vice", "president", "vp", "cmo", "officer", "lead", "of", "and", "the"]);
275
+ function roleKeywords(icp) {
276
+ return [...new Set([
277
+ ...(icp.persona.departments ?? []),
278
+ ...(icp.persona.titleKeywords ?? []).flatMap((title) => title.split(/[^a-z0-9]+/i)),
279
+ ].map((word) => word.trim().toLowerCase()).filter((word) => word.length >= 4 && !ROLE_STOPWORDS.has(word)))];
280
+ }
281
+ /** Progressive Clay searches for an ICP. Each fallback removes only
282
+ * provider-side AND constraints; the canonical ICP still scores every person
283
+ * locally, so broader discovery does not become broader qualification. */
284
+ export function clayPeopleFilterRoutes(icp) {
285
+ const exact = icpToClayPeopleFilters(icp);
286
+ const without = (...keys) => Object.fromEntries(Object.entries(exact).filter(([key]) => !keys.includes(key)));
287
+ const candidates = [
288
+ { id: "exact", filters: exact },
289
+ { id: "account-first", filters: without("job_title_seniority_levels_v2") },
290
+ { id: "persona-first", filters: without("company_industries_include", "company_sizes") },
291
+ { id: "title-first", filters: without("company_industries_include", "company_sizes", "job_title_seniority_levels_v2", "location_countries_include") },
292
+ { id: "function-first", filters: { job_title_keywords: roleKeywords(icp) } },
293
+ ];
294
+ const seen = new Set();
295
+ return candidates.filter((route) => {
296
+ const fingerprint = JSON.stringify(route.filters);
297
+ if (seen.has(fingerprint))
298
+ return false;
299
+ seen.add(fingerprint);
300
+ return true;
301
+ });
302
+ }
261
303
  const ACRONYMS = new Set(["cro", "coo", "ceo", "cfo", "vp", "crm", "gtm", "saas"]);
262
304
  function titleCase(value) {
263
305
  return value
@@ -284,7 +326,7 @@ export function scoreProspectAgainstIcp(prospect, icp) {
284
326
  let weightSum = 0;
285
327
  if (keywords.length) {
286
328
  weightSum += 0.6;
287
- const hit = keywords.find((k) => title.includes(k));
329
+ const hit = keywords.find((k) => title.includes(k)) ?? roleKeywords(icp).find((keyword) => title.includes(keyword));
288
330
  if (hit) {
289
331
  score += 0.6;
290
332
  reasons.push(`title matches ICP keyword "${hit}"`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.52.4",
3
+ "version": "0.53.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)",
package/src/cli/enrich.ts CHANGED
@@ -18,7 +18,7 @@ import { uploadHostedPatchPlan } from "../hostedPatchPlan.ts";
18
18
  import { progressReporter, reportCounts, reportEvent } from "../runReport.ts";
19
19
  import { ACQUIRE_STAGES, composeListeners, createProgressEmitter } from "../progress.ts";
20
20
  import { createLinkedInProvider, discoverLinkedInProspects } from "../acquireLinkedIn.ts";
21
- import { fitThreshold, icpToClayPeopleFilters, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp, type Icp } from "../icp.ts";
21
+ import { clayPeopleFilterRoutes, fitThreshold, icpToClayPeopleFilters, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp, type Icp } from "../icp.ts";
22
22
  import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, type ApolloPullKey } from "../enrichApollo.ts";
23
23
  import type { CanonicalGtmSnapshot } from "../types.ts";
24
24
  import { isSpoolPath, readSpoolPath } from "../spoolFiles.ts";
@@ -589,7 +589,7 @@ async function acquireFromApi(
589
589
  "enrich acquire --source linkedin needs a HeyReach lead-list id: pass --list <id> or set acquire.discovery.linkedin.listId.",
590
590
  );
591
591
  }
592
- const filters = disc.provider === "explorium"
592
+ let filters = disc.provider === "explorium"
593
593
  ? (icp ? icpToExploriumFilters(icp) : (disc.filters ?? {}))
594
594
  : disc.provider === "pipe0"
595
595
  ? (icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {}))
@@ -648,6 +648,9 @@ async function acquireFromApi(
648
648
  const prospects: Prospect[] = [];
649
649
  const crmKeys = crmContactKeys(snapshot);
650
650
  const runSeen = new Set(seen);
651
+ const clayRoutes = disc.provider === "clay" && icp ? clayPeopleFilterRoutes(icp) : [];
652
+ let clayRouteIndex = 0;
653
+ const allowClayFallback = !resume;
651
654
 
652
655
  // Pull successive pages until we have enough fresh candidates or hit the
653
656
  // bounded scan ceiling. Page size follows the remaining target, so advancing
@@ -671,18 +674,26 @@ async function acquireFromApi(
671
674
  cursor = result.nextCursor;
672
675
  exhausted = result.nextCursor === null;
673
676
  } else if (disc.provider === "clay") {
674
- if (!cursor) {
675
- progress.note("creating Clay people-search iterator");
676
- cursor = await createClaySearch({
677
- apiKey: providerKey("clay"), sourceType: "people", filters,
678
- });
677
+ while (true) {
678
+ if (!cursor) {
679
+ const route = clayRoutes[clayRouteIndex];
680
+ if (route) filters = route.filters;
681
+ progress.note(`creating Clay people-search iterator · ${route?.id ?? "configured"}`);
682
+ cursor = await createClaySearch({ apiKey: providerKey("clay"), sourceType: "people", filters });
683
+ }
684
+ const result = await runClayPeopleSearchPage({ apiKey: providerKey("clay"), searchId: cursor, limit: requestSize });
685
+ page = result.prospects;
686
+ offset += page.length;
687
+ exhausted = !result.hasMore;
688
+ if (page.length > 0 || !allowClayFallback || clayRouteIndex >= clayRoutes.length - 1) break;
689
+ const failed = clayRoutes[clayRouteIndex]?.id ?? "configured";
690
+ clayRouteIndex += 1;
691
+ const next = clayRoutes[clayRouteIndex];
692
+ progress.note(`Clay ${failed} route returned 0 · trying ${next.id}`);
693
+ cursor = null;
694
+ offset = 0;
695
+ exhausted = false;
679
696
  }
680
- const result = await runClayPeopleSearchPage({
681
- apiKey: providerKey("clay"), searchId: cursor, limit: requestSize,
682
- });
683
- page = result.prospects;
684
- offset += page.length;
685
- exhausted = !result.hasMore;
686
697
  } else if (disc.provider === "explorium") {
687
698
  const pageNumber = offset + 1;
688
699
  exploriumPage = pageNumber;
@@ -729,12 +740,27 @@ async function acquireFromApi(
729
740
  progress.note(
730
741
  `${discovered} scanned · ${qualified} qualified · ${prospects.length}/${targetFresh} fresh`,
731
742
  );
743
+ if (
744
+ disc.provider === "clay" && allowClayFallback && exhausted &&
745
+ prospects.length < targetFresh && clayRouteIndex < clayRoutes.length - 1 && discovered < scanLimit
746
+ ) {
747
+ const failed = clayRoutes[clayRouteIndex]?.id ?? "configured";
748
+ clayRouteIndex += 1;
749
+ progress.note(`Clay ${failed} route did not fill the target · trying ${clayRoutes[clayRouteIndex].id}`);
750
+ cursor = null;
751
+ offset = 0;
752
+ exhausted = false;
753
+ }
732
754
  // Explorium is page-number based. Only advance after consuming every fresh
733
755
  // candidate on that page; otherwise the next run safely re-reads the page
734
756
  // and CRM/seen dedupe exposes the remainder instead of losing it.
735
757
  if (exploriumPage !== null && partition.fresh.length <= remainingTarget) offset = exploriumPage;
736
758
  }
737
759
 
760
+ if (disc.provider === "clay" && clayRouteIndex > 0 && discovered > 0) {
761
+ console.error(`Clay discovery relaxed to ${clayRoutes[clayRouteIndex].id} after narrower routes returned 0; all candidates still passed through local ICP fit scoring.`);
762
+ }
763
+
738
764
  if (discovered === 0 && !resume) {
739
765
  console.error(
740
766
  `enrich acquire: ${disc.provider} discovered 0 prospects for this ICP — the plan will be empty. ` +
package/src/icp.ts CHANGED
@@ -273,6 +273,13 @@ const CLAY_SENIORITY: Record<string, string> = {
273
273
  senior: "senior",
274
274
  };
275
275
 
276
+ const CLAY_REGION_COUNTRIES: Record<string, string[]> = {
277
+ "north america": ["United States", "Canada"],
278
+ europe: ["United Kingdom", "Germany", "France", "Netherlands", "Spain", "Italy", "Ireland", "Sweden", "Switzerland"],
279
+ "asia pacific": ["Australia", "New Zealand", "Singapore", "Japan", "India"],
280
+ apac: ["Australia", "New Zealand", "Singapore", "Japan", "India"],
281
+ };
282
+
276
283
  /** Clay people-search filters using only fields confirmed in the live catalog. */
277
284
  export function icpToClayPeopleFilters(icp: Icp): Record<string, unknown> {
278
285
  const filters: Record<string, unknown> = {};
@@ -282,7 +289,12 @@ export function icpToClayPeopleFilters(icp: Icp): Record<string, unknown> {
282
289
  const sizes = [...new Set((icp.firmographics.employeeBands ?? []).map((band) => CLAY_EMPLOYEE_BAND[band.replace(/,/g, "")]).filter(Boolean))];
283
290
  if (sizes.length) filters.company_sizes = sizes;
284
291
  if (icp.firmographics.geos?.length) {
285
- filters.location_countries_include = icp.firmographics.geos.map((geo) => COUNTRY_NAMES[geo.toLowerCase()] ?? geo);
292
+ const countries = [...new Set(icp.firmographics.geos.flatMap((geo) => {
293
+ const normalized = geo.toLowerCase().trim();
294
+ if (normalized === "global" || normalized === "worldwide") return [];
295
+ return CLAY_REGION_COUNTRIES[normalized] ?? [COUNTRY_NAMES[normalized] ?? geo];
296
+ }))];
297
+ if (countries.length) filters.location_countries_include = countries;
286
298
  }
287
299
  // Clay accepts a controlled industry catalog. Never invent a title-cased
288
300
  // enum for model-authored labels: unsupported values make the entire search
@@ -295,6 +307,38 @@ export function icpToClayPeopleFilters(icp: Icp): Record<string, unknown> {
295
307
  return filters;
296
308
  }
297
309
 
310
+ export type ClayPeopleFilterRoute = { id: "exact" | "account-first" | "persona-first" | "title-first" | "function-first"; filters: Record<string, unknown> };
311
+
312
+ const ROLE_STOPWORDS = new Set(["chief", "head", "director", "manager", "senior", "vice", "president", "vp", "cmo", "officer", "lead", "of", "and", "the"]);
313
+ function roleKeywords(icp: Icp): string[] {
314
+ return [...new Set([
315
+ ...(icp.persona.departments ?? []),
316
+ ...(icp.persona.titleKeywords ?? []).flatMap((title) => title.split(/[^a-z0-9]+/i)),
317
+ ].map((word) => word.trim().toLowerCase()).filter((word) => word.length >= 4 && !ROLE_STOPWORDS.has(word)))];
318
+ }
319
+
320
+ /** Progressive Clay searches for an ICP. Each fallback removes only
321
+ * provider-side AND constraints; the canonical ICP still scores every person
322
+ * locally, so broader discovery does not become broader qualification. */
323
+ export function clayPeopleFilterRoutes(icp: Icp): ClayPeopleFilterRoute[] {
324
+ const exact = icpToClayPeopleFilters(icp);
325
+ const without = (...keys: string[]) => Object.fromEntries(Object.entries(exact).filter(([key]) => !keys.includes(key)));
326
+ const candidates: ClayPeopleFilterRoute[] = [
327
+ { id: "exact", filters: exact },
328
+ { id: "account-first", filters: without("job_title_seniority_levels_v2") },
329
+ { id: "persona-first", filters: without("company_industries_include", "company_sizes") },
330
+ { id: "title-first", filters: without("company_industries_include", "company_sizes", "job_title_seniority_levels_v2", "location_countries_include") },
331
+ { id: "function-first", filters: { job_title_keywords: roleKeywords(icp) } },
332
+ ];
333
+ const seen = new Set<string>();
334
+ return candidates.filter((route) => {
335
+ const fingerprint = JSON.stringify(route.filters);
336
+ if (seen.has(fingerprint)) return false;
337
+ seen.add(fingerprint);
338
+ return true;
339
+ });
340
+ }
341
+
298
342
  const ACRONYMS = new Set(["cro", "coo", "ceo", "cfo", "vp", "crm", "gtm", "saas"]);
299
343
  function titleCase(value: string): string {
300
344
  return value
@@ -332,7 +376,7 @@ export function scoreProspectAgainstIcp(
332
376
 
333
377
  if (keywords.length) {
334
378
  weightSum += 0.6;
335
- const hit = keywords.find((k) => title.includes(k));
379
+ const hit = keywords.find((k) => title.includes(k)) ?? roleKeywords(icp).find((keyword) => title.includes(keyword));
336
380
  if (hit) {
337
381
  score += 0.6;
338
382
  reasons.push(`title matches ICP keyword "${hit}"`);