fullstackgtm 0.52.4 → 0.53.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,38 @@ 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.1] — 2026-07-11
11
+
12
+ ### Changed
13
+
14
+ - Human-readable `enrich acquire` output shows compact lead cards by default
15
+ before the governed plan preview; `--verbose` remains the full forensic dump.
16
+ - Acquisition budget counters are labeled as applied-create policy headroom,
17
+ explicitly distinguished from market size, and no longer duplicated by a
18
+ second gauge in compact output.
19
+
20
+ ### Fixed
21
+
22
+ - Multi-line progress renderers reserve their terminal rows before painting,
23
+ preventing partial spinner frames from scrolling into final output.
24
+
25
+ ## [0.53.0] — 2026-07-11
26
+
27
+ ### Added
28
+
29
+ - Clay acquisition progressively relaxes over-constrained searches through
30
+ exact, account-first, persona-first, title-first, and function-first routes;
31
+ every broader candidate still passes through local ICP fit scoring.
32
+
33
+ ### Changed
34
+
35
+ - Clay geography translation expands broad regions such as North America,
36
+ Europe, and APAC into valid country filters and treats global/worldwide as
37
+ an unconstrained geography rather than a fake country.
38
+ - Phrase-style model-derived titles can qualify equivalent buyer functions,
39
+ allowing roles such as `VP, Global Brand Strategy` to match `Head of Brand`
40
+ without weakening the configured fit threshold.
41
+
10
42
  ## [0.52.4] — 2026-07-11
11
43
 
12
44
  ### Fixed
@@ -17,13 +17,13 @@ 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";
24
24
  import { providerKey } from "./tam.js";
25
25
  import { unknownSubcommandError } from "./suggest.js";
26
- import { colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint } from "./ui.js";
26
+ import { box, colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint, truncateToWidth } from "./ui.js";
27
27
  import { compactPlan, verbosePlanRequested } from "./planOutput.js";
28
28
  /**
29
29
  * The enrich layer: governed append/refresh of third-party data (Apollo pull,
@@ -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 " +
@@ -760,8 +785,8 @@ function acquireGaugeLine(headroom, budget, p) {
760
785
  const painter = fraction >= 0.9 ? p.red : fraction >= 0.7 ? p.yellow : p.green;
761
786
  parts.push(`${p.dim(label)} ${painter(formatBar(fraction, 10))} ${fmt(used)}/${fmt(cap)}`);
762
787
  };
763
- segment("records/day", headroom.records.day, budget.records?.perDay, String);
764
- segment("records/mo", headroom.records.month, budget.records?.perMonth, String);
788
+ segment("applied/day", headroom.records.day, budget.records?.perDay, String);
789
+ segment("applied/mo", headroom.records.month, budget.records?.perMonth, String);
765
790
  segment("spend/day", headroom.spendUsd.day, budget.spend?.perDay, (value) => `$${value.toFixed(2)}`);
766
791
  segment("spend/mo", headroom.spendUsd.month, budget.spend?.perMonth, (value) => `$${value.toFixed(2)}`);
767
792
  return parts.length > 0 ? parts.join(" · ") : null;
@@ -770,10 +795,33 @@ function formatAcquireMeter(headroom, costPerRecord) {
770
795
  const n = (v) => (v === null ? "∞" : String(v));
771
796
  const money = (v) => (v === null ? "∞" : `$${v.toFixed(2)}`);
772
797
  const max = headroom.maxRecords === null ? "unlimited" : `${headroom.maxRecords}`;
773
- return (`Acquire metercreatable now: ${max} lead(s). ` +
774
- `Records left: ${n(headroom.records.day)}/day, ${n(headroom.records.month)}/month · ` +
775
- `Spend left: ${money(headroom.spendUsd.day)}/day, ${money(headroom.spendUsd.month)}/month ` +
776
- `(≈$${costPerRecord.toFixed(2)}/lead).`);
798
+ return `Apply budget (not market size) up to ${max} more create(s) may be applied now; ` +
799
+ `${n(headroom.records.day)} left today, ${n(headroom.records.month)} this month · ` +
800
+ `spend headroom ${money(headroom.spendUsd.day)}/day, ${money(headroom.spendUsd.month)}/month ` +
801
+ `(≈$${costPerRecord.toFixed(2)}/lead). Dry runs do not consume this budget.`;
802
+ }
803
+ function renderAcquireLeadCards(result) {
804
+ const p = paint(colorEnabled(process.stdout));
805
+ const evidence = new Map((result.plan.evidence ?? []).map((item) => [item.id, item]));
806
+ return result.plan.operations.map((operation, index) => {
807
+ const payload = operation.afterValue;
808
+ const props = payload.properties ?? {};
809
+ const name = [props.firstname, props.lastname].filter(Boolean).join(" ") || payload.matchValue;
810
+ const title = props.jobtitle || "Title unavailable";
811
+ const company = props.company || payload.associateCompanyName || "Company unavailable";
812
+ let fit;
813
+ const item = operation.evidenceIds?.[0] ? evidence.get(operation.evidenceIds[0]) : undefined;
814
+ try {
815
+ fit = Number(JSON.parse(item?.text ?? "{}").fitScore);
816
+ }
817
+ catch { /* malformed evidence remains display-only */ }
818
+ const lines = [
819
+ truncateToWidth(name, 84),
820
+ truncateToWidth(`${title} · ${company}`, 84),
821
+ truncateToWidth([payload.associateCompanyDomain, props.hs_linkedin_url].filter(Boolean).join(" · "), 84),
822
+ ];
823
+ return box(lines, p, `Lead ${index + 1}/${result.plan.operations.length}${Number.isFinite(fit) ? ` · ${Math.round((fit ?? 0) * 100)}% fit` : ""}`).join("\n");
824
+ }).join("\n");
777
825
  }
