fullstackgtm 0.50.0 → 0.51.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 (66) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +7 -0
  3. package/dist/cli/audit.js +6 -1
  4. package/dist/cli/auth.js +49 -4
  5. package/dist/cli/backfill.js +4 -0
  6. package/dist/cli/call.js +28 -6
  7. package/dist/cli/draft.js +11 -1
  8. package/dist/cli/enrich.js +124 -57
  9. package/dist/cli/fix.js +6 -3
  10. package/dist/cli/help.js +31 -28
  11. package/dist/cli/icp.js +15 -1
  12. package/dist/cli/init.js +3 -3
  13. package/dist/cli/market.js +14 -1
  14. package/dist/cli/planOutput.d.ts +5 -0
  15. package/dist/cli/planOutput.js +27 -0
  16. package/dist/cli/plans.js +184 -33
  17. package/dist/cli/schedule.js +22 -11
  18. package/dist/cli/signals.js +22 -3
  19. package/dist/cli/tam.d.ts +1 -1
  20. package/dist/cli/tam.js +14 -2
  21. package/dist/cli/ui.js +14 -6
  22. package/dist/connectors/clay.d.ts +33 -0
  23. package/dist/connectors/clay.js +123 -0
  24. package/dist/connectors/hubspot.d.ts +4 -0
  25. package/dist/connectors/hubspot.js +36 -18
  26. package/dist/connectors/prospectSources.d.ts +12 -0
  27. package/dist/connectors/prospectSources.js +1 -1
  28. package/dist/contactProviders.d.ts +44 -0
  29. package/dist/contactProviders.js +100 -0
  30. package/dist/enrich.d.ts +7 -1
  31. package/dist/enrich.js +46 -1
  32. package/dist/hostedPatchPlan.js +6 -1
  33. package/dist/icp.d.ts +2 -0
  34. package/dist/icp.js +49 -0
  35. package/dist/index.d.ts +2 -0
  36. package/dist/index.js +2 -0
  37. package/dist/init.d.ts +1 -1
  38. package/dist/init.js +1 -1
  39. package/docs/api.md +18 -1
  40. package/package.json +1 -1
  41. package/src/cli/audit.ts +5 -1
  42. package/src/cli/auth.ts +46 -4
  43. package/src/cli/backfill.ts +3 -0
  44. package/src/cli/call.ts +25 -6
  45. package/src/cli/draft.ts +9 -1
  46. package/src/cli/enrich.ts +132 -56
  47. package/src/cli/fix.ts +5 -3
  48. package/src/cli/help.ts +31 -28
  49. package/src/cli/icp.ts +13 -1
  50. package/src/cli/init.ts +3 -3
  51. package/src/cli/market.ts +12 -1
  52. package/src/cli/planOutput.ts +30 -0
  53. package/src/cli/plans.ts +174 -29
  54. package/src/cli/schedule.ts +21 -15
  55. package/src/cli/signals.ts +22 -3
  56. package/src/cli/tam.ts +12 -3
  57. package/src/cli/ui.ts +15 -6
  58. package/src/connectors/clay.ts +155 -0
  59. package/src/connectors/hubspot.ts +41 -17
  60. package/src/connectors/prospectSources.ts +7 -1
  61. package/src/contactProviders.ts +141 -0
  62. package/src/enrich.ts +51 -2
  63. package/src/hostedPatchPlan.ts +6 -1
  64. package/src/icp.ts +51 -0
  65. package/src/index.ts +25 -0
  66. package/src/init.ts +2 -2
package/CHANGELOG.md CHANGED
@@ -7,6 +7,51 @@ The path to 1.0 is planned in [docs/roadmap-to-1.0.md](https://github.com/fullst
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.51.0] — 2026-07-11
11
+
12
+ ### Added
13
+
14
+ - Native Clay Public API authentication and people search for governed lead
15
+ acquisition, including ICP-to-Clay filter translation and resumable search
16
+ checkpoints.
17
+ - A provider-neutral, fill-only contact waterfall contract for work email,
18
+ mobile, and direct-dial enrichment, with Pipe0 as the first registered
19
+ provider and compatibility for existing Pipe0 email resolution.
20
+ - Clay acquisition presets, initialization, doctor diagnostics, public API
21
+ exports, strategy documentation, and live-safe credential validation.
22
+
23
+ ### Changed
24
+
25
+ - `enrich acquire` now uses the shared decision-first presentation contract:
26
+ compact cards by default, full evidence with `--verbose`, and a stable JSON
27
+ envelope with counts, cost, meter, and persistence state under `--json`.
28
+ - Pre-enrichment deduplication and avoided-spend reporting are provider-neutral
29
+ so the same acquisition pipeline can support additional contact sources.
30
+
31
+ ## [0.50.1] — 2026-07-10
32
+
33
+ ### Added
34
+
35
+ - Decision-first CLI presentation across plan previews, plan queues, apply
36
+ outcomes, doctor, schedules, signals, ICP, market, TAM, and call analysis.
37
+ Human defaults use compact cards and ranked summaries; `--verbose` restores
38
+ full audit detail and `--json` retains the stable machine contract.
39
+
40
+ ### Fixed
41
+
42
+ - HubSpot 429/5xx responses now retry with bounded backoff, honor
43
+ `Retry-After`, and report accurate live throttle feedback.
44
+ - Multi-line progress boards no longer scroll intermediate paint frames into
45
+ terminal history.
46
+ - `plans sync` reconciles historical workspaces concurrently with visible
47
+ progress, skips absent legacy mirrors, and avoids echoing hosted-origin
48
+ receipts back to hosted.
49
+ - CLI apply reports only the exact approved receipt subset to hosted while
50
+ retaining excluded rows in the complete local audit trail.
51
+ - Plan and apply output now distinguish approved, applied, excluded, skipped,
52
+ and failed operations without exposing the immutable document's stale
53
+ creation-time status as the current lifecycle state.
54
+
10
55
  ## [0.50.0] — 2026-07-10
