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
package/src/cli/enrich.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  // Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
2
2
 
3
3
  import { existsSync, readFileSync } from "node:fs";
4
+ import { createHash } from "node:crypto";
4
5
  import { resolve } from "node:path";
5
6
  import { getCredential } from "../credentials.ts";
6
7
  import { patchPlanToMarkdown } from "../format.ts";
7
8
  import { createFilePlanStore } from "../planStore.ts";
8
9
  import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, builtinAcquirePreset, builtinEnrichPreset, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor, type EnrichConfig, type EnrichCounts, type EnrichObjectType, type EnrichRun, type EnrichRunStore, type EnrichSourceRecord } from "../enrich.ts";
9
10
  import { loadMeter, remaining, type AcquireRemaining } from "../acquireMeter.ts";
10
- import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspects, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys, type Prospect } from "../connectors/prospectSources.ts";
11
+ import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspectPage, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys, type Prospect } from "../connectors/prospectSources.ts";
11
12
  import { loadSeen, recordSeen } from "../acquireSeen.ts";
13
+ import { acquireCheckpointId, createFileAcquireCheckpointStore, type AcquireCheckpointKey } from "../acquireCheckpoint.ts";
14
+ import { readHostedAcquireCheckpoint, writeHostedAcquireCheckpoint } from "../hostedAcquireCheckpoint.ts";
15
+ import { uploadHostedPatchPlan } from "../hostedPatchPlan.ts";
12
16
  import { progressReporter, reportCounts, reportEvent } from "../runReport.ts";
13
17
  import { ACQUIRE_STAGES, composeListeners, createProgressEmitter } from "../progress.ts";
14
18
  import { createLinkedInProvider, discoverLinkedInProspects } from "../acquireLinkedIn.ts";