778
826
  function printAcquireOutput(options) {
779
827
  const { args, result, meter, meterLine, gaugeLine, saved, planSaved, hostedPlanUrl } = options;
@@ -802,15 +850,13 @@ function printAcquireOutput(options) {
802
850
  console.log(` Plan ${hostedPlanUrl}`);
803
851
  }
804
852
  else if (planSaved || !saved) {
853
+ if (result.plan.operations.length > 0)
854
+ console.log(renderAcquireLeadCards(result));
805
855
  console.log(compactPlan(result.plan, { saved: planSaved }));
806
856
  console.log(meterLine);
807
- if (gaugeLine)
808
- console.log(gaugeLine);
809
857
  }
810
858
  else {
811
859
  console.log(meterLine);
812
- if (gaugeLine)
813
- console.log(gaugeLine);
814
860
  console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
815
861
  }
816
862
  if (planSaved) {
package/dist/cli/ui.js CHANGED
@@ -266,6 +266,12 @@ export function createChecklist(items, stream = process.stderr, env = process.en
266
266
  // newline on every repaint can scroll the old top row into permanent
267
267
  // history when the board sits at the bottom of the terminal, producing
268
268
  // apparent duplicate/errored frames in terminal captures.
269
+ // Reserve the board's rows before the first paint. Writing populated rows
270
+ // directly at the terminal bottom can scroll the first rows into history,
271
+ // after which cursor-up repaints target the wrong lines and leave fragments.
272
+ if (painted === 0 && lines.length > 1) {
273
+ stream.write(`${"\n".repeat(lines.length - 1)}\u001b[${lines.length - 1}A`);
274
+ }
269
275
  const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
270
276
  stream.write(`${up}${lines.map((line) => `\u001b[2K${line}`).join("\n")}`);
271
277
  painted = lines.length;
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.1",
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,14 +18,14 @@ 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
- import type { CanonicalGtmSnapshot } from "../types.ts";
23
+ import type { CanonicalGtmSnapshot, CreateRecordPayload } from "../types.ts";
24
24
  import { isSpoolPath, readSpoolPath } from "../spoolFiles.ts";
25
25
  import { isOptionValue, loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.ts";
26
26
  import { providerKey } from "./tam.ts";
27
27
  import { unknownSubcommandError } from "./suggest.ts";
28
- import { colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint, type Paint } from "./ui.ts";
28
+ import { box, colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint, truncateToWidth, type Paint } from "./ui.ts";
29
29
  import { compactPlan, verbosePlanRequested } from "./planOutput.ts";
30
30
  import type { AcquireBudget } from "../acquireMeter.ts";
31
31
 
@@ -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. ` +
@@ -877,8 +903,8 @@ function acquireGaugeLine(headroom: AcquireRemaining, budget: AcquireBudget, p:
877
903
  const painter = fraction >= 0.9 ? p.red : fraction >= 0.7 ? p.yellow : p.green;
878
904
  parts.push(`${p.dim(label)} ${painter(formatBar(fraction, 10))} ${fmt(used)}/${fmt(cap)}`);
879
905
  };
880
- segment("records/day", headroom.records.day, budget.records?.perDay, String);
881
- segment("records/mo", headroom.records.month, budget.records?.perMonth, String);
906
+ segment("applied/day", headroom.records.day, budget.records?.perDay, String);
907
+ segment("applied/mo", headroom.records.month, budget.records?.perMonth, String);
882
908
  segment("spend/day", headroom.spendUsd.day, budget.spend?.perDay, (value) => `$${value.toFixed(2)}`);
883
909
  segment("spend/mo", headroom.spendUsd.month, budget.spend?.perMonth, (value) => `$${value.toFixed(2)}`);
884
910
  return parts.length > 0 ? parts.join(" · ") : null;
@@ -888,12 +914,31 @@ function formatAcquireMeter(headroom: AcquireRemaining, costPerRecord: number):
888
914
  const n = (v: number | null) => (v === null ? "∞" : String(v));
889
915
  const money = (v: number | null) => (v === null ? "∞" : `$${v.toFixed(2)}`);
890
916
  const max = headroom.maxRecords === null ? "unlimited" : `${headroom.maxRecords}`;
891
- return (
892
- `Acquire meter creatable now: ${max} lead(s). ` +
893
- `Records left: ${n(headroom.records.day)}/day, ${n(headroom.records.month)}/month · ` +
894
- `Spend left: ${money(headroom.spendUsd.day)}/day, ${money(headroom.spendUsd.month)}/month ` +
895
- `(≈$${costPerRecord.toFixed(2)}/lead).`
896
- );
917
+ return `Apply budget (not market size) — up to ${max} more create(s) may be applied now; ` +
918
+ `${n(headroom.records.day)} left today, ${n(headroom.records.month)} this month · ` +
919
+ `spend headroom ${money(headroom.spendUsd.day)}/day, ${money(headroom.spendUsd.month)}/month ` +
920
+ `(≈$${costPerRecord.toFixed(2)}/lead). Dry runs do not consume this budget.`;
921
+ }
922
+
923
+ function renderAcquireLeadCards(result: ReturnType<typeof buildAcquirePlan>): string {
924
+ const p = paint(colorEnabled(process.stdout));
925
+ const evidence = new Map((result.plan.evidence ?? []).map((item) => [item.id, item]));
926
+ return result.plan.operations.map((operation, index) => {
927
+ const payload = operation.afterValue as CreateRecordPayload;
928
+ const props = payload.properties ?? {};
929
+ const name = [props.firstname, props.lastname].filter(Boolean).join(" ") || payload.matchValue;
930
+ const title = props.jobtitle || "Title unavailable";
931
+ const company = props.company || payload.associateCompanyName || "Company unavailable";
932
+ let fit: number | undefined;
933
+ const item = operation.evidenceIds?.[0] ? evidence.get(operation.evidenceIds[0]) : undefined;
934
+ try { fit = Number((JSON.parse(item?.text ?? "{}") as { fitScore?: unknown }).fitScore); } catch { /* malformed evidence remains display-only */ }
935
+ const lines = [
936
+ truncateToWidth(name, 84),
937
+ truncateToWidth(`${title} · ${company}`, 84),
938
+ truncateToWidth([payload.associateCompanyDomain, props.hs_linkedin_url].filter(Boolean).join(" · "), 84),
939
+ ];
940
+ return box(lines, p, `Lead ${index + 1}/${result.plan.operations.length}${Number.isFinite(fit) ? ` · ${Math.round((fit ?? 0) * 100)}% fit` : ""}`).join("\n");
941
+ }).join("\n");
897
942
  }
898
943
 
899
944
  function printAcquireOutput(options: {
@@ -933,12 +978,11 @@ function printAcquireOutput(options: {
933
978
  console.log(` Budget ${gaugeLine ?? meterLine}`);
934
979
  if (hostedPlanUrl) console.log(` Plan ${hostedPlanUrl}`);
935
980
  } else if (planSaved || !saved) {
981
+ if (result.plan.operations.length > 0) console.log(renderAcquireLeadCards(result));
936
982
  console.log(compactPlan(result.plan, { saved: planSaved }));
937
983
  console.log(meterLine);
938
- if (gaugeLine) console.log(gaugeLine);
939
984
  } else {
940
985
  console.log(meterLine);
941
- if (gaugeLine) console.log(gaugeLine);
942
986
  console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
943
987
  }
944
988
 
package/src/cli/ui.ts CHANGED
@@ -328,6 +328,12 @@ export function createChecklist(
328
328
  // newline on every repaint can scroll the old top row into permanent
329
329
  // history when the board sits at the bottom of the terminal, producing
330
330
  // apparent duplicate/errored frames in terminal captures.
331
+ // Reserve the board's rows before the first paint. Writing populated rows
332
+ // directly at the terminal bottom can scroll the first rows into history,
333
+ // after which cursor-up repaints target the wrong lines and leave fragments.
334
+ if (painted === 0 && lines.length > 1) {
335
+ stream.write(`${"\n".repeat(lines.length - 1)}\u001b[${lines.length - 1}A`);
336
+ }
331
337
  const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
332
338
  stream.write(`${up}${lines.map((line) => `\u001b[2K${line}`).join("\n")}`);
333
339
  painted = lines.length;
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}"`);