11
56
 
12
57
  ### Added
package/README.md CHANGED
@@ -243,6 +243,13 @@ replica learns what happened rather than inferring it from plan status. A
243
243
  conflicting plan body or approval revision is surfaced for review and is never
244
244
  merged by expanding authority.
245
245
 
246
+ Interactive command output is decision-first by default: compact cards show
247
+ status, selected scope, expected effect, per-record outcomes, hosted links, and
248
+ the next safe command. Use `--verbose` on supported human-facing commands for
249
+ the complete findings/evidence/payload document; use `--json` for the stable
250
+ machine-readable contract. Progress and warnings remain on stderr, while JSON
251
+ and requested report data remain clean on stdout.
252
+
246
253
  Local signing keys and HMAC digests never upload. Operation before/after values
247
254
  do upload to the paired organization because they are required for informed
248
255
  review and hosted execution.
package/dist/cli/audit.js CHANGED
@@ -11,6 +11,7 @@ import { auditReportToHtml, auditReportToMarkdown } from "../report.js";
11
11
  import { reportCounts, reportCrm, reportFindings } from "../runReport.js";
12
12
  import { connectorFor, numericOption, option, readSnapshot, saveRequested, selectedRules } from "./shared.js";
13
13
  import { colorEnabled, createChecklist, formatCount, paint, scoreColor, sparkline, stylizePlanMarkdown, table } from "./ui.js";
