fullstackgtm 0.49.0 → 0.50.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.
Files changed (78) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/DATA-FLOWS.md +1 -0
  3. package/README.md +48 -0
  4. package/dist/acquireCheckpoint.d.ts +33 -0
  5. package/dist/acquireCheckpoint.js +149 -0
  6. package/dist/acquireLinkedIn.d.ts +2 -0
  7. package/dist/acquireLinkedIn.js +1 -1
  8. package/dist/cli/audit.js +6 -1
  9. package/dist/cli/auth.js +25 -0
  10. package/dist/cli/backfill.js +4 -0
  11. package/dist/cli/call.js +28 -6
  12. package/dist/cli/draft.js +11 -1
  13. package/dist/cli/enrich.js +286 -81
  14. package/dist/cli/fix.js +6 -3
  15. package/dist/cli/help.js +30 -26
  16. package/dist/cli/icp.js +15 -1
  17. package/dist/cli/market.js +14 -1
  18. package/dist/cli/planOutput.d.ts +5 -0
  19. package/dist/cli/planOutput.js +27 -0
  20. package/dist/cli/plans.js +269 -24
  21. package/dist/cli/schedule.js +22 -11
  22. package/dist/cli/shared.d.ts +3 -1
  23. package/dist/cli/shared.js +2 -2
  24. package/dist/cli/signals.js +22 -3
  25. package/dist/cli/tam.js +10 -1
  26. package/dist/cli/ui.d.ts +10 -5
  27. package/dist/cli/ui.js +28 -9
  28. package/dist/connectors/hubspot.d.ts +4 -0
  29. package/dist/connectors/hubspot.js +36 -18
  30. package/dist/connectors/linkedin.d.ts +2 -0
  31. package/dist/connectors/linkedin.js +5 -0
  32. package/dist/connectors/prospectSources.d.ts +23 -0
  33. package/dist/connectors/prospectSources.js +21 -3
  34. package/dist/enrich.d.ts +44 -1
  35. package/dist/hostedAcquireCheckpoint.d.ts +43 -0
  36. package/dist/hostedAcquireCheckpoint.js +129 -0
  37. package/dist/hostedPatchPlan.d.ts +87 -0
  38. package/dist/hostedPatchPlan.js +275 -0
  39. package/dist/icp.js +13 -2
  40. package/dist/index.d.ts +4 -1
  41. package/dist/index.js +3 -0
  42. package/dist/planStore.d.ts +14 -0
  43. package/dist/planStore.js +73 -0
  44. package/dist/progress.d.ts +2 -2
  45. package/dist/progress.js +2 -2
  46. package/docs/api.md +24 -0
  47. package/docs/architecture.md +9 -0
  48. package/package.json +1 -1
  49. package/skills/fullstackgtm/SKILL.md +1 -1
  50. package/src/acquireCheckpoint.ts +186 -0
  51. package/src/acquireLinkedIn.ts +3 -1
  52. package/src/cli/audit.ts +5 -1
  53. package/src/cli/auth.ts +24 -0
  54. package/src/cli/backfill.ts +3 -0
  55. package/src/cli/call.ts +25 -6
  56. package/src/cli/draft.ts +9 -1
  57. package/src/cli/enrich.ts +325 -80
  58. package/src/cli/fix.ts +5 -3
  59. package/src/cli/help.ts +31 -26
  60. package/src/cli/icp.ts +13 -1
  61. package/src/cli/market.ts +12 -1
  62. package/src/cli/planOutput.ts +30 -0
  63. package/src/cli/plans.ts +259 -25
  64. package/src/cli/schedule.ts +21 -15
  65. package/src/cli/shared.ts +2 -1
  66. package/src/cli/signals.ts +22 -3
  67. package/src/cli/tam.ts +8 -1
  68. package/src/cli/ui.ts +31 -13
  69. package/src/connectors/hubspot.ts +41 -17
  70. package/src/connectors/linkedin.ts +6 -0
  71. package/src/connectors/prospectSources.ts +46 -4
  72. package/src/enrich.ts +47 -1
  73. package/src/hostedAcquireCheckpoint.ts +156 -0
  74. package/src/hostedPatchPlan.ts +291 -0
  75. package/src/icp.ts +14 -2
  76. package/src/index.ts +20 -0
  77. package/src/planStore.ts +87 -0
  78. package/src/progress.ts +2 -2
