fullstackgtm 0.49.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/DATA-FLOWS.md +1 -0
  3. package/README.md +41 -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/enrich.js +283 -79
  9. package/dist/cli/help.js +13 -9
  10. package/dist/cli/plans.js +100 -6
  11. package/dist/cli/shared.d.ts +3 -1
  12. package/dist/cli/shared.js +2 -2
  13. package/dist/cli/ui.d.ts +10 -5
  14. package/dist/cli/ui.js +16 -5
  15. package/dist/connectors/linkedin.d.ts +2 -0
  16. package/dist/connectors/linkedin.js +5 -0
  17. package/dist/connectors/prospectSources.d.ts +23 -0
  18. package/dist/connectors/prospectSources.js +21 -3
  19. package/dist/enrich.d.ts +44 -1
  20. package/dist/hostedAcquireCheckpoint.d.ts +43 -0
  21. package/dist/hostedAcquireCheckpoint.js +129 -0
  22. package/dist/hostedPatchPlan.d.ts +87 -0
  23. package/dist/hostedPatchPlan.js +270 -0
  24. package/dist/icp.js +13 -2
  25. package/dist/index.d.ts +4 -1
  26. package/dist/index.js +3 -0
  27. package/dist/planStore.d.ts +14 -0
  28. package/dist/planStore.js +73 -0
  29. package/dist/progress.d.ts +2 -2
  30. package/dist/progress.js +2 -2
  31. package/docs/api.md +24 -0
  32. package/docs/architecture.md +9 -0
  33. package/package.json +1 -1
  34. package/skills/fullstackgtm/SKILL.md +1 -1
  35. package/src/acquireCheckpoint.ts +186 -0
  36. package/src/acquireLinkedIn.ts +3 -1
  37. package/src/cli/enrich.ts +322 -78
  38. package/src/cli/help.ts +14 -9
  39. package/src/cli/plans.ts +95 -6
  40. package/src/cli/shared.ts +2 -1
  41. package/src/cli/ui.ts +18 -9
  42. package/src/connectors/linkedin.ts +6 -0
  43. package/src/connectors/prospectSources.ts +46 -4
  44. package/src/enrich.ts +47 -1
  45. package/src/hostedAcquireCheckpoint.ts +156 -0
  46. package/src/hostedPatchPlan.ts +286 -0
  47. package/src/icp.ts +14 -2
  48. package/src/index.ts +20 -0
  49. package/src/planStore.ts +87 -0
  50. package/src/progress.ts +2 -2
package/src/cli/help.ts CHANGED
@@ -180,7 +180,7 @@ Usage:
180
180
  fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
181
181
  derive values for requires_human_* placeholders
182
182
  from snapshot evidence, with confidence + reasons
183
- fullstackgtm plans list [--status <s>] | show <id> | reject <id>
183
+ fullstackgtm plans list [--status <s>] | show <id> | sync | reject <id>
184
184
  fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]
185
185
  fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
186
186
  fullstackgtm plans recover <id> --acknowledge-uncertain-writes
@@ -204,7 +204,11 @@ Profiles (multi-organization use):
204
204
  Plan lifecycle:
205
205
  audit --save persists the dry-run plan to ~/.fullstackgtm/plans. Approve
206
206
  specific operations (optionally with --value <opId>=<v> for placeholders),