14
+ import { compactPlan, verbosePlanRequested } from "./planOutput.js";
14
15
  const SEVERITY_RANK = {
15
16
  info: 0,
16
17
  warning: 1,
@@ -241,11 +242,15 @@ export async function audit(args) {
241
242
  if (args.includes("--json")) {
242
243
  console.log(JSON.stringify(plan, null, 2));
243
244
  }
245
+ else if (!verbosePlanRequested(args)) {
246
+ console.log(compactPlan(plan, { saved: saveRequested(args) }));
247
+ console.error(`\n${auditNextStep(args, plan)}`);
248
+ }
244
249
  else {
245
250
  // Default to the summary view (rule table + counts); the full per-operation
246
251
  // dump is opt-in via --full or, for a deliverable, `report`. (dx-punch-list #3)
247
252
  // Interactive terminals get the styled rendering; piped output is unchanged.
248
- console.log(stylizePlanMarkdown(patchPlanToMarkdown(plan, { summary: !args.includes("--full") }), paint(colorEnabled(process.stdout))));
253
+ console.log(stylizePlanMarkdown(patchPlanToMarkdown(plan, { summary: false }), paint(colorEnabled(process.stdout))));
249
254
  console.error(`\n${auditNextStep(args, plan)}`);
250
255
  }
251
256
  if (threshold &&
package/dist/cli/auth.js CHANGED
@@ -7,6 +7,7 @@ import { activeProfile, credentialsPath, DEFAULT_PROFILE, deleteCredential, getC
7
7
  import { activeWorkspaceProfile, readHealthTimeline, summarizeHealth } from "../health.js";
8
8
  import { resolveLlmCredential, validateLlmKey } from "../llm.js";
9
9
  import { createFilePlanStore } from "../planStore.js";
10
+ import { validateClayApiKey } from "../connectors/clay.js";
10
11
  import { isOptionValue, numericOption, option, readPackageInfo, readSecret } from "./shared.js";
11
12
  import { box, colorEnabled, paint, scoreColor, sparkline } from "./ui.js";
12
13
  /**
@@ -351,6 +352,22 @@ export async function login(args) {
351
352
  console.log(`Stored Apollo API key in ${credentialsPath()}. \`fullstackgtm enrich append|refresh\` use it automatically.`);
352
353
  return;
353
354
  }
355
+ if (provider === "clay") {
356
+ rejectArgvSecret(args, "--token", "--key", "--api-key");
357
+ const key = await readSecret("Clay Public API key");
358
+ if (!key)
359
+ throw new Error("No Clay Public API key provided.");
360
+ if (!args.includes("--no-validate")) {
361
+ const validation = await validateClayApiKey(key);
362
+ if (!validation.ok)
363
+ throw new Error(validation.detail);
364
+ console.log(validation.detail);
365
+ }
366
+ const stamp = new Date().toISOString();
367
+ storeCredential("clay", { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
368
+ console.log(`Stored Clay Public API key in ${credentialsPath()}. Clay connector commands resolve it automatically.`);
369
+ return;
370
+ }
354
371
  if (provider === "pipe0" || provider === "explorium" || provider === "heyreach" || provider === "theirstack") {
355
372
  rejectArgvSecret(args, "--token", "--key", "--api-key");
356
373
  const key = await readSecret(`${provider} API key`);
@@ -365,7 +382,7 @@ export async function login(args) {
365
382
  return;
366
383
  }
367
384
  if (provider !== "hubspot") {
368
- throw new Error("login supports: hubspot, salesforce, stripe, anthropic, openai, apollo, pipe0, explorium, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
385
+ throw new Error("login supports: hubspot, salesforce, stripe, anthropic, openai, apollo, clay, pipe0, explorium, heyreach, theirstack, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
369
386
  }
370
387
  const now = new Date().toISOString();
371
388
  rejectArgvSecret(args, "--token");
@@ -450,6 +467,9 @@ export function doctorReport(env = process.env) {
450
467
  stripe: env.STRIPE_SECRET_KEY
451
468
  ? { source: "env", detail: "STRIPE_SECRET_KEY" }
452
469
  : providerStatus("stripe", broker),
470
+ clay: env.CLAY_API_KEY
471
+ ? { source: "env", detail: "CLAY_API_KEY" }
472
+ : providerStatus("clay", null),
453
473
  };
454
474
  const llm = resolveLlmCredential(env);
455
475
  const missingPeers = ["@modelcontextprotocol/sdk", "zod"].filter((name) => {
@@ -461,13 +481,13 @@ export function doctorReport(env = process.env) {
461
481
  return true;
462
482
  }
463
483
  });
464
- const connected = Object.entries(providers).filter(([, status]) => status.source !== "none");
465
- const nextSteps = connected.length === 0
484
+ const connectedCrm = ["hubspot", "salesforce"].filter((provider) => providers[provider].source !== "none");
485
+ const nextSteps = connectedCrm.length === 0
466
486
  ? [
467
487
  "fullstackgtm audit --demo # no credentials needed",
468
488
  "fullstackgtm login hubspot # hosted browser OAuth (default; or: login salesforce)",
469
489
  ]
470
- : [`fullstackgtm audit --provider ${connected[0][0]}`];
490
+ : [`fullstackgtm audit --provider ${connectedCrm[0]}`];
471
491
  return {
472
492
  package: packageInfo,
473
493
  node: { version: process.versions.node, ok: nodeMajor >= 20, required: ">=20" },
@@ -579,6 +599,31 @@ export async function doctorCommand(args) {
579
599
  const healthLine = workspace.auditCount === 0
580
600
  ? p.dim("no saved audits yet (fullstackgtm audit --provider <name> --save starts the timeline)")
581
601
  : `${scoreColor(workspace.healthScore ?? 0, p)}/100${delta} — ${workspace.auditCount} audit(s) saved, last ${workspace.lastAuditAt}${healthSparkline(workspace.profile, p.enabled)}`;
602
+ const connectedProviders = Object.entries(report.providers).filter(([, provider]) => provider.source !== "none");
603
+ const blockers = [
604
+ ...(!report.node.ok ? [`Node v${report.node.version} is unsupported; install ${report.node.required}.`] : []),
605
+ ...(connectedProviders.length === 0 ? ["No CRM connected."] : []),
606
+ ...(workspace.pendingPlans.length > 0 ? [`${workspace.pendingPlans.length} plan${workspace.pendingPlans.length === 1 ? "" : "s"} awaiting approval.`] : []),
607
+ ];
608
+ if (!args.includes("--verbose")) {
609
+ const ready = report.node.ok && connectedProviders.length > 0;
610
+ const compact = [
611
+ `${ready ? p.green("Ready") : p.yellow("Setup needed")} · ${report.package.name} ${report.package.version} · profile ${report.profile}`,
612
+ `CRM ${connectedProviders.length > 0 ? connectedProviders.map(([name]) => name).join(", ") : "not connected"}`,
613
+ `Health ${healthLine}`,
614
+ `Plans ${workspace.pendingPlans.length === 0 ? "none awaiting approval" : `${workspace.pendingPlans.length} awaiting approval · ${workspace.pendingPlans[0].id}`}`,
615
+ ...(blockers.length > 0 ? ["", "Attention", ...blockers.map((blocker) => ` ${blocker}`)] : []),
616
+ "",
617
+ "Next",
618
+ ...nextSteps.map((step) => ` ${step}`),
619
+ "",
620
+ "Run `fullstackgtm doctor --verbose` for credential paths and optional tooling checks.",
621
+ ];
622
+ console.log(p.enabled ? box(compact, p, "Workspace readiness").join("\n") : compact.join("\n"));
623
+ if (!report.node.ok)
624
+ process.exitCode = 1;
625
+ return;
626
+ }
582
627
  const lines = [
583
628
  `Package: ${p.bold(`${report.package.name} ${report.package.version}`)}`,
584
629
  `Node: v${report.node.version} (${report.node.required} required) ${mark(report.node.ok)}`,
@@ -14,6 +14,7 @@ import { progressReporter } from "../runReport.js";
14
14
  import { unknownSubcommandError } from "./suggest.js";
15
15
  import { option, readSnapshot, saveRequested } from "./shared.js";
16
16
  import { colorEnabled, createProgressRenderer, paint, stylizePlanMarkdown, table } from "./ui.js";
17
+ import { compactPlan, verbosePlanRequested } from "./planOutput.js";
17
18
  /** STRIPE_SECRET_KEY env ∨ stored `login stripe` credential (same ladder as `--provider stripe`). */
18
19
  function resolveStripeKey() {
19
20
  const key = process.env.STRIPE_SECRET_KEY ?? getCredential("stripe")?.accessToken;
@@ -73,6 +74,9 @@ export async function backfillCommand(args) {
73
74
  if (rest.includes("--json")) {
74
75
  console.log(JSON.stringify({ plan: result.plan, counts: result.counts, unmatched: result.unmatched, proposedAccounts: result.proposedAccounts }, null, 2));
75
76
  }
77
+ else if (!verbosePlanRequested(rest)) {
78
+ console.log(compactPlan(result.plan, { saved: save && result.plan.operations.length > 0 }));
79
+ }
76
80
  else {
77
81
  // TTY-only styling; piped output stays byte-identical plain text.
78
82
  console.log(stylizePlanMarkdown(patchPlanToMarkdown(result.plan), paint(colorEnabled(process.stdout))));
package/dist/cli/call.js CHANGED
@@ -161,11 +161,13 @@ score always needs a key (scoring is LLM work).`);
161
161
  console.log(JSON.stringify(parsed, null, 2));
162
162
  return;
163
163
  }
164
- console.log(`Call ${parsed.id}${parsed.title ? ` — ${parsed.title}` : ""} (${parsed.sourceSystem})`);
165
- console.log(`${parsed.segments.length} segments · ${parsed.insights.length} insights (${parsed.summary.highImportance} high-importance)\n`);
166
- for (const insight of parsed.insights) {
167
- console.log(`[${insight.type}] (importance ${insight.importance}) ${insight.text}`);
168
- }
164
+ console.log(`Call ${parsed.id}${parsed.title ? ` — ${parsed.title}` : ""}`);
165
+ console.log(`${parsed.segments.length} segments · ${parsed.insights.length} insights · ${parsed.summary.highImportance} high priority`);
166
+ const visible = rest.includes("--verbose") ? parsed.insights : parsed.insights.filter((insight) => insight.importance >= 3);
167
+ for (const insight of visible)
168
+ console.log(`\n${insight.type.replaceAll("_", " ")} · priority ${insight.importance}\n ${insight.text.replace(/\s+/g, " ").trim()}`);
169
+ if (!rest.includes("--verbose") && visible.length < parsed.insights.length)
170
+ console.log(`\n${parsed.insights.length - visible.length} lower-priority insight(s) hidden; use --verbose to show all.`);
169
171
  return;
170
172
  }
171
173
  if (subcommand === "link") {
@@ -284,11 +286,31 @@ score always needs a key (scoring is LLM work).`);
284
286
  console.log(JSON.stringify(scorecard, null, 2));
285
287
  return;
286
288
  }
287
- console.log(renderScorecard(scorecard, title));
289
+ console.log(rest.includes("--verbose") ? renderScorecard(scorecard, title) : renderCompactScorecard(scorecard, title));
288
290
  return;
289
291
  }
290
292
  throw new Error(`call supports: parse, classify, link, plan, score (got ${subcommand ?? "nothing"})`);
291
293
  }
294
+ function renderCompactScorecard(scorecard, title) {
295
+ const lines = [
296
+ `${title ?? "Call"} · ${scorecard.overallScore}/${scorecard.scale}${scorecard.band ? ` · ${scorecard.band.label}` : ""}`,
297
+ scorecard.rubricName ? `${scorecard.rubricName}${scorecard.callType ? ` (${scorecard.callType})` : ""}` : "",
298
+ "",
299
+ ].filter((line, index) => line || index === 2);
300
+ for (const dimension of scorecard.dimensions) {
301
+ lines.push(`${dimension.score}/${dimension.maxScore} ${dimension.name}`);
302
+ lines.push(` ${dimension.coachingNote.replace(/\s+/g, " ").trim()}`);
303
+ }
304
+ if (scorecard.missedItems.length) {
305
+ lines.push("", "Focus next");
306
+ for (const item of scorecard.missedItems)
307
+ lines.push(` ${item}`);
308
+ }
309
+ if (scorecard.highlights.length)
310
+ lines.push("", `Strengths: ${scorecard.highlights.join(" · ")}`);
311
+ lines.push("", "Use --verbose for the full coaching scorecard.");
312
+ return lines.join("\n");
313
+ }
292
314
  function renderScorecard(scorecard, title) {
293
315
  const bandText = scorecard.band ? ` — ${scorecard.band.label}` : "";
294
316
  const rubricLine = scorecard.rubricName ? `Rubric: ${scorecard.rubricName}${scorecard.callType ? ` (${scorecard.callType})` : ""}` : "";
package/dist/cli/draft.js CHANGED
@@ -7,6 +7,7 @@ import { createFileSignalStore } from "../signals.js";
7
7
  import { createFileJudgeStore } from "../judge.js";
8
8
  import { authorOpeners, DEFAULT_DRAFT_PROMPT, DRAFT_CHANNELS, draft } from "../draft.js";
9
9
  import { numericOption, option, resolveLlmBaseUrls, saveRequested } from "./shared.js";
10
+ import { compactPlan, verbosePlanRequested } from "./planOutput.js";
10
11
  /**
11
12
  * `draft` — author ONE trigger-grounded opener per hot judge decision as a
12
13
  * governed create_task plan. Structurally a proposal: never sends, never writes
@@ -68,7 +69,16 @@ LLM key it emits a clearly-labeled stub rather than fake authored copy.`);
68
69
  channel,
69
70
  openers,
70
71
  });
71
- console.log(JSON.stringify({ drafts, rejected }, null, 2));
72
+ if (args.includes("--json")) {
73
+ console.log(JSON.stringify({ drafts, rejected }, null, 2));
74
+ }
75
+ else if (verbosePlanRequested(args)) {
76
+ console.log(JSON.stringify({ drafts, rejected }, null, 2));
77
+ console.log(compactPlan(plan, { saved: save }));
78
+ }
79
+ else {
80
+ console.log(compactPlan(plan, { saved: save }));
81
+ }
72
82
  const stale = drafts.filter((d) => d.staleTrigger);
73
83
  console.error(`${drafts.length} opener(s) staged as create_task proposals (channel ${channel}, min score ${minScore})` +
74
84
  `${rejected.length ? `; ${rejected.length} rejected (ungrounded first line)` : ""}` +
@@ -8,6 +8,8 @@ import { createFilePlanStore } from "../planStore.js";
8
8
  import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, builtinAcquirePreset, builtinEnrichPreset, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor } from "../enrich.js";
9
9
  import { loadMeter, remaining } from "../acquireMeter.js";
10
10
  import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspectPage, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys } from "../connectors/prospectSources.js";
11
+ import { createClaySearch, runClayPeopleSearchPage } from "../connectors/clay.js";
12
+ import { runContactWaterfall } from "../contactProviders.js";
11
13
  import { loadSeen, recordSeen } from "../acquireSeen.js";
12
14
  import { acquireCheckpointId, createFileAcquireCheckpointStore } from "../acquireCheckpoint.js";
13
15
  import { readHostedAcquireCheckpoint, writeHostedAcquireCheckpoint } from "../hostedAcquireCheckpoint.js";
@@ -15,13 +17,14 @@ import { uploadHostedPatchPlan } from "../hostedPatchPlan.js";
15
17
  import { progressReporter, reportCounts, reportEvent } from "../runReport.js";
16
18
  import { ACQUIRE_STAGES, composeListeners, createProgressEmitter } from "../progress.js";
17
19
  import { createLinkedInProvider, discoverLinkedInProspects } from "../acquireLinkedIn.js";
18
- import { fitThreshold, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp } from "../icp.js";
20
+ import { fitThreshold, icpToClayPeopleFilters, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp } from "../icp.js";
19
21
  import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords } from "../enrichApollo.js";
20
22
  import { isSpoolPath, readSpoolPath } from "../spoolFiles.js";
21
23
  import { isOptionValue, loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.js";
22
24
  import { providerKey } from "./tam.js";
23
25
  import { unknownSubcommandError } from "./suggest.js";
24
26
  import { colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint } from "./ui.js";
27
+ import { compactPlan, verbosePlanRequested } from "./planOutput.js";
25
28
  /**
26
29
  * The enrich layer: governed append/refresh of third-party data (Apollo pull,
27
30
  * Clay ingest) into the CRM through the normal dry-run → approval → apply
@@ -192,8 +195,8 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
192
195
  // Meter: how many MORE leads may we create right now? --max is an
193
196
  // additional per-run ceiling, never a way to exceed the budget.
194
197
  if (apiSkippedCrm || apiSkippedSeen) {
195
- console.error(`Pre-email dedup: skipped ${apiSkippedCrm} already-in-CRM + ${apiSkippedSeen} seen before paying for emails ` +
196
- `(≈$${((apiSkippedCrm + apiSkippedSeen) * costPerRecord).toFixed(2)} enrichment saved).`);
198
+ console.error(`Pre-enrichment dedup: skipped ${apiSkippedCrm} already-in-CRM + ${apiSkippedSeen} previously seen ` +
199
+ `(≈$${((apiSkippedCrm + apiSkippedSeen) * costPerRecord).toFixed(2)} downstream spend avoided).`);
197
200
  }
198
201
  // `cap` is the desired create count bounded by the persistent meter. Raw
199
202
  // discovery scanning has its own --scan-limit and may cross many duplicate-
@@ -263,16 +266,16 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
263
266
  const meterLine = formatAcquireMeter(headroom, costPerRecord);
264
267
  const gaugeLine = acquireGaugeLine(headroom, config.acquire.budget ?? {}, paint(colorEnabled(process.stdout)));
265
268
  if (!save) {
266
- if (rest.includes("--json")) {
267
- console.log(JSON.stringify({ plan: result.plan, counts: result.counts, estCostUsd: result.estCostUsd, meter: headroom }, null, 2));
268
- }
269
- else {
270
- console.log(patchPlanToMarkdown(result.plan));
271
- console.log(meterLine);
272
- if (gaugeLine)
273
- console.log(gaugeLine);
274
- console.log("\nDry run — nothing written. Re-run with --save to persist the plan, then approve + apply.");
275
- }
269
+ printAcquireOutput({
270
+ args: rest,
271
+ result,
272
+ meter: headroom,
273
+ meterLine,
274
+ gaugeLine,
275
+ saved: false,
276
+ planSaved: false,
277
+ hostedPlanUrl: null,
278
+ });
276
279
  return;
277
280
  }
278
281
  const run = await openEnrichRun(store, source, "acquire", option(rest, "--run-label"), today);
@@ -318,28 +321,16 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
318
321
  if (acquireTelemetry && acquireCheckpointSync) {
319
322
  await persistAcquireCheckpoint(acquireCheckpointSync.key, acquireTelemetry.discovery, acquireCheckpointSync.hostedVersion);
320
323
  }
321
- if (planIds.length > 0) {
322
- const hostedRuns = hostedRunsUrl();
323
- console.log(`\nSaved plan ${result.plan.id}`);
324
- console.log(` Leads ${result.counts.created} net-new`);
325
- console.log(` Est. cost $${result.estCostUsd.toFixed(2)}`);
326
- console.log(` Budget ${gaugeLine ?? meterLine}`);
327
- if (hostedPlanUrl)
328
- console.log(` Plan ${hostedPlanUrl}`);
329
- if (hostedRuns)
330
- console.log(` Observe ${hostedRuns}`);
331
- console.log("\nNext steps");
332
- console.log(` Review fullstackgtm plans show ${result.plan.id}`);
333
- console.log(` Approve fullstackgtm plans approve ${result.plan.id} --operations all`);
334
- console.log(` Apply fullstackgtm apply --plan-id ${result.plan.id} --provider hubspot`);
335
- console.log("\nThe meter is charged only when a create lands at apply.");
336
- }
337
- else {
338
- console.log(meterLine);
339
- if (gaugeLine)
340
- console.log(gaugeLine);
341
- console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
342
- }
324
+ printAcquireOutput({
325
+ args: rest,
326
+ result,
327
+ meter: headroom,
328
+ meterLine,
329
+ gaugeLine,
330
+ saved: true,
331
+ planSaved: planIds.length > 0,
332
+ hostedPlanUrl,
333
+ });
343
334
  return;
344
335
  }
345
336
  const mode = subcommand === "refresh" ? "refresh" : "append";
@@ -460,7 +451,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
460
451
  console.log(JSON.stringify(result.plan, null, 2));
461
452
  }
462
453
  else {
463
- console.log(patchPlanToMarkdown(result.plan));
454
+ console.log(verbosePlanRequested(rest) ? patchPlanToMarkdown(result.plan) : compactPlan(result.plan));
464
455
  console.log(formatEnrichCounts(result.counts, result.ambiguities.length));
465
456
  console.log("\nDry run — nothing written. Re-run with --save to persist the plan and the run record.");
466
457
  }
@@ -500,8 +491,9 @@ function formatEnrichCounts(counts, ambiguities) {
500
491
  /**
501
492
  * Pull net-new prospects from an API acquire source into source records the
502
493
  * acquire builder dedupes + turns into create_record ops. Explorium discovers;
503
- * pipe0 (when resolveEmailsWith=pipe0) fills real work emails. Only rows that
504
- * carry the dedupe key (email) survive you cannot resolve-first without it.
494
+ * A field-level provider waterfall fills missing contact data. Existing
495
+ * email-keyed and resolveEmailsWith=pipe0 configs translate to an implicit
496
+ * Pipe0 work-email step. Only rows that carry the configured dedupe key survive.
505
497
  */
506
498
  async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRun, targetFresh, persistCheckpoint, progress) {
507
499
  const acquire = config.acquire;
@@ -520,7 +512,9 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
520
512
  ? (icp ? icpToExploriumFilters(icp) : (disc.filters ?? {}))
521
513
  : disc.provider === "pipe0"
522
514
  ? (icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {}))
523
- : {};
515
+ : disc.provider === "clay"
516
+ ? (icp ? icpToClayPeopleFilters(icp) : (disc.filters ?? {}))
517
+ : {};
524
518
  const queryFingerprint = createHash("sha256")
525
519
  .update(JSON.stringify({ source, provider: disc.provider, listId, filters, icp: icp ?? null }))
526
520
  .digest("hex");
@@ -583,6 +577,20 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
583
577
  cursor = result.nextCursor;
584
578
  exhausted = result.nextCursor === null;
585
579
  }
580
+ else if (disc.provider === "clay") {
581
+ if (!cursor) {
582
+ progress.note("creating Clay people-search iterator");
583
+ cursor = await createClaySearch({
584
+ apiKey: providerKey("clay"), sourceType: "people", filters,
585
+ });
586
+ }
587
+ const result = await runClayPeopleSearchPage({
588
+ apiKey: providerKey("clay"), searchId: cursor, limit: requestSize,
589
+ });
590
+ page = result.prospects;
591
+ offset += page.length;
592
+ exhausted = !result.hasMore;
593
+ }
586
594
  else if (disc.provider === "explorium") {
587
595
  const pageNumber = offset + 1;
588
596
  exploriumPage = pageNumber;
@@ -603,7 +611,7 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
603
611
  exhausted = page.length < requestSize;
604
612
  }
605
613
  else {
606
- throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0 | linkedin).`);
614
+ throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (clay | explorium | pipe0 | linkedin).`);
607
615
  }