@@ -20,6 +24,7 @@ import { isOptionValue, loadIcp, numericOption, option, readSnapshot, saveReques
20
24
  import { providerKey } from "./tam.ts";
21
25
  import { unknownSubcommandError } from "./suggest.ts";
22
26
  import { colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint, type Paint } from "./ui.ts";
27
+ import { compactPlan, verbosePlanRequested } from "./planOutput.ts";
23
28
  import type { AcquireBudget } from "../acquireMeter.ts";
24
29
 
25
30
 
@@ -43,7 +48,7 @@ enrich append [--source apollo] [--objects companies,contacts] [--save] [--conf
43
48
  enrich refresh [--source apollo] [--stale-days <n>] [--save] [--config <path>]
44
49
  [source options] [--run-label <label>] [--json]
45
50
  enrich ingest <file.csv|payload.json|spool.jsonl|spool-dir> --source clay [--run-label <label>] [--objects companies|contacts] [--config <path>]
46
- enrich acquire [--source <id>] [--max <n>] [--list <id>] [--assign-owner <id>] [--save] [--config <path>] [--json]
51
+ enrich acquire [--source <id>] [--max <new-leads>] [--scan-limit <candidates>] [--list <id>] [--assign-owner <id>] [--save] [--config <path>] [--json]
47
52
  enrich status [--runs] [--source <id>] [--config <path>] [--json]
48
53
 
49
54
  acquire creates NET-NEW leads from a staged prospect list (ingest first):
@@ -53,6 +58,9 @@ it dedupes each sourced row against the CRM, skips matches and ambiguities
53
58
  remaining budget (records + spend, per day and per month; whichever is hit
54
59
  first). Approval-gated like every write: \`--save\` → plans approve → apply.
55
60
  The meter is charged only when a create actually lands at apply.
61
+ \`--max\` controls the desired net-new plan size; \`--scan-limit\` separately
62
+ bounds raw candidates scanned after ICP and dedupe filtering. Continuation is
63
+ persisted per query so scheduled runs advance instead of rereading page one.
56
64
  Discovery sources (zero-config presets): explorium | pipe0 (ICP-driven search),
57
65
  and linkedin (reads a HeyReach lead list — pass --list <id>, key on the LinkedIn
58
66
  URL, $0/record; \`login heyreach\` or HEYREACH_API_KEY).
@@ -141,7 +149,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
141
149
 
142
150
  // Prospects come from an API source (net-new discovery, e.g. Explorium +
143
151
  // pipe0 work-email) or a staged ingest list (Clay/CSV/webhook).
144
- const snapshot = await readSnapshot(rest);
152
+ const snapshot = await readSnapshot(rest, undefined, { persistProgress: true });
145
153
  const icp = loadIcp(rest);
146
154
  if (sourceConfig.kind === "api" && !icp) {
147
155
  console.error(
@@ -159,16 +167,39 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
159
167
  );
160
168
  }
161
169
  const seen = loadSeen();
170
+ const now = new Date();
171
+ const costPerRecord = config.acquire.costPerRecord?.[source] ?? 0;
172
+ const headroom = remaining(loadMeter(now), config.acquire.budget, costPerRecord, now);
173
+ const explicitMax = numericOption(rest, "--max");
174
+ const desiredCreates = explicitMax ?? config.acquire.discovery?.[source]?.size ?? 25;
175
+ const cap = headroom.maxRecords === null ? desiredCreates : Math.min(headroom.maxRecords, desiredCreates);
162
176
  let records: EnrichSourceRecord[];
163
177
  let apiSkippedCrm = 0;
164
178
  let apiSkippedSeen = 0;
165
179
  let apiProcessedKeys: string[] = [];
180
+ let acquireTelemetry: NonNullable<EnrichRun["acquireTelemetry"]> | undefined;
181
+ let acquireCheckpointSync: { key: AcquireCheckpointKey; hostedVersion: number | null } | undefined;
182
+ const renderer = createProgressRenderer(ACQUIRE_STAGES);
183
+ const acquireProgress = createProgressEmitter(
184
+ composeListeners(renderer.listener, progressReporter()),
185
+ );
166
186
  if (sourceConfig.kind === "api") {
167
- const api = await acquireFromApi(config, source, rest, icp, snapshot, seen);
187
+ const priorRun = await store.latest({ source, mode: "acquire" });
188
+ acquireProgress.stage(ACQUIRE_STAGES[0], 0, ACQUIRE_STAGES.length);
189
+ acquireProgress.note("loading saved audience position");
190
+ let api: Awaited<ReturnType<typeof acquireFromApi>>;
191
+ try {
192
+ api = await acquireFromApi(config, source, rest, icp, snapshot, seen, priorRun, cap, save, acquireProgress);
193
+ } catch (error) {
194
+ renderer.done({ persist: true });
195
+ throw error;
196
+ }
168
197
  records = api.records;
169
198
  apiSkippedCrm = api.skippedCrm;
170
199
  apiSkippedSeen = api.skippedSeen;
171
200
  apiProcessedKeys = api.processedKeys;
201
+ acquireTelemetry = api.telemetry;
202
+ acquireCheckpointSync = { key: api.checkpointKey, hostedVersion: api.hostedVersion };
172
203
  } else {
173
204
  const stagedLabel = option(rest, "--staged-run");
174
205
  const stagedRun = stagedLabel
@@ -184,18 +215,15 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
184
215
 
185
216
  // Meter: how many MORE leads may we create right now? --max is an
186
217
  // additional per-run ceiling, never a way to exceed the budget.
187
- const now = new Date();
188
- const costPerRecord = config.acquire.costPerRecord?.[source] ?? 0;
189
218
  if (apiSkippedCrm || apiSkippedSeen) {
190
219
  console.error(
191
220
  `Pre-email dedup: skipped ${apiSkippedCrm} already-in-CRM + ${apiSkippedSeen} seen before paying for emails ` +
192
221
  `(≈$${((apiSkippedCrm + apiSkippedSeen) * costPerRecord).toFixed(2)} enrichment saved).`,
193
222
  );
194
223
  }
195
- const headroom = remaining(loadMeter(now), config.acquire.budget, costPerRecord, now);
196
- const explicitMax = numericOption(rest, "--max");
197
- let cap = headroom.maxRecords;
198
- if (explicitMax !== undefined) cap = cap === null ? explicitMax : Math.min(cap, explicitMax);
224
+ // `cap` is the desired create count bounded by the persistent meter. Raw
225
+ // discovery scanning has its own --scan-limit and may cross many duplicate-
226
+ // heavy pages to fill this target.
199
227
 
200
228
  // Assignment: never create an ownerless lead. An explicit `acquire.assign`
201
229
  // policy wins; a `--assign-owner <id>` flag is a quick fixed override;
@@ -229,13 +257,10 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
229
257
  // piped) and, via the composed reporter, heartbeat to a paired hosted app.
230
258
  // The meter reading (creates vs headroom + budget burn) rides the same
231
259
  // emitter, feeding the dashboard's gauge without printing anything new.
232
- const renderer = createProgressRenderer(ACQUIRE_STAGES);
233
- const acquireProgress = createProgressEmitter(
234
- composeListeners(renderer.listener, progressReporter()),
235
- );
236
260
  let result: ReturnType<typeof buildAcquirePlan>;
237
261
  try {
238
- acquireProgress.stage(ACQUIRE_STAGES[0], 0, ACQUIRE_STAGES.length);
262
+ acquireProgress.stage(ACQUIRE_STAGES[2], 2, ACQUIRE_STAGES.length);
263
+ acquireProgress.note("building governed create plan");
239
264
  if (config.acquire.budget?.records?.perDay && headroom.records.day !== null) {
240
265
  acquireProgress.meter(
241
266
  config.acquire.budget.records.perDay - headroom.records.day,
@@ -253,7 +278,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
253
278
  progress: acquireProgress,
254
279
  });
255
280
  } finally {
256
- renderer.done();
281
+ renderer.done({ persist: true });
257
282
  }
258
283
 
259
284
  if (result.counts.unassigned > 0 && result.counts.created > 0) {
@@ -285,7 +310,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
285
310
  ),
286
311
  );
287
312
  } else {
288
- console.log(patchPlanToMarkdown(result.plan));
313
+ console.log(verbosePlanRequested(rest) ? patchPlanToMarkdown(result.plan) : compactPlan(result.plan));
289
314
  console.log(meterLine);
290
315
  if (gaugeLine) console.log(gaugeLine);
291
316
  console.log("\nDry run — nothing written. Re-run with --save to persist the plan, then approve + apply.");
@@ -293,31 +318,67 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
293
318
  return;
294
319
  }
295
320
 
296
- const run = await openEnrichRun(store, source, "append", option(rest, "--run-label"), today);
321
+ const run = await openEnrichRun(store, source, "acquire", option(rest, "--run-label"), today);
297
322
  const planIds: string[] = [];
323
+ let hostedPlanUrl: string | null = null;
298
324
  if (result.plan.operations.length > 0) {
299
- await createFilePlanStore().save(result.plan);
325
+ const storedPlan = await createFilePlanStore().save(result.plan);
300
326
  planIds.push(result.plan.id);
301
327
  reportEvent("plan_saved", result.plan.id);
328
+ const mirror = await uploadHostedPatchPlan(storedPlan);
329
+ if (mirror.status === "saved") {
330
+ hostedPlanUrl = mirror.state.url;
331
+ reportEvent("plan_mirrored", mirror.state.patchPlanId);
332
+ } else if (mirror.status === "unavailable" || mirror.status === "conflict") {
333
+ console.error(`Hosted plan sync pending: ${mirror.reason}. The local signed plan remains authoritative.`);
334
+ }
335
+ }
336
+ if (acquireTelemetry) {
337
+ acquireTelemetry.funnel.proposed = result.counts.created;
338
+ acquireTelemetry.funnel.withheldByMeter = result.counts.withheldByMeter;
302
339
  }
303
340
  await store.update({
304
341
  ...run,
305
342
  completedAt: new Date().toISOString(),
306
- cursor: null,
343
+ cursor: acquireTelemetry?.discovery.cursor ?? null,
344
+ counts: acquireTelemetry
345
+ ? {
346
+ fetched: acquireTelemetry.funnel.discovered,
347
+ matched: acquireTelemetry.funnel.skippedCrm + acquireTelemetry.funnel.skippedSeen,
348
+ unmatched: acquireTelemetry.funnel.resolved,
349
+ ambiguous: result.counts.ambiguous,
350
+ opsEmitted: acquireTelemetry.funnel.proposed,
351
+ }
352
+ : run.counts,
353
+ acquireTelemetry,
307
354
  planIds: [...(run.planIds ?? []), ...planIds],
308
355
  });
309
356
  // Remember everyone we email-resolved this run so the next run skips them
310
357
  // pre-email (cross-run credit saver). Committed (--save) runs only.
311
358
  if (apiProcessedKeys.length > 0) recordSeen(apiProcessedKeys, now);
312
- console.log(meterLine);
313
- if (gaugeLine) console.log(gaugeLine);
314
- if (planIds.length > 0) {
315
- console.log(
316
- `Saved plan ${result.plan.id} — ${result.counts.created} net-new lead(s), est. $${result.estCostUsd.toFixed(2)}. ` +
317
- `Review \`fullstackgtm plans show ${result.plan.id}\`, approve \`fullstackgtm plans approve ${result.plan.id} --operations all\`, ` +
318
- `then \`fullstackgtm apply --plan-id ${result.plan.id} --provider hubspot\`. The meter is charged only when a create lands at apply.`,
359
+ if (acquireTelemetry && acquireCheckpointSync) {
360
+ await persistAcquireCheckpoint(
361
+ acquireCheckpointSync.key,
362
+ acquireTelemetry.discovery,
363
+ acquireCheckpointSync.hostedVersion,
319
364
  );
365
+ }
366
+ if (planIds.length > 0) {
367
+ const hostedRuns = hostedRunsUrl();
368
+ console.log(`\nSaved plan ${result.plan.id}`);
369
+ console.log(` Leads ${result.counts.created} net-new`);
370
+ console.log(` Est. cost $${result.estCostUsd.toFixed(2)}`);
371
+ console.log(` Budget ${gaugeLine ?? meterLine}`);
372
+ if (hostedPlanUrl) console.log(` Plan ${hostedPlanUrl}`);
373
+ if (hostedRuns) console.log(` Observe ${hostedRuns}`);
374
+ console.log("\nNext steps");
375
+ console.log(` Review fullstackgtm plans show ${result.plan.id}`);
376
+ console.log(` Approve fullstackgtm plans approve ${result.plan.id} --operations all`);
377
+ console.log(` Apply fullstackgtm apply --plan-id ${result.plan.id} --provider hubspot`);
378
+ console.log("\nThe meter is charged only when a create lands at apply.");
320
379
  } else {
380
+ console.log(meterLine);
381
+ if (gaugeLine) console.log(gaugeLine);
321
382
  console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
322
383
  }
323
384
  return;
@@ -454,7 +515,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
454
515
  if (rest.includes("--json")) {
455
516
  console.log(JSON.stringify(result.plan, null, 2));
456
517
  } else {
457
- console.log(patchPlanToMarkdown(result.plan));
518
+ console.log(verbosePlanRequested(rest) ? patchPlanToMarkdown(result.plan) : compactPlan(result.plan));
458
519
  console.log(formatEnrichCounts(result.counts, result.ambiguities.length));
459
520
  console.log("\nDry run — nothing written. Re-run with --save to persist the plan and the run record.");
460
521
  }
@@ -510,47 +571,165 @@ async function acquireFromApi(
510
571
  icp: Icp | undefined,
511
572
  snapshot: CanonicalGtmSnapshot,
512
573
  seen: Set<string>,
513
- ): Promise<{ records: EnrichSourceRecord[]; skippedCrm: number; skippedSeen: number; processedKeys: string[] }> {
574
+ priorRun: EnrichRun | null,
575
+ targetFresh: number,
576
+ persistCheckpoint: boolean,
577
+ progress: ReturnType<typeof createProgressEmitter>,
578
+ ): Promise<{
579
+ records: EnrichSourceRecord[];
580
+ skippedCrm: number;
581
+ skippedSeen: number;
582
+ processedKeys: string[];
583
+ telemetry: NonNullable<EnrichRun["acquireTelemetry"]>;
584
+ checkpointKey: AcquireCheckpointKey;
585
+ hostedVersion: number | null;
586
+ }> {
514
587
  const acquire = config.acquire!;
515
588
  const disc = acquire.discovery?.[source];
516
589
  if (!disc) {
517
590
  throw new Error(`enrich acquire: api source "${source}" needs an "acquire.discovery.${source}" config (provider + size).`);
518
591
  }
519
592
  const matchKey = acquire.create.contact?.matchKey ?? "email";
520
- const maxOverride = numericOption(rest, "--max");
521
- const size = maxOverride !== undefined ? Math.min(maxOverride, disc.size ?? 25) : disc.size ?? 25;
522
-
523
- // 1. Discover. Filters come from the ICP when one is loaded (the whole point —
524
- // targeted, not random); otherwise from the hand-written disc.filters.
525
- let prospects: Prospect[];
526
- if (disc.provider === "explorium") {
527
- const filters = icp
528
- ? icpToExploriumFilters(icp)
529
- : ((disc.filters ?? {}) as Record<string, { values?: string[]; value?: boolean }>);
530
- prospects = await fetchExploriumProspects({ apiKey: providerKey("explorium"), filters, size });
531
- } else if (disc.provider === "pipe0") {
532
- const filters = icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {});
533
- prospects = await fetchPipe0CrustdataProspects({ apiKey: providerKey("pipe0"), filters, limit: size });
534
- } else if (disc.provider === "linkedin" || disc.provider === "heyreach") {
535
- // LinkedIn reads a pre-populated lead list (not an ICP-driven query); the ICP
536
- // scores the pulled list below. List id: disc.listId or --list <id>.
537
- const listId = disc.listId ?? option(rest, "--list") ?? undefined;
538
- if (!listId) {
539
- throw new Error(
540
- "enrich acquire --source linkedin needs a HeyReach lead-list id: pass --list <id> or set acquire.discovery.linkedin.listId.",
593
+ const explicitScanLimit = numericOption(rest, "--scan-limit");
594
+ const scanLimit = Math.max(targetFresh, Math.min(explicitScanLimit ?? disc.scanLimit ?? Math.max(100, targetFresh * 10), 5_000));
595
+ const listId = disc.listId ?? option(rest, "--list") ?? undefined;
596
+ if ((disc.provider === "linkedin" || disc.provider === "heyreach") && !listId) {
597
+ throw new Error(
598
+ "enrich acquire --source linkedin needs a HeyReach lead-list id: pass --list <id> or set acquire.discovery.linkedin.listId.",
599
+ );
600
+ }
601
+ const filters = disc.provider === "explorium"
602
+ ? (icp ? icpToExploriumFilters(icp) : (disc.filters ?? {}))
603
+ : disc.provider === "pipe0"
604
+ ? (icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {}))
605
+ : {};
606
+ const queryFingerprint = createHash("sha256")
607
+ .update(JSON.stringify({ source, provider: disc.provider, listId, filters, icp: icp ?? null }))
608
+ .digest("hex");
609
+ const checkpointKey: AcquireCheckpointKey = {
610
+ provider: disc.provider,
611
+ source,
612
+ listId: listId ?? null,
613
+ queryFingerprint,
614
+ };
615
+ const checkpointStore = createFileAcquireCheckpointStore();
616
+ const localCheckpoint = await checkpointStore.get(checkpointKey);
617
+ const hostedRead = await readHostedAcquireCheckpoint(acquireCheckpointId(checkpointKey));
618
+ const hostedRecord = hostedRead.status === "found" ? hostedRead.record : null;
619
+ const hostedVersion = hostedRecord?.version ?? null;
620
+ // Once paired, hosted state lets another authenticated worker resume this
621
+ // audience. A newer offline local checkpoint is still preserved and pushed
622
+ // with CAS; otherwise hosted wins. Legacy run telemetry is a one-time
623
+ // migration source only when neither dedicated store has this exact key.
624
+ let persisted = localCheckpoint?.continuation;
625
+ if (
626
+ hostedRecord &&
627
+ (!localCheckpoint || hostedRecord.updatedAt >= Date.parse(localCheckpoint.updatedAt))
628
+ ) {
629
+ persisted = hostedRecord.checkpoint;
630
+ if (persistCheckpoint) {
631
+ await checkpointStore.put(
632
+ checkpointKey,
633
+ {
634
+ cursor: hostedRecord.checkpoint.cursor,
635
+ offset: hostedRecord.checkpoint.offset,
636
+ exhausted: hostedRecord.checkpoint.exhausted,
637
+ },
638
+ new Date(hostedRecord.updatedAt),
541
639
  );
542
640
  }
543
- const provider = createLinkedInProvider(disc.provider, providerKey("heyreach"));
544
- prospects = await discoverLinkedInProspects(provider, { sourceId: listId, max: size });
545
- } else {
546
- throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0 | linkedin).`);
641
+ }
642
+ const prior = priorRun?.acquireTelemetry?.discovery;
643
+ const resume = persisted ?? (
644
+ prior?.queryFingerprint === queryFingerprint
645
+ ? { cursor: prior.cursor, offset: prior.offset, exhausted: prior.exhausted }
646
+ : undefined
647
+ );
648
+ let cursor = resume?.cursor ?? null;
649
+ let offset = resume?.offset ?? 0;
650
+ let exhausted = resume?.exhausted ?? false;
651
+ let discovered = 0;
652
+ let qualified = 0;
653
+ let skippedCrm = 0;
654
+ let skippedSeen = 0;
655
+ const prospects: Prospect[] = [];
656
+ const crmKeys = crmContactKeys(snapshot);
657
+ const runSeen = new Set(seen);
658
+
659
+ // Pull successive pages until we have enough fresh candidates or hit the
660
+ // bounded scan ceiling. Page size follows the remaining target, so advancing
661
+ // a cursor never silently discards unused candidates from the final page.
662
+ while (prospects.length < targetFresh && discovered < scanLimit && !exhausted) {
663
+ // Cursor/offset providers can shrink the final request exactly. Explorium
664
+ // uses page numbers, so keep page_size stable or page boundaries would
665
+ // overlap/skip as the number of fresh candidates changes.
666
+ const requestSize = Math.max(1, Math.min(
667
+ 100,
668
+ disc.provider === "explorium" ? targetFresh : targetFresh - prospects.length,
669
+ scanLimit - discovered,
670
+ ));
671
+ let page: Prospect[];
672
+ let exploriumPage: number | null = null;
673
+ if (disc.provider === "pipe0") {
674
+ const result = await fetchPipe0CrustdataProspectPage({
675
+ apiKey: providerKey("pipe0"), filters, limit: requestSize, cursor: cursor ?? undefined,
676
+ });
677
+ page = result.prospects;
678
+ cursor = result.nextCursor;
679
+ exhausted = result.nextCursor === null;
680
+ } else if (disc.provider === "explorium") {
681
+ const pageNumber = offset + 1;
682
+ exploriumPage = pageNumber;
683
+ page = await fetchExploriumProspects({
684
+ apiKey: providerKey("explorium"),
685
+ filters: filters as Record<string, { values?: string[]; value?: boolean }>,
686
+ size: requestSize,
687
+ page: pageNumber,
688
+ });
689
+ exhausted = page.length < requestSize;
690
+ } else if (disc.provider === "linkedin" || disc.provider === "heyreach") {
691
+ const provider = createLinkedInProvider(disc.provider, providerKey("heyreach"));
692
+ page = await discoverLinkedInProspects(provider, {
693
+ sourceId: listId, max: requestSize, cursor: String(offset),
694
+ });
695
+ offset += page.length;
696
+ exhausted = page.length < requestSize;
697
+ } else {
698
+ throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0 | linkedin).`);
699
+ }
700
+ discovered += page.length;
701
+ progress.items(discovered, scanLimit);
702
+ if (page.length === 0) {
703
+ exhausted = true;
704
+ break;
705
+ }
706
+ let fitted = page;
707
+ if (icp) {
708
+ const threshold = fitThreshold(icp);
709
+ fitted = page
710
+ .map((p) => ({ ...p, fitScore: scoreProspectAgainstIcp(p, icp).score }))
711
+ .filter((p) => (p.fitScore ?? 0) >= threshold);
712
+ }
713
+ qualified += fitted.length;
714
+ const partition = partitionFreshProspects(fitted, crmKeys, runSeen);
715
+ skippedCrm += partition.skippedCrm;
716
+ skippedSeen += partition.skippedSeen;
717
+ const remainingTarget = targetFresh - prospects.length;
718
+ for (const prospect of partition.fresh) {
719
+ if (prospects.length >= targetFresh) break;
720
+ prospects.push(prospect);
721
+ for (const key of prospectIdentityKeys(prospect)) runSeen.add(key);
722
+ }
723
+ progress.note(
724
+ `${discovered} scanned · ${qualified} qualified · ${prospects.length}/${targetFresh} fresh`,
725
+ );
726
+ // Explorium is page-number based. Only advance after consuming every fresh
727
+ // candidate on that page; otherwise the next run safely re-reads the page
728
+ // and CRM/seen dedupe exposes the remainder instead of losing it.
729
+ if (exploriumPage !== null && partition.fresh.length <= remainingTarget) offset = exploriumPage;
547
730
  }
548
731
 
549
- // Surface a zero-discovery result LOUDLY rather than emit a silent empty plan.
550
- // A provider that returns 0 with no error usually means an over-narrow ICP
551
- // filter (most often the industry/seniority vocab) — not "no market exists".
552
- const discoveredCount = prospects.length;
553
- if (discoveredCount === 0) {
732
+ if (discovered === 0 && !resume) {
554
733
  console.error(
555
734
  `enrich acquire: ${disc.provider} discovered 0 prospects for this ICP — the plan will be empty. ` +
556
735
  "This is usually an over-narrow filter, not an empty market: check the ICP's industry and seniority " +
@@ -558,28 +737,15 @@ async function acquireFromApi(
558
737
  "generated discovery filters. (Distinct from dedup: nothing was returned to filter.)",
559
738
  );
560
739
  }
561
-
562
- // 2. ICP fit scoring BEFORE paying for emails — only above-threshold prospects
563
- // proceed to (credit-spending) email resolution.
564
- if (icp) {
565
- const threshold = fitThreshold(icp);
566
- prospects = prospects
567
- .map((p) => ({ ...p, fitScore: scoreProspectAgainstIcp(p, icp).score }))
568
- .filter((p) => (p.fitScore ?? 0) >= threshold);
569
- if (discoveredCount > 0 && prospects.length === 0) {
740
+ if (icp && discovered > 0 && qualified === 0) {
570
741
  console.error(
571
- `enrich acquire: all ${discoveredCount} discovered prospect(s) scored below the ICP fit threshold ` +
742
+ `enrich acquire: all ${discovered} discovered prospect(s) scored below the ICP fit threshold ` +
572
743
  `(${fitThreshold(icp)}) — none qualified. Loosen the ICP persona or lower scoring.threshold.`,
573
744
  );
574
- }
575
745
  }
576
746
 
577
- // 2.5 Pre-email dedup — drop prospects already in the CRM (name+domain match
578
- // vs the snapshot) or already processed in a prior run (the seen cache),
579
- // BEFORE paying pipe0 for emails. The email-level dedup (buildAcquirePlan)
580
- // and apply-time resolve-first remain the precise backstop.
581
- const { fresh, skippedCrm, skippedSeen } = partitionFreshProspects(prospects, crmContactKeys(snapshot), seen);
582
- prospects = fresh;
747
+ progress.stage(ACQUIRE_STAGES[1], 1, ACQUIRE_STAGES.length);
748
+ progress.note("checking work-email requirements");
583
749
 
584
750
  // 3. Resolve real work emails. Triggered either when email IS the dedupe key
585
751
  // (Explorium's email is hashed, Crustdata returns none) or when a source
@@ -591,10 +757,16 @@ async function acquireFromApi(
591
757
  // names. Resolve domains first (pipe0 company:identity) so resolution can
592
758
  // actually land — without it, name-only resolution fails for every lead.
593
759
  if (prospects.some((p) => !p.companyDomain && p.companyName)) {
594
- prospects = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects });
760
+ progress.note(`resolving company domains for ${prospects.length} candidate(s)`);
761
+ const resolved = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects });
762
+ prospects.splice(0, prospects.length, ...resolved);
595
763
  }
596
- prospects = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects });
764
+ progress.note(`resolving work emails for ${prospects.length} candidate(s)`);
765
+ const resolved = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects });
766
+ prospects.splice(0, prospects.length, ...resolved);
597
767
  }