207
- then apply by id — the store enforces approval and records every run.
207
+ then apply by id — the store enforces approval and records every run. When
208
+ paired, local and hosted are replicas of the same immutable plan: either
209
+ capable surface may approve or apply, and each CLI check-in imports missing
210
+ approvals and exact operation receipts. Use \`plans sync\` for an explicit
211
+ check-in; offline changes reconcile later without blocking local work.
208
212
 
209
213
  Authentication (checked in order):
210
214
  1. --token-env <name> explicit env var for this invocation (hubspot)
@@ -543,15 +547,16 @@ export const HELP: Record<string, HelpEntry> = {
543
547
  seeAlso: ["audit", "plans", "apply"],
544
548
  },
545
549
  plans: {
546
- summary: "plan lifecycle: list / show / approve / reject saved plans",
550
+ summary: "replicated plan lifecycle: list / show / sync / approve / reject",
547
551
  phase: "Govern",
548
552
  synopsis: [
549
- "fullstackgtm plans list [--status <s>] | show <id> | reject <id>",
553
+ "fullstackgtm plans list [--status <s>] | show <id> | sync | reject <id>",
550
554
  "fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]",
551
555
  "fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low]",
552
556
  "fullstackgtm plans recover <id> --acknowledge-uncertain-writes",
553
557
  ],
554
- detail: "Approval is explicit and per-operation; placeholders need a concrete --value or a suggestion to be approvable.",
558
+ detail:
559
+ "Approval is explicit and per-operation; placeholders need a concrete --value or a suggestion to be approvable. When paired, hosted and CLI are eventually consistent replicas of one immutable plan. `plans sync` explicitly exchanges approval revisions, execution state, and exact per-operation receipts; ordinary plan commands also check in best-effort. Either surface may apply when its CRM connector supports the selected operations.",
555
560
  seeAlso: ["audit", "suggest", "apply"],
556
561
  },
557
562
  apply: {
@@ -563,7 +568,7 @@ export const HELP: Record<string, HelpEntry> = {
563
568
  "fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [--value <opId>=<v>]",
564
569
  ],
565
570
  detail:
566
- "The only verb that mutates a CRM. Writes only operations approved via `plans approve` or `--approve`, with compare-and-set and readback. Never writes requires_human_* placeholders without a --value override.",
571
+ "The CLI CRM-write verb. Writes only operations approved locally or imported from the shared hosted replica, with an online execution claim when available, stable operation ids, compare-and-set, resolve-before-create, and readback. Hosted may execute the same synchronized plan when it has a compatible CRM connection; its exact operation receipts are imported on the next CLI check-in. Never writes requires_human_* placeholders without a --value override.",
567
572
  seeAlso: ["plans", "suggest", "audit-log"],
568
573
  },
569
574
  "audit-log": {
@@ -681,7 +686,7 @@ export const COMMAND_FLAGS: Record<string, string[]> = {
681
686
  dedupe: [...SOURCE_FLAGS, "--key", "--keep", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
682
687
  reassign: [...SOURCE_FLAGS, "--from", "--assign-unowned", "--to", "--objects", "--where", "--except-deal-stage", "--include-closed-deals", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
683
688
  backfill: [...SOURCE_FLAGS, "--since", "--pipeline", "--match-property", "--skip-unmatched", "--save", "--dry-run", "--json"],
684
- enrich: [...SOURCE_FLAGS, "--config", "--source", "--icp", "--input", "--provider", "--stale-days", "--assign-owner", "--objects", "--max", "--staged-run", "--run-label", "--label", "--runs", "--save", "--dry-run", "--json", "--out"],
689
+ enrich: [...SOURCE_FLAGS, "--config", "--source", "--icp", "--input", "--provider", "--list", "--stale-days", "--assign-owner", "--objects", "--max", "--scan-limit", "--staged-run", "--run-label", "--label", "--runs", "--save", "--dry-run", "--json", "--out"],
685
690
  call: [...SOURCE_FLAGS, "--transcript", "--title", "--source", "--model", "--deterministic", "--heuristics", "--llm", "--json", "--ndjson", "--out", "--call", "--call-type", "--rubric", "--list", "--list-rubrics", "--attendees", "--domain", "--deal", "--save", "--dry-run"],
686
691
  suggest: [...SOURCE_FLAGS, "--plan-id", "--plan", "--json", "--out"],
687
692
  plans: ["--status", "--operations", "--value", "--values-from", "--min-confidence", "--include-creates", "--acknowledge-uncertain-writes", "--json"],
@@ -693,11 +698,11 @@ export const COMMAND_FLAGS: Record<string, string[]> = {
693
698
  icp: [...SOURCE_FLAGS, "--name", "--signals-from", "--with-history", "--prompt", "--min-score", "--from-judge", "--golden", "--against-outcomes", "--min-accuracy", "--model", "--label", "--save", "--json", "--out"],
694
699
  signals: ["--bucket", "--source", "--connector", "--connector-opt", "--watchlist", "--keywords", "--from", "--label", "--since", "--account", "--contact", "--unjudged", "--touch", "--result", "--save", "--json", "--explain"],
695
700
  draft: ["--from-judge", "--min-score", "--prompt", "--model", "--channel", "--save", "--dry-run", "--json", "--out"],
696
- schedule: ["--cron", "--label", "--provider", "--trigger", "--runs", "--json"],
701
+ schedule: ["--cron", "--label", "--provider", "--trigger", "--timer", "--runs", "--json"],
697
702
  };
698
703
 
699
704
  export const FLAGS_WITH_VALUES = new Set([
700
- "--account", "--account-id", "--accounts", "--acv", "--acv-basis", "--after", "--anchor", "--api-key", "--approve", "--archive", "--assign-owner", "--attendees", "--before", "--bucket", "--buyers-per-account", "--call", "--call-type", "--calls", "--capture-run", "--category", "--channel", "--client", "--client-id", "--client-secret", "--config", "--connector", "--connector-opt", "--contact", "--cron", "--cross-checks", "--deal", "--deal-period", "--diff", "--domain", "--email", "--except-deal-stage", "--format", "--from", "--from-judge", "--golden", "--guard", "--icp", "--in", "--input", "--instance-url", "--keep", "--key", "--keywords", "--label", "--login-url", "--match", "--match-property", "--max", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--min-accuracy", "--min-confidence", "--min-mentions", "--min-score", "--model", "--name", "--objects", "--operations", "--out", "--pipeline", "--plan", "--plan-id", "--policy", "--port", "--prepared-by", "--prior-run", "--profile", "--promote-lift", "--prompt", "--provider", "--reason", "--require", "--result", "--rubric", "--rule", "--rules", "--run", "--run-label", "--runs", "--scopes", "--seed", "--set", "--signals-from", "--since", "--snapshot", "--source", "--staged-run", "--stale-days", "--status", "--task-account", "--task-deal", "--title", "--to", "--today", "--token", "--token-env", "--touch", "--transcript", "--trigger", "--usd-per-credit", "--value", "--values-from", "--vendor", "--via", "--watchlist", "--where",
705
+ "--account", "--account-id", "--accounts", "--acv", "--acv-basis", "--after", "--anchor", "--api-key", "--approve", "--archive", "--assign-owner", "--attendees", "--before", "--bucket", "--buyers-per-account", "--call", "--call-type", "--calls", "--capture-run", "--category", "--channel", "--client", "--client-id", "--client-secret", "--config", "--connector", "--connector-opt", "--contact", "--cron", "--cross-checks", "--deal", "--deal-period", "--diff", "--domain", "--email", "--except-deal-stage", "--format", "--from", "--from-judge", "--golden", "--guard", "--icp", "--in", "--input", "--instance-url", "--keep", "--key", "--keywords", "--label", "--list", "--login-url", "--match", "--match-property", "--max", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--min-accuracy", "--min-confidence", "--min-mentions", "--min-score", "--model", "--name", "--objects", "--operations", "--out", "--pipeline", "--plan", "--plan-id", "--policy", "--port", "--prepared-by", "--prior-run", "--profile", "--promote-lift", "--prompt", "--provider", "--reason", "--require", "--result", "--rubric", "--rule", "--rules", "--run", "--run-label", "--runs", "--scan-limit", "--scopes", "--seed", "--set", "--signals-from", "--since", "--snapshot", "--source", "--staged-run", "--stale-days", "--status", "--task-account", "--task-deal", "--timer", "--title", "--to", "--today", "--token", "--token-env", "--touch", "--transcript", "--trigger", "--usd-per-credit", "--value", "--values-from", "--vendor", "--via", "--watchlist", "--where",
701
706
  ]);
702
707
 
703
708
  // Lifecycle-grouped front door. One line per verb, organized by the
package/src/cli/plans.ts CHANGED
@@ -15,6 +15,7 @@ import { createFilePlanStore } from "../planStore.ts";
15
15
  import { ENRICH_CONFIG_FILE_NAME, loadEnrichConfig, type EnrichConfig } from "../enrich.ts";
16
16
  import { loadMeter, recordConsumption, remaining } from "../acquireMeter.ts";
17
17
  import { progressReporter, reportCounts } from "../runReport.ts";
18
+ import { claimHostedPlanApply, reconcileHostedPatchPlan, releaseHostedPlanApply, reportHostedPlanLifecycle, uploadHostedPatchPlan } from "../hostedPatchPlan.ts";
18
19
  import { APPLY_STAGES, composeListeners, createProgressEmitter } from "../progress.ts";
19
20
  import type { ValueSuggestion } from "../suggest.ts";
20
21
  import type { CanonicalGtmSnapshot, CreateRecordPayload, PatchOperation, PatchPlan, PatchPlanRun } from "../types.ts";
@@ -141,8 +142,18 @@ export async function apply(args: string[]) {
141
142
  const store = planId ? createFilePlanStore() : null;
142
143
 
143
144
  if (planId && store) {
144
- const stored = await store.get(planId);
145
+ let stored = await store.get(planId);
145
146
  if (!stored) throw new Error(`No stored plan with id ${planId}.`);
147
+ const reconciliation = await reconcileHostedPatchPlan(store, stored);
148
+ if (reconciliation.status === "conflict") throw new Error(`Refusing to apply plan ${planId}: ${reconciliation.reason}.`);
149
+ if (reconciliation.status === "updated") {
150
+ stored = reconciliation.stored;
151
+ console.error(`Synced hosted ${reconciliation.remoteStatus} state for ${planId}.`);
152
+ }
153
+ if (stored.status === "applied") {
154
+ console.log(`Plan ${planId} was already applied by another replica; imported its execution receipt. No provider writes were attempted.`);
155
+ return;
156
+ }
146
157
  if (stored.status !== "approved") {
147
158
  throw new Error(
148
159
  `Plan ${planId} is ${stored.status}; approve operations first with \`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`,