608
616
  discovered += page.length;
609
617
  progress.items(discovered, scanLimit);
@@ -649,23 +657,35 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
649
657
  }
650
658
  progress.stage(ACQUIRE_STAGES[1], 1, ACQUIRE_STAGES.length);
651
659
  progress.note("checking work-email requirements");
652
- // 3. Resolve real work emails. Triggered either when email IS the dedupe key
653
- // (Explorium's email is hashed, Crustdata returns none) or when a source
654
- // that keys on something else opts in via `resolveEmailsWith: "pipe0"`
655
- // (e.g. LinkedIn keys on the profile URL but still wants outreach emails).
656
- // pipe0 waterfall, chunked; resolves from name + company domain/name.
657
- if (matchKey === "email" || disc.resolveEmailsWith === "pipe0") {
658
- // The waterfall needs a company DOMAIN; LinkedIn/HeyReach lists carry only
659
- // names. Resolve domains first (pipe0 company:identity) so resolution can
660
- // actually land — without it, name-only resolution fails for every lead.
661
- if (prospects.some((p) => !p.companyDomain && p.companyName)) {
662
- progress.note(`resolving company domains for ${prospects.length} candidate(s)`);
663
- const resolved = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects });
664
- prospects.splice(0, prospects.length, ...resolved);
665
- }
666
- progress.note(`resolving work emails for ${prospects.length} candidate(s)`);
667
- const resolved = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects });
668
- prospects.splice(0, prospects.length, ...resolved);
660
+ // 3. Resolve contact fields through the ordered, fill-only waterfall. Keep
661
+ // legacy behavior: email-keyed acquisition and resolveEmailsWith=pipe0 imply
662
+ // a Pipe0 work-email step when no explicit waterfall is configured.
663
+ const implicitPipe0 = matchKey === "email" || disc.resolveEmailsWith === "pipe0";
664
+ const contactWaterfall = disc.contactWaterfall ?? (implicitPipe0 ? [{ provider: "pipe0", fields: ["work_email"] }] : []);
665
+ if (contactWaterfall.length > 0) {
666
+ const adapters = {
667
+ pipe0: {
668
+ provider: "pipe0",
669
+ resolve: async (candidates, fields) => {
670
+ let resolved = candidates;
671
+ if (fields.includes("work_email") && resolved.some((p) => !p.companyDomain && p.companyName)) {
672
+ progress.note(`resolving company domains for ${resolved.length} candidate(s)`);
673
+ resolved = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects: resolved });
674
+ }
675
+ if (fields.includes("work_email")) {
676
+ progress.note(`resolving work emails with pipe0 for ${resolved.length} candidate(s)`);
677
+ resolved = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects: resolved });
678
+ }
679
+ return resolved;
680
+ },
681
+ },
682
+ };
683
+ const result = await runContactWaterfall({ prospects, steps: contactWaterfall, adapters });
684
+ prospects.splice(0, prospects.length, ...result.prospects);
685
+ for (const attempt of result.attempts) {
686
+ const added = Object.entries(attempt.added).map(([field, count]) => `${count} ${field}`).join(", ") || "0 fields";
687
+ progress.note(`${attempt.provider}: ${attempt.attempted} attempted · ${added} added`);
688
+ }
669
689
  }