@@ -9,6 +9,7 @@ import { computeMissedFirings, createFileScheduleRunStore, createFileScheduleSto
9
9
  import { runCli } from "../cli.ts";
10
10
  import { isOptionValue, numericOption, option } from "./shared.ts";
11
11
  import { unknownSubcommandError } from "./suggest.ts";
12
+ import { colorEnabled, paint, table } from "./ui.ts";
12
13
 
13
14
 
14
15
  /**
@@ -120,12 +121,19 @@ trigger: manual. status shows next firing and surfaces missed firings
120
121
  console.log('No schedules. Add one with `fullstackgtm schedule add "<command>" --cron "<expr>"`.');
121
122
  return;
122
123
  }
123
- for (const entry of withNext) {
124
- console.log(
125
- `${entry.id} ${entry.enabled ? "on " : "off"} ${entry.cron.padEnd(14)} next ${entry.nextFiring ?? "—"} ${entry.label}: ${entry.argv.join(" ")}`,
126
- );
127
- }
128
- console.log("\nEntries are declarative — `schedule install` materializes enabled ones into the crontab.");
124
+ const p = paint(colorEnabled(process.stdout));
125
+ const verbose = rest.includes("--verbose");
126
+ const rows = [
127
+ ["STATE", "SCHEDULE", "NEXT", ...(verbose ? ["CRON", "COMMAND"] : [])],
128
+ ...withNext.map((entry) => [
129
+ entry.enabled ? "on" : "off",
130
+ `${entry.label} · ${entry.id}`,
131
+ entry.nextFiring ?? "—",
132
+ ...(verbose ? [entry.cron, entry.argv.join(" ")] : []),
133
+ ]),
134
+ ];
135
+ console.log(table(rows, [p.dim, null, null]).join("\n"));
136
+ console.log("\nDeclarative only · run `fullstackgtm schedule install` after changes.");
129
137
  return;
130
138
  }
131
139
 
@@ -272,21 +280,19 @@ trigger: manual. status shows next firing and surfaces missed firings
272
280
  const last = entry.lastRun as { firedAt: string; trigger: string; exitCode: number; noopReason?: string; artifacts: { planIds: string[]; runLabels: string[] } } | null;
273
281
  const streak = entry.streak as { outcome: string; length: number } | null;
274
282
  const missed = entry.missedFirings as string[];
275
- console.log(`${entry.id} ${entry.enabled ? "on " : "off"} ${entry.cron} ${entry.label}: ${(entry.argv as string[]).join(" ")}`);
276
- console.log(` next: ${entry.nextFiring ?? " (disabled)"}`);
283
+ const verbose = rest.includes("--verbose");
284
+ const outcome = !last ? "never run" : last.exitCode === 0 ? (last.noopReason ? `no-op · ${last.noopReason}` : "healthy") : `failed · exit ${last.exitCode}`;
285
+ console.log(`${entry.enabled ? "●" : "○"} ${entry.label} · ${outcome}`);
286
+ console.log(` ${entry.id} · next ${entry.nextFiring ?? "— (disabled)"}`);
287
+ if (verbose) console.log(` ${(entry.argv as string[]).join(" ")} · cron ${entry.cron}`);
277
288
  if (last) {
278
289
  const artifacts = [
279
290
  ...last.artifacts.planIds.map((planId) => `plan ${planId}`),
280
291
  ...last.artifacts.runLabels.map((runLabel) => `run ${runLabel}`),
281
292
  ];
282
- console.log(
283
- ` last: ${last.firedAt} (${last.trigger}) exit ${last.exitCode}` +
284
- (last.noopReason ? ` — no-op: ${last.noopReason}` : "") +
285
- (artifacts.length ? ` — ${artifacts.join(", ")}` : ""),
286
- );
287
- console.log(` streak: ${streak!.length} ${streak!.outcome}(s)`);
293
+ console.log(` last ${last.firedAt} · ${last.trigger} · ${streak!.length} ${streak!.outcome}${streak!.length === 1 ? "" : "s"}${artifacts.length ? ` · ${artifacts.join(", ")}` : ""}`);
288
294
  } else {
289
- console.log(" last: never fired");
295
+ console.log(" last never fired");
290
296
  }
291
297
  if (missed.length > 0) {
292
298
  console.log(
package/src/cli/shared.ts CHANGED
@@ -164,6 +164,7 @@ export async function connectorFor(
164
164
  export async function readSnapshot(
165
165
  args: string[],
166
166
  progress?: ProgressEmitter,
167
+ options: { persistProgress?: boolean } = {},
167
168
  ): Promise<CanonicalGtmSnapshot> {
168
169
  const provider = option(args, "--provider");
169
170
  if (provider) {
@@ -189,7 +190,7 @@ export async function readSnapshot(
189
190
  try {
190
191
  return await connector.fetchSnapshot();
191
192
  } finally {
192
- renderer.done();
193
+ renderer.done({ persist: options.persistProgress });
193
194
  }
194
195
  }
195
196
  if (args.includes("--demo")) {
@@ -27,6 +27,21 @@ function resolveSignalsConfig(args: string[]): SignalsConfig {
27
27
  /** A watchlist entry: an account domain plus optional per-source board tokens. */
28
28
  type WatchlistAccount = { domain: string; boards?: Partial<Record<AtsBoardSource, string>> };
29
29
 
30
+ function concise(value: string, max = 72): string {
31
+ const oneLine = value.replace(/\s+/g, " ").trim();
32
+ return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}…`;
33
+ }
34
+
35
+ function renderSignals(signals: Signal[]): string {
36
+ if (signals.length === 0) return "No signals matched.";
37
+ const lines = [`Fresh signals (${signals.length})`, ""];
38
+ for (const signal of signals) {
39
+ lines.push(`${signal.weight.toFixed(2).padStart(5)} ${signal.accountDomain} · ${signal.bucket}`);
40
+ lines.push(` ${concise(signal.trigger)}`);
41
+ }
42
+ return lines.join("\n");
43
+ }
44
+
30
45
  /**
31
46
  * Resolve the watchlist of accounts to scan. Sources, in precedence order:
32
47
  * - a --watchlist file (JSON array of {domain, boards?} or bare domain strings),
@@ -228,7 +243,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
228
243
  const ranked = [...fresh].sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
229
244
 
230
245
  // Ranked fresh signals to stdout; guidance to stderr.
231
- console.log(JSON.stringify(ranked, null, 2));
246
+ console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(ranked, null, 2) : renderSignals(ranked));
232
247
  console.error(
233
248
  `Fetched ${fetched} candidate signal(s); ${fresh.length} fresh, ${deduped.length} deduped ` +
234
249
  `(window ${config.dedupWindowDays}d). NO plan emitted — signals are read-only re: CRM.`,
@@ -275,7 +290,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
275
290
  return true;
276
291
  });
277
292
  const ranked = filtered.sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
278
- console.log(JSON.stringify(ranked, null, 2));
293
+ console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(ranked, null, 2) : renderSignals(ranked));
279
294
  console.error(`${ranked.length} signal(s)${unjudgedOnly ? " (unjudged)" : ""}.`);
280
295
  return;
281
296
  }
@@ -305,7 +320,11 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
305
320
  const outcomes = await store.listOutcomes();
306
321
  const signalsById = new Map((await store.allSignals()).map((s) => [s.id, s]));
307
322
  const weights = computeWeights(config, outcomes, signalsById);
308
- console.log(JSON.stringify(weights, null, 2));
323
+ if (rest.includes("--json") || rest.includes("--verbose")) {
324
+ console.log(JSON.stringify(weights, null, 2));
325
+ } else {
326
+ console.log(["Learned signal weights", "", ...SIGNAL_BUCKETS.map((bucket) => `${bucket.padEnd(10)} ${weights[bucket].toFixed(4)}`)].join("\n"));
327
+ }
309
328
  if (rest.includes("--explain")) {
310
329
  // Per-bucket config default vs learned + booked/total over credited signals.
311
330
  const booked = new Map<SignalBucket, number>();
package/src/cli/tam.ts CHANGED
@@ -275,8 +275,15 @@ RevOps universe, with real names. --source explorium is a firmographic count onl
275
275
  if (out) {
276
276
  writeFileSync(resolve(process.cwd(), out), md);
277
277
  console.log(`Wrote ${out}.`);
278
- } else {
278
+ } else if (rest.includes("--verbose")) {
279
279
  console.log(md);
280
+ } else if (timeline.length > 0) {
281
+ console.log(coverageToText(model, timeline.at(-1)!, eta));
282
+ console.log("\nUse --verbose for assumptions, cross-checks, and the full coverage history.");
283
+ } else {
284
+ console.log(`TAM "${model.name}" · ${model.universe.accounts.toLocaleString()} accounts · ${model.universe.contacts.toLocaleString()} buyers · $${Math.round(model.tamUsd).toLocaleString()}`);
285
+ console.log(`ICP ${model.icpName} · ${model.acv.basis}-basis ACV $${Math.round(model.acv.valueUsd).toLocaleString()} (${model.acv.source})`);
286
+ console.log("No coverage readings yet. Run `fullstackgtm tam status --save` to establish one; use --verbose for the full assumptions report.");
280
287
  }
281
288
  return;
282
289
  }
package/src/cli/ui.ts CHANGED
@@ -286,8 +286,8 @@ export function startElapsedStatus(
286
286
 
287
287
  export type Checklist = {
288
288
  update(id: string, state: "pending" | "running" | "ok" | "warn" | "fail", note?: string): void;
289
- /** Stop animating and erase the board (callers print their own summary). */
290
- done(): void;
289
+ /** Stop animating; optionally leave the final board in the terminal history. */
290
+ done(options?: { persist?: boolean }): void;
291
291
  readonly active: boolean;
292
292
  };
293
293
 
@@ -296,7 +296,8 @@ const NOOP_CHECKLIST: Checklist = { update() {}, done() {}, active: false };
296
296
  /**
297
297
  * A multi-line live board — one line per item, each flipping ○ → ⠹ → ✓ as work
298
298
  * progresses (the audit rule registry renders through this). Repaints with
299
- * cursor-up; erases itself on done() so the verb's real output owns stdout.
299
+ * cursor-up; done() normally erases it, while done({ persist: true }) leaves a
300
+ * final static history for multi-phase human workflows.
300
301
  */
301
302
  export function createChecklist(
302
303
  items: Array<{ id: string; label: string }>,
@@ -323,8 +324,12 @@ export function createChecklist(
323
324
  const label = entry.state === "pending" ? p.dim(item.label.padEnd(labelWidth)) : item.label.padEnd(labelWidth);
324
325
  return ` ${glyph} ${label}${note}`;
325
326
  });
326
- const up = painted > 0 ? `\u001b[${painted}A` : "";
327
- stream.write(`${up}${lines.map((line) => `\u001b[2K${line}`).join("\n")}\n`);
327
+ // Keep the cursor on the board's last row while it is live. A trailing
328
+ // newline on every repaint can scroll the old top row into permanent
329
+ // history when the board sits at the bottom of the terminal, producing
330
+ // apparent duplicate/errored frames in terminal captures.
331
+ const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
332
+ stream.write(`${up}${lines.map((line) => `\u001b[2K${line}`).join("\n")}`);
328
333
  painted = lines.length;
329
334
  };
330
335
 
@@ -343,16 +348,28 @@ export function createChecklist(
343
348
  const entry = states.get(id);
344
349
  if (!entry) return;
345
350
  entry.state = state;
346
- entry.note = note;
351
+ if (note !== undefined) entry.note = note;
347
352
  start();
348
353
  render();
349
354
  },
350
- done() {
355
+ done(options = {}) {
351
356
  if (timer) clearInterval(timer);
352
357
  timer = null;
358
+ if (options.persist) {
359
+ // The caller's final state update already painted the completed board.
360
+ // Advance exactly once so subsequent output begins below it.
361
+ if (painted > 0) stream.write("\n");
362
+ painted = 0;
363
+ return;
364
+ }
353
365
  if (painted > 0) {
354
- // Erase the board: cursor up over every painted line, clear each.
355
- stream.write(`\u001b[${painted}A${"\u001b[2K\n".repeat(painted)}\u001b[${painted}A`);
366
+ // Erase in place without linefeeds, which could themselves scroll.
367
+ const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
368
+ const clear = Array.from({ length: painted }, (_, index) =>
369
+ `\u001b[2K${index < painted - 1 ? "\u001b[1B" : ""}`,
370
+ ).join("");
371
+ const restore = painted > 1 ? `\u001b[${painted - 1}A` : "";
372
+ stream.write(`${up}${clear}${restore}`);
356
373
  painted = 0;
357
374
  }
358
375
  },
@@ -366,8 +383,8 @@ export type ProgressRenderer = {
366
383
  * listener via `composeListeners` when the run should also heartbeat).
367
384
  */
368
385
  listener: ProgressListener;
369
- /** Stop animating and erase the board (callers print their own summary). */
370
- done(): void;
386
+ /** Stop animating; optionally leave the completed board in terminal history. */
387
+ done(options?: { persist?: boolean }): void;
371
388
  readonly active: boolean;
372
389
  };
373
390
 
@@ -423,8 +440,9 @@ export function createProgressRenderer(
423
440
  // larger flow) are ignored by createChecklist's unknown-id no-op.
424
441
  if (current) board.update(current, "running", noteFor(event, snapshot));
425
442
  },
426
- done() {
427
- board.done();
443
+ done(options) {
444
+ if (current) board.update(current, "ok");
445
+ board.done(options);
428
446
  },
429
447
  active: true,
430
448
  };
@@ -42,6 +42,10 @@ export type HubspotConnectorOptions = {
42
42
  * alongside the legacy `onProgress` callback; both are presentation-only.
43
43
  */
44
44
  progress?: ProgressEmitter;
45
+ /** Maximum retries for HubSpot 429/5xx responses (default 5). */
46
+ maxRetries?: number;
47
+ /** Injectable delay for deterministic retry tests. */
48
+ sleep?: (milliseconds: number) => Promise<void>;
45
49
  };
46
50
 
47
51
  const OBJECT_PATHS: Partial<Record<GtmObjectType, string>> = {
@@ -76,6 +80,8 @@ const PULL_STAGE_BY_TYPE: Record<SnapshotProgress["objectType"], (typeof SNAPSHO
76
80
  export function createHubspotConnector(options: HubspotConnectorOptions): HubspotWritableConnector {
77
81
  const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
78
82
  const fetchImpl = options.fetchImpl ?? fetch;
83
+ const maxRetries = options.maxRetries ?? 5;
84
+ const sleep = options.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
79
85
  const mappings = options.fieldMappings;
80
86
  // create:<Name> dedup within one connector lifetime (one apply run): the
81
87
  // search API is eventually consistent, so a just-created company is
@@ -113,27 +119,45 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Hubspo
113
119
  };
114
120
 
115
121
  async function request(path: string, init: RequestInit = {}): Promise<any> {
116
- const token = await options.getAccessToken();
117
- let response: Response;
118
- try {
119
- response = await fetchImpl(`${baseUrl}${path}`, {
120
- ...init,
121
- headers: {
122
- Authorization: `Bearer ${token}`,
123
- "Content-Type": "application/json",
124
- ...(init.headers ?? {}),
125
- },
126
- });
127
- } catch (error) {
128
- const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
129
- throw new Error(`Cannot reach HubSpot at ${baseUrl}${cause}. Check network access.`);
122
+ let response: Response | undefined;
123
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
124
+ const token = await options.getAccessToken();
125
+ try {
126
+ response = await fetchImpl(`${baseUrl}${path}`, {
127
+ ...init,
128
+ headers: {
129
+ Authorization: `Bearer ${token}`,
130
+ "Content-Type": "application/json",
131
+ ...(init.headers ?? {}),
132
+ },
133
+ });
134
+ } catch (error) {
135
+ const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
136
+ throw new Error(`Cannot reach HubSpot at ${baseUrl}${cause}. Check network access.`);
137
+ }
138
+ const retryable = response.status === 429 || response.status >= 500;
139
+ if (!retryable || attempt === maxRetries) break;
140
+ await response.text().catch(() => undefined);
141
+ const retryAfter = response.headers.get("Retry-After");
142
+ const seconds = retryAfter === null ? NaN : Number(retryAfter);
143
+ const dateDelay = retryAfter && !Number.isFinite(seconds) ? Date.parse(retryAfter) - Date.now() : NaN;
144
+ const delayMs = Math.min(
145
+ 30_000,
146
+ Math.max(0, Number.isFinite(seconds) ? seconds * 1_000 : Number.isFinite(dateDelay) ? dateDelay : 1_000 * 2 ** attempt),
147
+ );
148
+ const reason = response.status === 429 ? "rate limited" : `temporarily unavailable (${response.status})`;
149
+ options.progress?.note(`HubSpot ${reason}; retrying in ${Math.ceil(delayMs / 1_000)}s (${attempt + 1}/${maxRetries})`);
150
+ await sleep(delayMs);
130
151
  }
131
- if (!response.ok) {
152
+ if (!response || !response.ok) {
132
153
  // Status line only — HubSpot 4xx bodies echo submitted property values
133
154
  // (contact emails, company domains) and the request payload, and these
134
155
  // errors are persisted into scheduled-run records. Never interpolate it.
135
- await response.text().catch(() => undefined);
136
- throw new Error(`HubSpot API error ${response.status}. Check the token scopes and request.`);
156
+ await response?.text().catch(() => undefined);
157
+ if (response?.status === 429) {
158
+ throw new Error(`HubSpot rate limit (429) persisted after ${maxRetries} retries. Wait for the portal limit to reset, then retry; no CRM writes were made.`);
159
+ }
160
+ throw new Error(`HubSpot API error ${response?.status ?? "unknown"}. Check the token scopes and request.`);
137
161
  }
138
162
  // DELETE and some association writes return 204 with an empty body.
139
163
  const text = await response.text();
@@ -41,6 +41,8 @@ export type FetchProspectsOptions = {
41
41
  sourceId?: string;
42
42
  /** Hard cap on prospects returned; also bounds pagination. */
43
43
  max?: number;
44
+ /** Opaque continuation cursor. HeyReach uses the decimal list offset. */
45
+ cursor?: string;
44
46
  };
45
47
 
46
48
  export type ConnectionStatus = { ok: boolean; detail: string };
@@ -152,6 +154,10 @@ export function createHeyReachProvider(options: HeyReachProviderOptions): Linked
152
154
  const max = opts.max ?? Number.POSITIVE_INFINITY;
153
155
  const out: LinkedInProspect[] = [];
154
156
  let offset = 0;
157
+ if (opts.cursor !== undefined) {
158
+ if (!/^\d+$/.test(opts.cursor)) throw new Error(`Invalid HeyReach cursor: ${opts.cursor}`);
159
+ offset = Number(opts.cursor);
160
+ }
155
161
  while (out.length < max) {
156
162
  const data = await call("/list/GetLeadsFromList", "POST", { listId, offset, limit: pageSize });
157
163
  const items = toArray(data);
@@ -68,6 +68,8 @@ export async function fetchExploriumProspects(opts: {
68
68
  apiKey: string;
69
69
  filters: ExploriumFilters;
70
70
  size?: number;
71
+ /** One-based result page. */
72
+ page?: number;
71
73
  apiBaseUrl?: string;
72
74
  fetchImpl?: FetchImpl;
73
75
  }): Promise<Prospect[]> {
@@ -77,7 +79,7 @@ export async function fetchExploriumProspects(opts: {
77
79
  const response = await fetchImpl(`${base}/v1/prospects`, {
78
80
  method: "POST",
79
81
  headers: { "api_key": opts.apiKey, "Content-Type": "application/json" },
80
- body: JSON.stringify({ mode: "full", size, page_size: size, page: 1, filters: opts.filters }),
82
+ body: JSON.stringify({ mode: "full", size, page_size: size, page: opts.page ?? 1, filters: opts.filters }),
81
83
  });
82
84
  if (!response.ok) {
83
85
  throw new ProviderHttpError("Explorium", "prospect search", response.status);
@@ -119,17 +121,44 @@ export async function fetchPipe0CrustdataProspects(opts: {
119
121
  apiKey: string;
120
122
  filters: Record<string, unknown>;
121
123
  limit?: number;
124
+ /** Opaque cursor returned by a previous page. */
125
+ cursor?: string;
122
126
  apiBaseUrl?: string;
123
127
  fetchImpl?: FetchImpl;
124
128
  }): Promise<Prospect[]> {
129
+ return (await fetchPipe0CrustdataProspectPage(opts)).prospects;
130
+ }
131
+
132
+ /** A page from pipe0/Crustdata's cursor-based people search. */
133
+ export type Pipe0CrustdataProspectPage = {
134
+ prospects: Prospect[];
135
+ /** Opaque cursor for the next page, or null when the search is exhausted. */
136
+ nextCursor: string | null;
137
+ };
138
+
139
+ /**
140
+ * Fetch one page and expose its continuation cursor. The array-returning
141
+ * `fetchPipe0CrustdataProspects` remains available for existing consumers.
142
+ */
143
+ export async function fetchPipe0CrustdataProspectPage(opts: {
144
+ apiKey: string;
145
+ filters: Record<string, unknown>;
146
+ limit?: number;
147
+ /** Opaque cursor returned by a previous page. */
148
+ cursor?: string;
149
+ apiBaseUrl?: string;
150
+ fetchImpl?: FetchImpl;
151
+ }): Promise<Pipe0CrustdataProspectPage> {
125
152
  const fetchImpl = opts.fetchImpl ?? fetch;
126
153
  const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
127
154
  const limit = Math.min(opts.limit ?? 25, 100);
155
+ const config: Record<string, unknown> = { limit, filters: opts.filters };
156
+ if (opts.cursor !== undefined) config.cursor = opts.cursor;
128
157
  const response = await fetchImpl(`${base}/v1/searches/run/sync`, {
129
158
  method: "POST",
130
159
  headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
131
160
  body: JSON.stringify({
132
- searches: [{ search_id: "people:profiles:crustdata@1", config: { limit, filters: opts.filters } }],
161
+ searches: [{ search_id: "people:profiles:crustdata@1", config }],
133
162
  }),
134
163
  });
135
164
  if (!response.ok) {
@@ -137,7 +166,12 @@ export async function fetchPipe0CrustdataProspects(opts: {
137
166
  }
138
167
  const body = (await response.json()) as {
139
168
  results?: Array<Record<string, { value?: unknown }>>;
140
- search_statuses?: Array<{ errors?: Array<{ code?: string; message?: string }> }>;
169
+ next_cursor?: unknown;
170
+ search_statuses?: Array<{
171
+ errors?: Array<{ code?: string; message?: string }>;
172
+ next_cursor?: unknown;
173
+ cursor?: unknown;
174
+ }>;
141
175
  };
142
176
  // Surface upstream provider errors (e.g. CreditBalanceInsufficient) instead of
143
177
  // silently returning [] — a throttle/credit failure must not look like "no ICP matches".
@@ -147,7 +181,7 @@ export async function fetchPipe0CrustdataProspects(opts: {
147
181
  `pipe0/Crustdata search error: ${upstreamErrors.map((e) => `${e.code ?? "error"}: ${e.message ?? ""}`).join("; ")}`,
148
182
  );
149
183
  }
150
- return (body.results ?? []).map((row) => {
184
+ const prospects = (body.results ?? []).map((row) => {
151
185
  const fullName = strField(row.name);
152
186
  const { firstName, lastName } = splitName(fullName);
153
187
  return {
@@ -160,6 +194,14 @@ export async function fetchPipe0CrustdataProspects(opts: {
160
194
  sourceId: strField(row.profile_url) ?? fullName,
161
195
  };
162
196
  });
197
+ // Current pipe0 responses expose `next_cursor` at the top level. Accept the
198
+ // status-envelope variants too so older deployments remain usable.
199
+ const status = body.search_statuses?.[0];
200
+ const rawCursor = body.next_cursor ?? status?.next_cursor ?? status?.cursor;
201
+ return {
202
+ prospects,
203
+ nextCursor: typeof rawCursor === "string" && rawCursor.length > 0 ? rawCursor : null,
204
+ };
163
205
  }
164
206
 
165
207
  function strField(field: { value?: unknown } | undefined): string | undefined {
package/src/enrich.ts CHANGED
@@ -111,6 +111,8 @@ export type AcquireDiscoveryConfig = {
111
111
  provider: "explorium" | "pipe0" | "linkedin" | "heyreach";
112
112
  filters?: Record<string, unknown>;
113
113
  size?: number;
114
+ /** Maximum raw provider candidates scanned per run while seeking fresh leads. */
115
+ scanLimit?: number;
114
116
  resolveEmailsWith?: "pipe0";
115
117
  /** LinkedIn sources only: the provider-native list id to read (HeyReach lead-list id). */
116
118
  listId?: string;
@@ -1326,7 +1328,49 @@ export function selectStaleWork(
1326
1328
  // Checkpoint (cursor), staleness ledger (stamps), and observability surface
1327
1329
  // (enrich status) in one structure — mirrors the market observations store.
1328
1330
 
1329
- export type EnrichRunMode = EnrichMode | "ingest";
1331
+ export type EnrichRunMode = EnrichMode | "ingest" | "acquire";
1332
+
1333
+ /**
1334
+ * Provider continuation state for an acquisition query. The fingerprint binds
1335
+ * a cursor/offset to the ICP + provider filters that produced it, preventing a
1336
+ * changed query from accidentally resuming in the middle of the old audience.
1337
+ */
1338
+ export type AcquireDiscoveryCheckpoint = {
1339
+ queryFingerprint: string;
1340
+ /** Opaque provider cursor (Pipe0 and other cursor-based sources). */
1341
+ cursor?: string | null;
1342
+ /** Zero-based provider offset (LinkedIn/HeyReach and other offset sources). */
1343
+ offset?: number | null;
1344
+ /** True only when the provider has positively reported no next page. */
1345
+ exhausted: boolean;
1346
+ };
1347
+
1348
+ /**
1349
+ * Truthful acquisition funnel for one run. Each field counts candidates, not
1350
+ * provider requests, making duplicate saturation and resolution loss visible
1351
+ * instead of reporting every run as zero.
1352
+ */
1353
+ export type AcquireRunFunnel = {
1354
+ /** Raw candidates returned across all discovery pages scanned this run. */
1355
+ discovered: number;
1356
+ /** Candidates that met the configured ICP threshold. */
1357
+ qualified: number;
1358
+ /** Qualified candidates removed by the CRM pre-email dedupe. */
1359
+ skippedCrm: number;
1360
+ /** Qualified candidates removed by the cross-run seen ledger. */
1361
+ skippedSeen: number;
1362
+ /** Fresh candidates carrying the configured resolve-first key. */
1363
+ resolved: number;
1364
+ /** Governed create operations emitted into the saved/dry-run plan. */
1365
+ proposed: number;
1366
+ /** Otherwise-proposable candidates held back by the acquire meter. */
1367
+ withheldByMeter: number;
1368
+ };
1369
+
1370
+ export type AcquireRunTelemetry = {
1371
+ funnel: AcquireRunFunnel;
1372
+ discovery: AcquireDiscoveryCheckpoint;
1373
+ };
1330
1374
 
1331
1375
  export type EnrichRun = {
1332
1376
  id: string;
@@ -1339,6 +1383,8 @@ export type EnrichRun = {
1339
1383
  /** Resume point for an interrupted pull (last processed pull key). */
1340
1384
  cursor: string | null;
1341
1385
  counts: EnrichCounts;
1386
+ /** Acquisition-only funnel + provider continuation state (absent on legacy runs). */
1387
+ acquireTelemetry?: AcquireRunTelemetry;
1342
1388
  planIds: string[];
1343
1389
  stamps: EnrichStamp[];
1344
1390
  /** Staged source rows (ingest mode only), consumed by append/refresh. */