@@ -235,9 +246,29 @@ export async function apply(args: string[]) {
235
246
  // transmits nothing; a CRM provider writes records. Same governed apply path.
236
247
  const connector = channel ? createChannelConnector(channel) : await connectorFor(provider!, args);
237
248
  let applyClaimId: string | undefined;
249
+ let hostedApplyClaimId: string | undefined;
238
250
  if (planId && store) {
239
251
  const claimed = await store.claimApply(planId, { provider: provider ?? `channel:${channel}`, source: "cli" });
240
252
  applyClaimId = claimed.claimId;
253
+ const hostedClaim = await claimHostedPlanApply(claimed.stored);
254
+ if (hostedClaim.status === "applied") {
255
+ await store.abortApplyPreflight(planId, claimed.claimId, "Another replica completed apply before provider I/O.");
256
+ const refreshed = await store.get(planId);
257
+ if (refreshed) await reconcileHostedPatchPlan(store, refreshed);
258
+ console.log(`Plan ${planId} was already applied by another replica. No provider writes were attempted.`);
259
+ return;
260
+ }
261
+ if (hostedClaim.status === "conflict") {
262
+ await store.abortApplyPreflight(planId, claimed.claimId, `Hosted execution claim failed: ${hostedClaim.reason}`);
263
+ throw new Error(`Refusing to apply plan ${planId}: could not coordinate with its hosted replica (${hostedClaim.reason}).`);
264
+ }
265
+ if (hostedClaim.status === "unavailable") {
266
+ console.error(`Hosted replica is unavailable (${hostedClaim.reason}); continuing local-first. Resolve-before-create and provider conflict guards remain active, and the execution receipt will sync on a later check-in.`);
267
+ }
268
+ if (hostedClaim.status === "claimed") {
269
+ hostedApplyClaimId = hostedClaim.claimId;
270
+ await store.recordHostedClaim(planId, claimed.claimId, hostedClaim.claimId);
271
+ }
241
272
  const claimedVerification = verifyApprovalDigests(
242
273
  claimed.stored.plan.operations,
243
274
  claimed.stored.approvedOperationIds,
@@ -245,6 +276,16 @@ export async function apply(args: string[]) {
245
276
  claimed.stored.approvalDigests,
246
277
  );
247
278
  if (!claimedVerification.ok) {
279
+ if (hostedApplyClaimId) {
280
+ const released = await releaseHostedPlanApply(
281
+ claimed.stored,
282
+ hostedApplyClaimId,
283
+ "Approval integrity verification failed after claim and before provider I/O.",
284
+ );
285
+ if (released.status === "conflict" || released.status === "unavailable") {
286
+ console.error(`Hosted apply claim may require recovery: ${released.reason}.`);
287
+ }
288
+ }
248
289
  await store.abortApplyPreflight(
249
290
  planId,
250
291
  claimed.claimId,
@@ -280,7 +321,12 @@ export async function apply(args: string[]) {
280
321
  renderer.done();
281
322
  }
282
323
  if (planId && store) {
283
- await store.recordRun(planId, run, applyClaimId!);
324
+ const recorded = await store.recordRun(planId, run, applyClaimId!);
325
+ const durableHostedClaimId = recorded.applyAttempts?.find((attempt) => attempt.id === applyClaimId)?.hostedClaimId;
326
+ const mirrored = await reportHostedPlanLifecycle(recorded, { claimId: durableHostedClaimId ?? hostedApplyClaimId });
327
+ if (mirrored.status === "unavailable" || mirrored.status === "conflict") {
328
+ console.error(`Hosted plan status is stale: ${mirrored.reason}. Local apply history remains authoritative.`);
329
+ }
284
330
  }
285
331
 
286
332
  // Charge the acquire meter for the creates that actually landed.
@@ -408,7 +454,13 @@ export async function plansCommand(args: string[]) {
408
454
  const status = option(rest, "--status") as
409
455
  | "draft" | "needs_approval" | "approved" | "rejected" | "applied"
410
456
  | null;
411
- const plans = await store.list(status ?? undefined);
457
+ let plans = await store.list(status ?? undefined);
458
+ const synced = await Promise.all(plans.map(async (stored) => {
459
+ const result = await reconcileHostedPatchPlan(store, stored);
460
+ if (result.status === "updated") return result.stored;
461
+ return stored;
462
+ }));
463
+ plans = status ? synced.filter((stored) => stored.status === status) : synced;
412
464
  if (rest.includes("--json") || args.includes("--json")) {
413
465
  console.log(
414
466
  JSON.stringify(
@@ -473,8 +525,15 @@ export async function plansCommand(args: string[]) {
473
525
  if (subcommand === "show") {
474
526
  const planId = rest.find((arg) => !arg.startsWith("--"));
475
527
  if (!planId) throw new Error("Usage: fullstackgtm plans show <planId>");
476
- const stored = await store.get(planId);
528
+ let stored = await store.get(planId);
477
529
  if (!stored) throw new Error(`No stored plan with id ${planId}.`);
530
+ const reconciliation = await reconcileHostedPatchPlan(store, stored);
531
+ if (reconciliation.status === "updated") {
532
+ stored = reconciliation.stored;
533
+ console.error(`Synced hosted ${reconciliation.remoteStatus} state for ${planId}.`);
534
+ } else if (reconciliation.status === "conflict") {
535
+ console.error(`Hosted sync conflict: ${reconciliation.reason}.`);
536
+ }
478
537
  if (rest.includes("--json")) {
479
538
  console.log(JSON.stringify(stored, null, 2));
480
539
  return;
@@ -483,6 +542,9 @@ export async function plansCommand(args: string[]) {
483
542
  console.log(`Status: ${planStatusWord(stored.status, showPaint)}`);
484
543
  console.log(`Approved operations: ${stored.approvedOperationIds.join(", ") || "none"}`);
485
544
  console.log(`Runs: ${stored.runs.length}`);
545
+ const mirror = await uploadHostedPatchPlan(stored);
546
+ if (mirror.status === "saved") console.log(`Hosted review: ${mirror.state.url}`);
547
+ else if (mirror.status === "unavailable" || mirror.status === "conflict") console.log(`Hosted review: sync pending (${mirror.reason})`);
486
548
  if (stored.applyAttempts?.length) {
487
549
  console.log("Apply attempts:");
488
550
  for (const attempt of stored.applyAttempts) {
@@ -541,6 +603,10 @@ export async function plansCommand(args: string[]) {
541
603
  ...fileOverrides,
542
604
  ...explicitOverrides,
543
605
  });
606
+ const mirror = await reportHostedPlanLifecycle(updated);
607
+ if (mirror.status === "unavailable" || mirror.status === "conflict") {
608
+ console.error(`Hosted plan status is stale: ${mirror.reason}. Local approval remains authoritative.`);
609
+ }
544
610
  console.log(
545
611
  `Approved ${updated.approvedOperationIds.length} operation(s) on ${planId}. Apply with \`fullstackgtm apply --plan-id ${planId} --provider <name>\`.`,
546
612
  );
@@ -550,7 +616,11 @@ export async function plansCommand(args: string[]) {
550
616
  if (subcommand === "reject") {
551
617
  const planId = rest.find((arg) => !arg.startsWith("--"));
552
618
  if (!planId) throw new Error("Usage: fullstackgtm plans reject <planId>");
553
- await store.reject(planId);
619
+ const rejected = await store.reject(planId);
620
+ const mirror = await reportHostedPlanLifecycle(rejected);
621
+ if (mirror.status === "unavailable" || mirror.status === "conflict") {
622
+ console.error(`Hosted plan status is stale: ${mirror.reason}. Local rejection remains authoritative.`);
623
+ }
554
624
  console.log(`Rejected ${planId}.`);
555
625
  return;
556
626
  }
@@ -581,5 +651,24 @@ export async function plansCommand(args: string[]) {
581
651
  return;
582
652
  }
583
653
 
584
- throw unknownSubcommandError("plans", subcommand, ["list", "show", "approve", "reject", "recover"]);
654
+ if (subcommand === "sync") {
655
+ const plans = await store.list();
656
+ let updated = 0;
657
+ const warnings: string[] = [];
658
+ for (const stored of plans) {
659
+ const result = await reconcileHostedPatchPlan(store, stored);
660
+ if (result.status === "updated") updated += 1;
661
+ else if (result.status === "conflict" || result.status === "unavailable") warnings.push(`${stored.plan.id}: ${result.reason}`);
662
+ if (stored.status === "applied") {
663
+ const hostedClaimId = [...(stored.applyAttempts ?? [])].reverse().find((attempt) => attempt.hostedClaimId)?.hostedClaimId;
664
+ const pushed = await reportHostedPlanLifecycle(stored, { claimId: hostedClaimId });
665
+ if (pushed.status === "conflict" || pushed.status === "unavailable") warnings.push(`${stored.plan.id} receipt: ${pushed.reason}`);
666
+ }
667
+ }
668
+ console.log(`Plan replicas synchronized: ${updated} updated, ${plans.length - updated} unchanged.`);
669
+ for (const warning of warnings) console.error(` ${warning}`);
670
+ return;
671
+ }
672
+
673
+ throw unknownSubcommandError("plans", subcommand, ["list", "show", "sync", "approve", "reject", "recover"]);
585
674
  }
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")) {
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 }>,
@@ -343,13 +344,20 @@ export function createChecklist(
343
344
  const entry = states.get(id);
344
345
  if (!entry) return;
345
346
  entry.state = state;
346
- entry.note = note;
347
+ if (note !== undefined) entry.note = note;
347
348
  start();
348
349
  render();
349
350
  },
350
- done() {
351
+ done(options = {}) {
351
352
  if (timer) clearInterval(timer);
352
353
  timer = null;
354
+ if (options.persist) {
355
+ // The caller's final state update already painted the completed board.
356
+ // Freeze that frame in scrollback; repainting here can leave both the
357
+ // last live frame and the final frame visible in some terminals.
358
+ painted = 0;
359
+ return;
360
+ }
353
361
  if (painted > 0) {
354
362
  // Erase the board: cursor up over every painted line, clear each.
355
363
  stream.write(`\u001b[${painted}A${"\u001b[2K\n".repeat(painted)}\u001b[${painted}A`);
@@ -366,8 +374,8 @@ export type ProgressRenderer = {
366
374
  * listener via `composeListeners` when the run should also heartbeat).
367
375
  */
368
376
  listener: ProgressListener;
369
- /** Stop animating and erase the board (callers print their own summary). */
370
- done(): void;
377
+ /** Stop animating; optionally leave the completed board in terminal history. */
378
+ done(options?: { persist?: boolean }): void;
371
379
  readonly active: boolean;
372
380
  };
373
381
 
@@ -423,8 +431,9 @@ export function createProgressRenderer(
423
431
  // larger flow) are ignored by createChecklist's unknown-id no-op.
424
432
  if (current) board.update(current, "running", noteFor(event, snapshot));
425
433
  },
426
- done() {
427
- board.done();
434
+ done(options) {
435
+ if (current) board.update(current, "ok");
436
+ board.done(options);
428
437
  },
429
438
  active: true,
430
439
  };
@@ -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. */