768
+ progress.items(prospects.length, prospects.length);
769
+ progress.note(`${prospects.length} candidate(s) ready for planning`);
598
770
 
599
771
  const processedKeys = prospects.flatMap(prospectIdentityKeys);
600
772
  const records = prospects
@@ -608,7 +780,60 @@ async function acquireFromApi(
608
780
  };
609
781
  })
610
782
  .filter((record) => Boolean(record.keys[matchKey]));
611
- return { records, skippedCrm, skippedSeen, processedKeys };
783
+ const discovery = { queryFingerprint, cursor, offset, exhausted };
784
+ return {
785
+ records,
786
+ skippedCrm,
787
+ skippedSeen,
788
+ processedKeys,
789
+ telemetry: {
790
+ funnel: {
791
+ discovered,
792
+ qualified,
793
+ skippedCrm,
794
+ skippedSeen,
795
+ resolved: records.length,
796
+ proposed: 0,
797
+ withheldByMeter: 0,
798
+ },
799
+ discovery,
800
+ },
801
+ checkpointKey,
802
+ hostedVersion,
803
+ };
804
+ }
805
+
806
+ /** Commit continuation only after the saved plan/run is durable. */
807
+ async function persistAcquireCheckpoint(
808
+ key: AcquireCheckpointKey,
809
+ discovery: NonNullable<EnrichRun["acquireTelemetry"]>["discovery"],
810
+ hostedVersion: number | null,
811
+ ): Promise<void> {
812
+ const store = createFileAcquireCheckpointStore();
813
+ await store.put(key, {
814
+ cursor: discovery.cursor,
815
+ offset: discovery.offset,
816
+ exhausted: discovery.exhausted,
817
+ });
818
+ const hostedWrite = await writeHostedAcquireCheckpoint(
819
+ acquireCheckpointId(key),
820
+ discovery,
821
+ hostedVersion,
822
+ );
823
+ if (hostedWrite.status === "conflict" && hostedWrite.current) {
824
+ const current = hostedWrite.current.checkpoint;
825
+ await store.put(
826
+ key,
827
+ { cursor: current.cursor, offset: current.offset, exhausted: current.exhausted },
828
+ new Date(hostedWrite.current.updatedAt),
829
+ );
830
+ console.error(
831
+ "Acquisition checkpoint advanced concurrently in the hosted workspace; " +
832
+ "the newer hosted continuation was kept locally for the next run.",
833
+ );
834
+ } else if (hostedWrite.status === "unavailable") {
835
+ console.error(`Hosted checkpoint sync unavailable; local continuation retained (${hostedWrite.reason}).`);
836
+ }
612
837
  }