670
690
  progress.items(prospects.length, prospects.length);
671
691
  progress.note(`${prospects.length} candidate(s) ready for planning`);
@@ -755,6 +775,53 @@ function formatAcquireMeter(headroom, costPerRecord) {
755
775
  `Spend left: ${money(headroom.spendUsd.day)}/day, ${money(headroom.spendUsd.month)}/month ` +
756
776
  `(≈$${costPerRecord.toFixed(2)}/lead).`);
757
777
  }
778
+ function printAcquireOutput(options) {
779
+ const { args, result, meter, meterLine, gaugeLine, saved, planSaved, hostedPlanUrl } = options;
780
+ if (args.includes("--json")) {
781
+ console.log(JSON.stringify({
782
+ plan: result.plan,
783
+ counts: result.counts,
784
+ estCostUsd: result.estCostUsd,
785
+ meter,
786
+ persistence: {
787
+ runSaved: saved,
788
+ planSaved,
789
+ planId: planSaved ? result.plan.id : null,
790
+ hostedPlanUrl,
791
+ },
792
+ }, null, 2));
793
+ return;
794
+ }
795
+ if (verbosePlanRequested(args)) {
796
+ console.log(patchPlanToMarkdown(result.plan));
797
+ console.log(`\nAcquisition`);
798
+ console.log(` Leads ${result.counts.created} net-new · ${result.counts.unassigned} unassigned`);
799
+ console.log(` Est. cost $${result.estCostUsd.toFixed(2)}`);
800
+ console.log(` Budget ${gaugeLine ?? meterLine}`);
801
+ if (hostedPlanUrl)
802
+ console.log(` Plan ${hostedPlanUrl}`);
803
+ }
804
+ else if (planSaved || !saved) {
805
+ console.log(compactPlan(result.plan, { saved: planSaved }));
806
+ console.log(meterLine);
807
+ if (gaugeLine)
808
+ console.log(gaugeLine);
809
+ }
810
+ else {
811
+ console.log(meterLine);
812
+ if (gaugeLine)
813
+ console.log(gaugeLine);
814
+ console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
815
+ }
816
+ if (planSaved) {
817
+ console.log(`\nApprove fullstackgtm plans approve ${result.plan.id} --operations all`);
818
+ console.log(`Apply fullstackgtm apply --plan-id ${result.plan.id} --provider <hubspot|salesforce>`);
819
+ const hostedRuns = hostedRunsUrl();
820
+ if (hostedRuns)
821
+ console.log(`Observe ${hostedRuns}`);
822
+ console.log("The meter is charged only when a create lands at apply.");
823
+ }
824
+ }
758
825
  function hostedRunsUrl() {
759
826
  const baseUrl = getCredential("broker")?.baseUrl?.replace(/\/+$/, "");
760
827
  return baseUrl ? `${baseUrl}/dashboard/runs` : null;
package/dist/cli/fix.js CHANGED
@@ -20,6 +20,7 @@ import { APPLY_STAGES, composeListeners, createProgressEmitter } from "../progre
20
20
  import { progressReporter } from "../runReport.js";
21
21
  import { confirmRequested, connectorFor, isOptionValue, numericOption, option, readSnapshot, repeatedOption, saveRequested } from "./shared.js";
22
22
  import { box, colorEnabled, createProgressRenderer, paint } from "./ui.js";
23
+ import { compactPlan, verbosePlanRequested } from "./planOutput.js";
23
24
  /**
24
25
  * The resolve gate: exit 0 = safe to create, exit 2 = match found (exists or
25
26
  * ambiguous — do NOT blind-create), exit 1 = error. Built for sync jobs and
@@ -101,9 +102,12 @@ async function emitPlan(plan, args) {
101
102
  if (args.includes("--json")) {
102
103
  console.log(JSON.stringify(plan, null, 2));
103
104
  }
104
- else {
105
+ else if (verbosePlanRequested(args)) {
105
106
  console.log(patchPlanToMarkdown(plan));
106
107
  }
108
+ else {
109
+ console.log(compactPlan(plan, { saved: saveRequested(args) }));
110
+ }
107
111
  }
108
112
  /**
109
113
  * Governed duplicate cleanup: group by a normalized identity key, propose one
@@ -170,8 +174,7 @@ export async function reassignCommand(args) {
170
174
  for (const plan of plans) {
171
175
  if (store)
172
176
  await store.save(plan);
173
- console.log(`${plan.id} ${String(plan.operations.length).padStart(3)} operation(s) ${plan.title}`);
174
- console.log(` ${plan.summary}`);
177
+ console.log(verbosePlanRequested(args) ? patchPlanToMarkdown(plan) : compactPlan(plan, { saved: Boolean(store) }));
175
178
  }
176
179
  if (store) {
177
180
  console.log(`\nSaved ${plans.length} plan(s). For each: \`fullstackgtm plans show <id>\`, \`fullstackgtm plans approve <id> --operations <ids|all>\`, then \`fullstackgtm apply --plan-id <id> --provider <name>\`.`);