613
838
 
614
839
  /**
@@ -651,6 +876,11 @@ function formatAcquireMeter(headroom: AcquireRemaining, costPerRecord: number):
651
876
  );
652
877
  }
653
878
 
879
+ function hostedRunsUrl(): string | null {
880
+ const baseUrl = getCredential("broker")?.baseUrl?.replace(/\/+$/, "");
881
+ return baseUrl ? `${baseUrl}/dashboard/runs` : null;
882
+ }
883
+
654
884
  function resolveEnrichSource(config: EnrichConfig, rest: string[]): string {
655
885
  const requested = option(rest, "--source");
656
886
  const declared = Object.keys(config.sources);
@@ -702,7 +932,7 @@ function apolloApiKey(): string {
702
932
  async function openEnrichRun(
703
933
  store: EnrichRunStore,
704
934
  source: string,
705
- mode: "append" | "refresh",
935
+ mode: "append" | "refresh" | "acquire",
706
936
  requestedLabel: string | null,
707
937
  today: string,
708
938
  ): Promise<EnrichRun> {
@@ -861,6 +1091,7 @@ async function enrichStatus(store: EnrichRunStore, rest: string[], configFile: s
861
1091
  startedAt: last.startedAt,
862
1092
  completedAt: last.completedAt,
863
1093
  counts: last.counts,
1094
+ acquireTelemetry: last.acquireTelemetry,
864
1095
  planIds: last.planIds,
865
1096
  },
866
1097
  interrupted: interrupted.map((run) => ({ runLabel: run.runLabel, cursor: run.cursor })),
@@ -888,6 +1119,20 @@ async function enrichStatus(store: EnrichRunStore, rest: string[], configFile: s
888
1119
  ` ${last.counts.ambiguous} ambiguous, ${last.counts.opsEmitted} ops` +
889
1120
  (last.planIds.length ? ` · plans: ${last.planIds.join(", ")}` : ""),
890
1121
  );
1122
+ if (last.acquireTelemetry) {
1123
+ const f = last.acquireTelemetry.funnel;
1124
+ const d = last.acquireTelemetry.discovery;
1125
+ console.log(
1126
+ ` funnel: ${f.discovered} discovered → ${f.qualified} qualified → ` +
1127
+ `${f.resolved} resolved → ${f.proposed} proposed` +
1128
+ ` · skipped ${f.skippedCrm} CRM + ${f.skippedSeen} seen` +
1129
+ (f.withheldByMeter ? ` · ${f.withheldByMeter} meter-withheld` : ""),
1130
+ );
1131
+ console.log(
1132
+ ` audience: ${d.exhausted ? "exhausted" : "continuing"}` +
1133
+ `${d.cursor ? " · cursor saved" : d.offset ? ` · offset ${d.offset}` : ""}`,
1134
+ );
1135
+ }
891
1136
  for (const run of entry.interrupted) {
892
1137
  console.log(` interrupted: ${run.runLabel} at cursor ${run.cursor ?? "(start)"} — re-run with --save to resume`);
893
1138
  }
package/src/cli/fix.ts CHANGED
@@ -22,6 +22,7 @@ import { progressReporter } from "../runReport.ts";
22
22
  import type { PatchPlan, PatchPlanRun } from "../types.ts";
23
23
  import { confirmRequested, connectorFor, isOptionValue, numericOption, option, readSnapshot, repeatedOption, saveRequested } from "./shared.ts";
24
24
  import { box, colorEnabled, createProgressRenderer, paint } from "./ui.ts";
25
+ import { compactPlan, verbosePlanRequested } from "./planOutput.ts";
25
26
 
26
27
 
27
28
  /**
@@ -107,8 +108,10 @@ async function emitPlan(plan: PatchPlan, args: string[]) {
107
108
  }
108
109
  if (args.includes("--json")) {
109
110
  console.log(JSON.stringify(plan, null, 2));
110
- } else {
111
+ } else if (verbosePlanRequested(args)) {
111
112
  console.log(patchPlanToMarkdown(plan));
113
+ } else {
114
+ console.log(compactPlan(plan, { saved: saveRequested(args) }));
112
115
  }
113
116
  }
114
117
 
@@ -181,8 +184,7 @@ export async function reassignCommand(args: string[]) {
181
184
  const store = saveRequested(args) ? createFilePlanStore() : null;
182
185
  for (const plan of plans) {
183
186
  if (store) await store.save(plan);
184
- console.log(`${plan.id} ${String(plan.operations.length).padStart(3)} operation(s) ${plan.title}`);
185
- console.log(` ${plan.summary}`);
187
+ console.log(verbosePlanRequested(args) ? patchPlanToMarkdown(plan) : compactPlan(plan, { saved: Boolean(store) }));
186
188
  }
187
189
  if (store) {
188
190
  console.log(