fullstackgtm 0.57.0 → 0.58.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.
@@ -1,6 +1,6 @@
1
1
  /**
2
- * The enrich layer: governed append/refresh of third-party data (Apollo pull,
3
- * Clay ingest) into the CRM through the normal dry-run → approval → apply
2
+ * The enrich layer: governed append/refresh of third-party data (Apollo or
3
+ * ZoomInfo pull, Clay ingest) into the CRM through the normal dry-run → approval → apply
4
4
  * contract. State lives in the profile-scoped run store (checkpoint,
5
5
  * staleness ledger, observability in one); scheduling belongs to the
6
6
  * horizontal scheduler — enrich owns no cron logic.
@@ -19,6 +19,7 @@ import { ACQUIRE_STAGES, composeListeners, createProgressEmitter } from "../prog
19
19
  import { createLinkedInProvider, discoverLinkedInProspects } from "../acquireLinkedIn.js";
20
20
  import { clayPeopleFilterRoutes, fitThreshold, icpToClayInvestmentCompanyFilters, icpToClayPeopleFilters, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp } from "../icp.js";
21
21
  import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords } from "../enrichApollo.js";
22
+ import { createZoomInfoClient, pullZoomInfoRecords } from "../enrichZoomInfo.js";
22
23
  import { isSpoolPath, readSpoolPath } from "../spoolFiles.js";
23
24
  import { isOptionValue, loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.js";
24
25
  import { providerKey } from "./tam.js";
@@ -28,8 +29,8 @@ import { compactPlan, verbosePlanRequested } from "./planOutput.js";
28
29
  import { writeHostedArtifact } from "../hostedArtifacts.js";
29
30
  import { readIcpSyncState } from "../icpSync.js";
30
31
  /**
31
- * The enrich layer: governed append/refresh of third-party data (Apollo pull,
32
- * Clay ingest) into the CRM through the normal dry-run → approval → apply
32
+ * The enrich layer: governed append/refresh of third-party data (Apollo or
33
+ * ZoomInfo pull, Clay ingest) into the CRM through the normal dry-run → approval → apply
33
34
  * contract. State lives in the profile-scoped run store (checkpoint,
34
35
  * staleness ledger, observability in one); scheduling belongs to the
35
36
  * horizontal scheduler — enrich owns no cron logic.
@@ -71,8 +72,9 @@ and leaves them unassigned. Backfill existing ownerless records with
71
72
  \`reassign --assign-unowned --to <ownerId>\`.
72
73
 
73
74
  append pulls from an api source (Apollo — BYO key via \`login apollo\` or
74
- APOLLO_API_KEY) or reads data staged by \`enrich ingest\` (Clay CSV exports,
75
- webhook payload JSON), matches source records to CRM records via the ordered
75
+ APOLLO_API_KEY; ZoomInfo official \`gtm\` CLI via \`gtm auth login\`) or reads
76
+ data staged by \`enrich ingest\` (Clay CSV exports, webhook payload JSON),
77
+ matches source records to CRM records via the ordered
76
78
  match keys in enrich.config.json (unique hit wins; zero hits falls through to
77
79
  the next key; multiple hits skip or flow into the suggest chain, per
78
80
  onAmbiguous), and emits a fill-blanks-only patch plan. Without --save it
@@ -232,6 +234,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
232
234
  // piped) and, via the composed reporter, heartbeat to a paired hosted app.
233
235
  // The meter reading (creates vs headroom + budget burn) rides the same
234
236
  // emitter, feeding the dashboard's gauge without printing anything new.
237
+ const acquireRunLabel = option(rest, "--run-label") ?? `acquire-${source}-${today}`;
235
238
  let result;
236
239
  try {
237
240
  acquireProgress.stage(ACQUIRE_STAGES[2], 2, ACQUIRE_STAGES.length);
@@ -244,7 +247,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
244
247
  source,
245
248
  snapshot,
246
249
  records,
247
- runLabel: option(rest, "--run-label") ?? `acquire-${source}-${today}`,
250
+ runLabel: acquireRunLabel,
248
251
  maxRecords: cap,
249
252
  progress: acquireProgress,
250
253
  });
@@ -259,7 +262,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
259
262
  // Observability: headline metrics for the web run timeline (paired users).
260
263
  reportCounts({
261
264
  sourced: result.counts.fetched,
262
- created: result.counts.created,
265
+ proposed: result.counts.created,
263
266
  withheldByMeter: result.counts.withheldByMeter,
264
267
  skippedInCrm: apiSkippedCrm,
265
268
  skippedSeen: apiSkippedSeen,
@@ -270,9 +273,9 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
270
273
  const icpPath = resolve(process.cwd(), option(rest, "--icp") ?? "icp.json");
271
274
  const trackedIcp = existsSync(icpPath) ? readIcpSyncState(icpPath) : null;
272
275
  const leadMirror = await writeHostedArtifact({
273
- kind: "lead_run", key: `leads:${result.plan.id}`, label: result.plan.title ?? result.plan.id,
276
+ kind: "lead_run", key: `leads:${result.plan.id}`, label: acquireRunLabel,
274
277
  domain: trackedIcp?.domain,
275
- document: { runLabel: result.plan.id, createdAt: new Date().toISOString(), source, counts: result.counts,
278
+ document: { runLabel: acquireRunLabel, createdAt: new Date().toISOString(), source, targetDomain: option(rest, "--company-domain"), counts: result.counts,
276
279
  plan: result.plan, icpRef: trackedIcp ? { artifactId: trackedIcp.artifactId, domain: trackedIcp.domain, revision: trackedIcp.revision, localIcpSha256: trackedIcp.localIcpSha256 } : undefined },
277
280
  });
278
281
  if (leadMirror.status === "saved")
@@ -386,10 +389,17 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
386
389
  : "Nothing to refresh: no stale records carry a pull key (companies need a domain, contacts an email).");
387
390
  return;
388
391
  }
389
- const client = createApolloClient({
390
- getApiKey: () => apolloApiKey(),
391
- apiBaseUrl: process.env.APOLLO_API_BASE_URL,
392
- });
392
+ if (source !== "apollo" && source !== "zoominfo") {
393
+ throw new Error(`enrich ${mode}: api source "${source}" supports acquire discovery, not snapshot enrichment. ` +
394
+ "Use apollo or zoominfo for append/refresh, or stage records with `enrich ingest`.");
395
+ }
396
+ const apolloClient = source === "apollo"
397
+ ? createApolloClient({
398
+ getApiKey: () => apolloApiKey(),
399
+ apiBaseUrl: process.env.APOLLO_API_BASE_URL,
400
+ })
401
+ : null;
402
+ const zoomInfoClient = source === "zoominfo" ? createZoomInfoClient() : null;
393
403
  if (save) {
394
404
  run = await openEnrichRun(store, source, mode, option(rest, "--run-label"), today);
395
405
  if (run.cursor) {
@@ -403,7 +413,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
403
413
  let pullProcessed = 0;
404
414
  let result;
405
415
  try {
406
- result = await pullApolloRecords(client, pullKeys, {
416
+ const pullOptions = {
407
417
  resumeAfter: run?.cursor ?? null,
408
418
  onProgress: async (progress) => {
409
419
  if (pullBar.active) {
@@ -422,7 +432,10 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
422
432
  run.missedKeys = [...(run.missedKeys ?? []), progress.miss.value];
423
433
  await store.update(run);
424
434
  },
425
- });
435
+ };
436
+ result = source === "zoominfo"
437
+ ? await pullZoomInfoRecords(zoomInfoClient, pullKeys, pullOptions)
438
+ : await pullApolloRecords(apolloClient, pullKeys, pullOptions);
426
439
  }
427
440
  finally {
428
441
  pullBar.done();
package/dist/cli/help.js CHANGED
@@ -86,7 +86,7 @@ Usage:
86
86
  fullstackgtm enrich refresh [--source apollo] [--stale-days <n>] [--save] [--config <path>] [source options]
87
87
  fullstackgtm enrich ingest <file.csv|payload.json|spool.jsonl|spool-dir> --source clay [--run-label <label>]
88
88
  fullstackgtm enrich status [--runs] [--source <id>] [--json]
89
- governed enrichment: pull (Apollo) or stage (Clay) third-party
89
+ governed enrichment: pull (Apollo/ZoomInfo) or stage (Clay) third-party
90
90
  data, match it to CRM records deterministically, and emit a
91
91
  fill-blanks-only patch plan through the normal dry-run →
92
92
  approve → apply gate. refresh re-checks stale stamped fields
@@ -481,7 +481,7 @@ export const HELP = {
481
481
  seeAlso: ["enrich", "plans", "apply", "resolve"],
482
482
  },
483
483
  enrich: {
484
- summary: "governed third-party enrichment (Apollo/Clay), fill-blanks-only",
484
+ summary: "governed third-party enrichment (Apollo/ZoomInfo/Clay), fill-blanks-only",
485
485
  phase: "Remediate",
486
486
  synopsis: ["fullstackgtm enrich append|refresh|ingest|status … (run `enrich --help` for full options)"],
487
487
  detail: "Pull (Apollo) or stage (Clay) data, match it to CRM records deterministically, and emit a fill-blanks-only patch plan through the normal dry-run → approve → apply gate. `refresh` re-checks stale stamped fields.",
@@ -171,7 +171,18 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
171
171
  await store.appendRun({ id: signalRunId(runLabel), runLabel, startedAt: now.toISOString(), completedAt: new Date().toISOString(),
172
172
  buckets: ["job"], counts: { fetched: discovered.summary.rawResults, new: ranked.length, deduped: deduped.length }, signals: ranked });
173
173
  console.error(`Saved signal run "${runLabel}". Next: \`fullstackgtm icp judge --signals-from ${runLabel} --save\`.`);
174
- await mirrorSignalRun(runLabel, now, ["job"], { fetched: discovered.summary.rawResults, new: ranked.length, deduped: deduped.length }, ranked, rest);
174
+ await mirrorSignalRun(runLabel, now, ["job"], { fetched: discovered.summary.rawResults, new: ranked.length, deduped: deduped.length }, ranked, rest, {
175
+ provider: discovered.summary.provider,
176
+ searchesUsed: discovered.summary.searchesUsed,
177
+ searchLimit: maxSearches,
178
+ rawResults: discovered.summary.rawResults,
179
+ matchedEvidence: discovered.summary.matchedEvidence,
180
+ resolvedAccounts: discovered.summary.resolvedAccounts,
181
+ unresolvedAccounts: discovered.summary.unresolvedAccounts,
182
+ costUsd: discovered.summary.costUsd,
183
+ costLimitUsd: maxUsd,
184
+ warnings: discovered.summary.warnings,
185
+ });
175
186
  }
176
187
  else {
177
188
  console.error("(not saved — re-run with --save to persist this evidence to the signal ledger)");
@@ -389,12 +400,12 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
389
400
  }
390
401
  throw unknownSubcommandError("signals", sub, ["fetch", "discover", "list", "outcome", "weights"]);
391
402
  }
392
- async function mirrorSignalRun(runLabel, startedAt, buckets, counts, signals, args) {
403
+ async function mirrorSignalRun(runLabel, startedAt, buckets, counts, signals, args, discovery) {
393
404
  const icpPath = resolve(process.cwd(), option(args, "--icp") ?? "icp.json");
394
405
  const tracked = existsSync(icpPath) ? readIcpSyncState(icpPath) : null;
395
406
  const mirrored = await writeHostedArtifact({
396
407
  kind: "signal_run", key: `signals:${signalRunId(runLabel)}`, label: runLabel,
397
- document: { runLabel, startedAt: startedAt.toISOString(), completedAt: new Date().toISOString(), buckets, counts, signals,
408
+ document: { runLabel, startedAt: startedAt.toISOString(), completedAt: new Date().toISOString(), buckets, counts, signals, ...discovery,
398
409
  icpRef: tracked ? { artifactId: tracked.artifactId, domain: tracked.domain, revision: tracked.revision, localIcpSha256: tracked.localIcpSha256 } : undefined },
399
410
  });
400
411
  if (mirrored.status === "saved")
package/dist/enrich.d.ts CHANGED
@@ -8,7 +8,7 @@ import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
8
8
  *
9
9
  * Every enrichment vendor ships fire-and-forget writeback — data lands without
10
10
  * a diff, without approval, over whatever a human typed. This layer inverts
11
- * that: a source (Apollo pull, Clay ingest) feeds a deterministic matcher,
11
+ * that: a source (Apollo/ZoomInfo pull, Clay ingest) feeds a deterministic matcher,
12
12
  * the matcher feeds a fill-blanks-only patch plan, and the plan goes through
13
13
  * the existing dry-run → approval → apply contract. Every proposed value is
14
14
  * traceable to the source payload that produced it (`GtmEvidence` on the
package/dist/enrich.js CHANGED
@@ -13,7 +13,7 @@ const MATCH_KEYS = {
13
13
  contact: ["email", "name", "linkedin"],
14
14
  };
15
15
  /** API source ids the MVP can pull from. */
16
- export const SUPPORTED_API_SOURCES = ["apollo", "explorium", "pipe0", "clay", "linkedin"];
16
+ export const SUPPORTED_API_SOURCES = ["apollo", "zoominfo", "explorium", "pipe0", "clay", "linkedin"];
17
17
  /**
18
18
  * Canonical fields enrich may target, plus the HubSpot property spellings the
19
19
  * config may use for them (so `"crm": "numberofemployees"` and
@@ -0,0 +1,33 @@
1
+ import type { EnrichObjectType } from "./enrich.ts";
2
+ import type { ApolloPullKey as EnrichPullKey, ApolloPullOptions as EnrichPullOptions, ApolloPullResult as EnrichPullResult } from "./enrichApollo.ts";
3
+ /**
4
+ * ZoomInfo source adapter for governed enrichment.
5
+ *
6
+ * ZoomInfo owns authentication and credit accounting in its official `gtm`
7
+ * CLI. fullstackgtm invokes that CLI as a read-only source, normalizes the
8
+ * JSON response, then sends proposed CRM changes through the normal
9
+ * diff → patch plan → approval → apply lifecycle. No ZoomInfo credential is
10
+ * copied into fullstackgtm and this adapter never writes to the CRM directly.
11
+ */
12
+ export type ZoomInfoCommandResult = {
13
+ stdout: string;
14
+ stderr: string;
15
+ };
16
+ export type ZoomInfoCommandRunner = (executable: string, args: string[]) => Promise<ZoomInfoCommandResult>;
17
+ export type ZoomInfoClientOptions = {
18
+ /** Official ZoomInfo CLI executable. Defaults to `gtm`. */
19
+ executable?: string;
20
+ /** Injectable command runner for tests. */
21
+ run?: ZoomInfoCommandRunner;
22
+ };
23
+ export type ZoomInfoClient = {
24
+ enrichCompany(domain: string): Promise<unknown>;
25
+ enrichContact(email: string): Promise<unknown>;
26
+ };
27
+ export declare function createZoomInfoClient(options?: ZoomInfoClientOptions): ZoomInfoClient;
28
+ /** Pull snapshot-driven enrichment keys through ZoomInfo's official CLI. */
29
+ export declare function pullZoomInfoRecords(client: ZoomInfoClient, keys: EnrichPullKey[], options?: EnrichPullOptions): Promise<EnrichPullResult>;
30
+ export type ZoomInfoPullKey = EnrichPullKey;
31
+ export type ZoomInfoPullOptions = EnrichPullOptions;
32
+ export type ZoomInfoPullResult = EnrichPullResult;
33
+ export type ZoomInfoObjectType = EnrichObjectType;
@@ -0,0 +1,144 @@
1
+ import { execFile } from "node:child_process";
2
+ const DEFAULT_MAX_BUFFER = 16 * 1024 * 1024;
3
+ const runCommand = (executable, args) => new Promise((resolve, reject) => {
4
+ execFile(executable, args, { encoding: "utf8", maxBuffer: DEFAULT_MAX_BUFFER }, (error, stdout, stderr) => {
5
+ if (error) {
6
+ const code = error.code;
7
+ if (code === "ENOENT") {
8
+ reject(new Error("ZoomInfo CLI not found. Install it with `brew install zoominfo/gtm-ai/gtm-ai-cli` " +
9
+ "or `npm install -g @zoominfo/gtm-ai-cli`, then run `gtm auth login`."));
10
+ return;
11
+ }
12
+ // Do not echo stdout/stderr: provider errors can contain submitted
13
+ // company domains, contact emails, or tenant-specific data and this
14
+ // message may be persisted in a scheduled-run record.
15
+ reject(new Error(`ZoomInfo CLI failed${typeof code === "string" || typeof code === "number" ? ` (${code})` : ""}. ` +
16
+ "Run `gtm auth whoami`, then retry the same `gtm ... enrich` command directly if needed."));
17
+ return;
18
+ }
19
+ resolve({ stdout, stderr });
20
+ });
21
+ });
22
+ function parseOutput(stdout) {
23
+ const text = stdout.trim();
24
+ if (!text)
25
+ return null;
26
+ try {
27
+ return JSON.parse(text);
28
+ }
29
+ catch {
30
+ throw new Error("ZoomInfo CLI returned non-JSON output. Upgrade `gtm` and verify that `gtm ... enrich -f json` works directly.");
31
+ }
32
+ }
33
+ export function createZoomInfoClient(options = {}) {
34
+ const executable = options.executable ?? process.env.ZOOMINFO_GTM_BIN ?? "gtm";
35
+ const run = options.run ?? runCommand;
36
+ async function invoke(args) {
37
+ const result = await run(executable, [...args, "-f", "json"]);
38
+ return parseOutput(result.stdout);
39
+ }
40
+ return {
41
+ enrichCompany(domain) {
42
+ return invoke(["companies", "enrich", "--domain", domain]);
43
+ },
44
+ enrichContact(email) {
45
+ return invoke(["contacts", "enrich", "--email", email]);
46
+ },
47
+ };
48
+ }
49
+ function recordCandidates(value) {
50
+ if (Array.isArray(value)) {
51
+ return value.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item));
52
+ }
53
+ if (!value || typeof value !== "object")
54
+ return [];
55
+ const object = value;
56
+ if (Array.isArray(object.data))
57
+ return recordCandidates(object.data);
58
+ if (object.data && typeof object.data === "object")
59
+ return recordCandidates(object.data);
60
+ if (Array.isArray(object.companies))
61
+ return recordCandidates(object.companies);
62
+ if (Array.isArray(object.contacts))
63
+ return recordCandidates(object.contacts);
64
+ return [object];
65
+ }
66
+ function valueAt(record, paths) {
67
+ for (const path of paths) {
68
+ let current = record;
69
+ for (const segment of path.split(".")) {
70
+ if (!current || typeof current !== "object") {
71
+ current = undefined;
72
+ break;
73
+ }
74
+ current = current[segment];
75
+ }
76
+ if (typeof current === "string" && current.trim())
77
+ return current.trim();
78
+ if (typeof current === "number" && Number.isFinite(current))
79
+ return String(current);
80
+ }
81
+ return undefined;
82
+ }
83
+ function normalizeRecord(payload, key) {
84
+ const record = recordCandidates(payload)[0];
85
+ if (!record)
86
+ return undefined;
87
+ const id = valueAt(record, ["id", "attributes.id", "companyId", "personId", "attributes.companyId", "attributes.personId"]);
88
+ if (key.objectType === "company") {
89
+ return {
90
+ id: `zoominfo:company_${id ?? key.value}`,
91
+ objectType: "company",
92
+ keys: {
93
+ domain: valueAt(record, [
94
+ "attributes.domain",
95
+ "attributes.website",
96
+ "attributes.companyWebsite",
97
+ "domain",
98
+ "website",
99
+ "companyWebsite",
100
+ ]) ?? key.value,
101
+ name: valueAt(record, ["attributes.name", "attributes.companyName", "name", "companyName"]),
102
+ },
103
+ payload: record,
104
+ };
105
+ }
106
+ return {
107
+ id: `zoominfo:contact_${id ?? key.value}`,
108
+ objectType: "contact",
109
+ keys: {
110
+ email: valueAt(record, [
111
+ "attributes.email",
112
+ "attributes.emailAddress",
113
+ "attributes.contactEmail",
114
+ "email",
115
+ "emailAddress",
116
+ "contactEmail",
117
+ ]) ?? key.value,
118
+ name: valueAt(record, ["attributes.fullName", "attributes.name", "fullName", "name"]),
119
+ },
120
+ payload: record,
121
+ };
122
+ }
123
+ /** Pull snapshot-driven enrichment keys through ZoomInfo's official CLI. */
124
+ export async function pullZoomInfoRecords(client, keys, options = {}) {
125
+ const records = [];
126
+ const misses = [];
127
+ const resumeIndex = options.resumeAfter ? keys.findIndex((key) => key.value === options.resumeAfter) : -1;
128
+ for (const key of keys.slice(resumeIndex + 1)) {
129
+ const payload = key.objectType === "company"
130
+ ? await client.enrichCompany(key.value)
131
+ : await client.enrichContact(key.value);
132
+ const record = normalizeRecord(payload, key);
133
+ if (record)
134
+ records.push(record);
135
+ else
136
+ misses.push(key);
137
+ await options.onProgress?.({
138
+ lastKeyValue: key.value,
139
+ record,
140
+ miss: record ? undefined : key,
141
+ });
142
+ }
143
+ return { records, misses };
144
+ }
package/dist/index.d.ts CHANGED
@@ -28,6 +28,7 @@ export { claimHostedPlanApply, hostedPlanDigest, hostedReviewDocument, readHoste
28
28
  export { scaffoldWorkspace, starterEnrichConfig, starterIcp, starterPlaybook, type InitProvider, type InitSource, type ScaffoldFile, type ScaffoldOptions, } from "./init.ts";
29
29
  export { appendCoverage, classifyAccount, classifyCoverage, computeCoverage, coverageCountsFromSnapshot, coveredAccounts, coverageToText, crmCheckableCriteria, deriveAcvFromClosedWon, deriveBuyersPerAccount, estimateTam, loadTamModel, projectEta, readCoverageTimeline, saveTamModel, tamDir, tamReportToMarkdown, type AccountTamClass, type AcvBasis, type DerivedAcv, type DerivedBuyers, type EstimateTamInput, type TamClassified, type TamCoverage, type TamCoverageCounts, type TamCrossCheck, type TamEta, type TamModel, type TamTargeting, type TamUniverse, } from "./tam.ts";
30
30
  export { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, type ApolloClient, type ApolloClientOptions, type ApolloPullKey, type ApolloPullResult, } from "./enrichApollo.ts";
31
+ export { createZoomInfoClient, pullZoomInfoRecords, type ZoomInfoClient, type ZoomInfoClientOptions, type ZoomInfoCommandResult, type ZoomInfoCommandRunner, type ZoomInfoPullKey, type ZoomInfoPullOptions, type ZoomInfoPullResult, } from "./enrichZoomInfo.ts";
31
32
  export { diffFindings, diffSnapshots, diffToMarkdown, type CollectionDiff, type FieldChange, type FindingsDrift, type RecordChange, type SnapshotDiff, } from "./diff.ts";
32
33
  export { mergeSnapshots, type MergeConflict, type MergeMatch, type MergeReport, type MergeSuggestion, } from "./merge.ts";
33
34
  export { createFilePlanStore, type PlanStore, type StoredPlan } from "./planStore.ts";
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ export { claimHostedPlanApply, hostedPlanDigest, hostedReviewDocument, readHoste
28
28
  export { scaffoldWorkspace, starterEnrichConfig, starterIcp, starterPlaybook, } from "./init.js";
29
29
  export { appendCoverage, classifyAccount, classifyCoverage, computeCoverage, coverageCountsFromSnapshot, coveredAccounts, coverageToText, crmCheckableCriteria, deriveAcvFromClosedWon, deriveBuyersPerAccount, estimateTam, loadTamModel, projectEta, readCoverageTimeline, saveTamModel, tamDir, tamReportToMarkdown, } from "./tam.js";
30
30
  export { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, } from "./enrichApollo.js";
31
+ export { createZoomInfoClient, pullZoomInfoRecords, } from "./enrichZoomInfo.js";
31
32
  export { diffFindings, diffSnapshots, diffToMarkdown, } from "./diff.js";
32
33
  export { mergeSnapshots, } from "./merge.js";
33
34
  export { createFilePlanStore } from "./planStore.js";
package/dist/runReport.js CHANGED
@@ -85,6 +85,10 @@ let inflightHeartbeat = null;
85
85
  * commands simply never stream.
86
86
  */
87
87
  export function beginRunReport(args, startedAt) {
88
+ counts = undefined;
89
+ findings = undefined;
90
+ crm = undefined;
91
+ events.length = 0;
88
92
  lastHeartbeatAt = 0;
89
93
  heartbeatBroker = undefined;
90
94
  inflightHeartbeat = null;
@@ -186,6 +190,13 @@ export async function flushRunReport(args, status, startedAt, error) {
186
190
  const identity = runIdentity(args, startedAt);
187
191
  if (!identity)
188
192
  return;
193
+ // A successful command with no result, finding, event, or live progress is
194
+ // terminal telemetry rather than something a person can explore. Keep it
195
+ // out of Runs. Errors always report; heartbeating commands still send their
196
+ // terminal status so an in-progress row cannot be stranded.
197
+ const hasUsefulOutput = Boolean((counts && Object.keys(counts).length > 0) || findings?.length || events.length);
198
+ if (status === "success" && !hasUsefulOutput && lastHeartbeatAt === 0)
199
+ return;
189
200
  const broker = getCredential("broker");
190
201
  if (!broker?.baseUrl || !broker.accessToken)
191
202
  return; // opt-in: only when paired
package/docs/api.md CHANGED
@@ -126,7 +126,7 @@ Four flags name "where data comes from / goes to"; they are not interchangeable:
126
126
  | Flag | Meaning | Values |
127
127
  |---|---|---|
128
128
  | `--provider` | The CRM the data lives in — the system a snapshot is read from and an approved plan is applied to. | `hubspot`, `salesforce` (plus `stripe`, read-only) |
129
- | `--source` | The external data source feeding a verb: an enrichment/discovery vendor on `enrich`/`tam` (`--source apollo`, `acquire --source pipe0`), a staged-ingest label on `enrich ingest`, on `signals fetch` which no-auth ATS board adapters to scan, or `exa` on `signals discover`. | `apollo`, `clay`, `pipe0`, `explorium`, `theirstack`, `linkedin` (HeyReach), `exa`; `greenhouse` / `lever` / `ashby` on `signals fetch` |
129
+ | `--source` | The external data source feeding a verb: an enrichment/discovery vendor on `enrich`/`tam` (`--source zoominfo`, `--source apollo`, `acquire --source pipe0`), a staged-ingest label on `enrich ingest`, on `signals fetch` which no-auth ATS board adapters to scan, or `exa` on `signals discover`. | `zoominfo` (official `gtm` CLI), `apollo`, `clay`, `pipe0`, `explorium`, `theirstack`, `linkedin` (HeyReach), `exa`; `greenhouse` / `lever` / `ashby` on `signals fetch` |
130
130
  | `--connector` | A signal-intake source connector on `signals fetch`: pulls candidate signals from a connected platform or the local webhook spool into the signal ledger. | `file`, `serpapi-news`, `hubspot-forms` |
131
131
  | `--channel` | Where a plan's output is delivered. On `apply`, the delivery terminus — a plan applies to `--provider <crm>` *or* `--channel outbox` (render approved openers to a local outbox file; transmits nothing), never both. On `draft`, which outreach channel the opener is drafted for (shapes the emitted op). | `apply`: `outbox` · `draft`: `email`, `linkedin`, `task` |
132
132
 
package/llms.txt CHANGED
@@ -149,8 +149,9 @@ approve → apply.
149
149
 
150
150
  ## Key invariants (enrich)
151
151
 
152
- `fullstackgtm enrich` is governed enrichment: `append` pulls (Apollo, BYO key
153
- via `login apollo`/APOLLO_API_KEY) or reads data staged by `ingest` (Clay CSV
152
+ `fullstackgtm enrich` is governed enrichment: `append` pulls from Apollo (BYO key
153
+ via `login apollo`/APOLLO_API_KEY) or ZoomInfo (official `gtm` CLI; `gtm auth login`),
154
+ or reads data staged by `ingest` (Clay CSV
154
155
  exports / webhook payload JSON), matches source records to CRM records via
155
156
  ordered keys in `enrich.config.json` (unique hit wins; ambiguity skips or
156
157
  becomes `requires_human_record_selection` placeholders — never a coin flip),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.57.0",
4
- "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
3
+ "version": "0.58.0",
4
+ "description": "Open-source CRM control plane and safety toolbox for AI agents: deterministic audits, reviewable patch plans, approval-gated write-back, conflict detection, and entity resolution for HubSpot and Salesforce.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
7
7
  "homepage": "https://github.com/fullstackgtm/core#readme",
package/src/cli/enrich.ts CHANGED
@@ -19,7 +19,8 @@ import { progressReporter, reportCounts, reportEvent } from "../runReport.ts";
19
19
  import { ACQUIRE_STAGES, composeListeners, createProgressEmitter } from "../progress.ts";
20
20
  import { createLinkedInProvider, discoverLinkedInProspects } from "../acquireLinkedIn.ts";
21
21
  import { clayPeopleFilterRoutes, fitThreshold, icpToClayInvestmentCompanyFilters, icpToClayPeopleFilters, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp, type Icp } from "../icp.ts";
22
- import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, type ApolloPullKey } from "../enrichApollo.ts";
22
+ import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, type ApolloPullKey, type ApolloPullOptions } from "../enrichApollo.ts";
23
+ import { createZoomInfoClient, pullZoomInfoRecords } from "../enrichZoomInfo.ts";
23
24
  import type { CanonicalGtmSnapshot, CreateRecordPayload } from "../types.ts";
24
25
  import { isSpoolPath, readSpoolPath } from "../spoolFiles.ts";
25
26
  import { isOptionValue, loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.ts";
@@ -33,8 +34,8 @@ import { readIcpSyncState } from "../icpSync.ts";
33
34
 
34
35
 
35
36
  /**
36
- * The enrich layer: governed append/refresh of third-party data (Apollo pull,
37
- * Clay ingest) into the CRM through the normal dry-run → approval → apply
37
+ * The enrich layer: governed append/refresh of third-party data (Apollo or
38
+ * ZoomInfo pull, Clay ingest) into the CRM through the normal dry-run → approval → apply
38
39
  * contract. State lives in the profile-scoped run store (checkpoint,
39
40
  * staleness ledger, observability in one); scheduling belongs to the
40
41
  * horizontal scheduler — enrich owns no cron logic.
@@ -77,8 +78,9 @@ and leaves them unassigned. Backfill existing ownerless records with
77
78
  \`reassign --assign-unowned --to <ownerId>\`.
78
79
 
79
80
  append pulls from an api source (Apollo — BYO key via \`login apollo\` or
80
- APOLLO_API_KEY) or reads data staged by \`enrich ingest\` (Clay CSV exports,
81
- webhook payload JSON), matches source records to CRM records via the ordered
81
+ APOLLO_API_KEY; ZoomInfo official \`gtm\` CLI via \`gtm auth login\`) or reads
82
+ data staged by \`enrich ingest\` (Clay CSV exports, webhook payload JSON),
83
+ matches source records to CRM records via the ordered
82
84
  match keys in enrich.config.json (unique hit wins; zero hits falls through to
83
85
  the next key; multiple hits skip or flow into the suggest chain, per
84
86
  onAmbiguous), and emits a fill-blanks-only patch plan. Without --save it
@@ -261,6 +263,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
261
263
  // piped) and, via the composed reporter, heartbeat to a paired hosted app.
262
264
  // The meter reading (creates vs headroom + budget burn) rides the same
263
265
  // emitter, feeding the dashboard's gauge without printing anything new.
266
+ const acquireRunLabel = option(rest, "--run-label") ?? `acquire-${source}-${today}`;
264
267
  let result: ReturnType<typeof buildAcquirePlan>;
265
268
  try {
266
269
  acquireProgress.stage(ACQUIRE_STAGES[2], 2, ACQUIRE_STAGES.length);
@@ -277,7 +280,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
277
280
  source,
278
281
  snapshot,
279
282
  records,
280
- runLabel: option(rest, "--run-label") ?? `acquire-${source}-${today}`,
283
+ runLabel: acquireRunLabel,
281
284
  maxRecords: cap,
282
285
  progress: acquireProgress,
283
286
  });
@@ -295,7 +298,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
295
298
  // Observability: headline metrics for the web run timeline (paired users).
296
299
  reportCounts({
297
300
  sourced: result.counts.fetched,
298
- created: result.counts.created,
301
+ proposed: result.counts.created,
299
302
  withheldByMeter: result.counts.withheldByMeter,
300
303
  skippedInCrm: apiSkippedCrm,
301
304
  skippedSeen: apiSkippedSeen,
@@ -307,9 +310,9 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
307
310
  const icpPath = resolve(process.cwd(), option(rest, "--icp") ?? "icp.json");
308
311
  const trackedIcp = existsSync(icpPath) ? readIcpSyncState(icpPath) : null;
309
312
  const leadMirror = await writeHostedArtifact({
310
- kind: "lead_run", key: `leads:${result.plan.id}`, label: result.plan.title ?? result.plan.id,
313
+ kind: "lead_run", key: `leads:${result.plan.id}`, label: acquireRunLabel,
311
314
  domain: trackedIcp?.domain,
312
- document: { runLabel: result.plan.id, createdAt: new Date().toISOString(), source, counts: result.counts,
315
+ document: { runLabel: acquireRunLabel, createdAt: new Date().toISOString(), source, targetDomain: option(rest, "--company-domain"), counts: result.counts,
313
316
  plan: result.plan, icpRef: trackedIcp ? { artifactId: trackedIcp.artifactId, domain: trackedIcp.domain, revision: trackedIcp.revision, localIcpSha256: trackedIcp.localIcpSha256 } : undefined },
314
317
  });
315
318
  if (leadMirror.status === "saved") console.error(`Recorded lead preview against${trackedIcp ? ` ICP revision ${trackedIcp.revision}` : " the local untracked ICP"}.`);
@@ -436,10 +439,19 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
436
439
  );
437
440
  return;
438
441
  }
439
- const client = createApolloClient({
440
- getApiKey: () => apolloApiKey(),
441
- apiBaseUrl: process.env.APOLLO_API_BASE_URL,
442
- });
442
+ if (source !== "apollo" && source !== "zoominfo") {
443
+ throw new Error(
444
+ `enrich ${mode}: api source "${source}" supports acquire discovery, not snapshot enrichment. ` +
445
+ "Use apollo or zoominfo for append/refresh, or stage records with `enrich ingest`.",
446
+ );
447
+ }
448
+ const apolloClient = source === "apollo"
449
+ ? createApolloClient({
450
+ getApiKey: () => apolloApiKey(),
451
+ apiBaseUrl: process.env.APOLLO_API_BASE_URL,
452
+ })
453
+ : null;
454
+ const zoomInfoClient = source === "zoominfo" ? createZoomInfoClient() : null;
443
455
  if (save) {
444
456
  run = await openEnrichRun(store, source, mode, option(rest, "--run-label"), today);
445
457
  if (run.cursor) {
@@ -455,7 +467,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
455
467
  let pullProcessed = 0;
456
468
  let result: Awaited<ReturnType<typeof pullApolloRecords>>;
457
469
  try {
458
- result = await pullApolloRecords(client, pullKeys, {
470
+ const pullOptions: ApolloPullOptions = {
459
471
  resumeAfter: run?.cursor ?? null,
460
472
  onProgress: async (progress) => {
461
473
  if (pullBar.active) {
@@ -473,7 +485,10 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
473
485
  if (progress.miss) run.missedKeys = [...(run.missedKeys ?? []), progress.miss.value];
474
486
  await store.update(run);
475
487
  },
476
- });
488
+ };
489
+ result = source === "zoominfo"
490
+ ? await pullZoomInfoRecords(zoomInfoClient!, pullKeys, pullOptions)
491
+ : await pullApolloRecords(apolloClient!, pullKeys, pullOptions);
477
492
  } finally {
478
493
  pullBar.done();
479
494
  }
package/src/cli/help.ts CHANGED
@@ -90,7 +90,7 @@ Usage:
90
90
  fullstackgtm enrich refresh [--source apollo] [--stale-days <n>] [--save] [--config <path>] [source options]
91
91
  fullstackgtm enrich ingest <file.csv|payload.json|spool.jsonl|spool-dir> --source clay [--run-label <label>]
92
92
  fullstackgtm enrich status [--runs] [--source <id>] [--json]
93
- governed enrichment: pull (Apollo) or stage (Clay) third-party
93
+ governed enrichment: pull (Apollo/ZoomInfo) or stage (Clay) third-party
94
94
  data, match it to CRM records deterministically, and emit a
95
95
  fill-blanks-only patch plan through the normal dry-run →
96
96
  approve → apply gate. refresh re-checks stale stamped fields
@@ -524,7 +524,7 @@ export const HELP: Record<string, HelpEntry> = {
524
524
  seeAlso: ["enrich", "plans", "apply", "resolve"],
525
525
  },
526
526
  enrich: {
527
- summary: "governed third-party enrichment (Apollo/Clay), fill-blanks-only",
527
+ summary: "governed third-party enrichment (Apollo/ZoomInfo/Clay), fill-blanks-only",
528
528
  phase: "Remediate",
529
529
  synopsis: ["fullstackgtm enrich append|refresh|ingest|status … (run `enrich --help` for full options)"],
530
530
  detail:
@@ -179,7 +179,18 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
179
179
  await store.appendRun({ id: signalRunId(runLabel), runLabel, startedAt: now.toISOString(), completedAt: new Date().toISOString(),
180
180
  buckets: ["job"], counts: { fetched: discovered.summary.rawResults, new: ranked.length, deduped: deduped.length }, signals: ranked });
181
181
  console.error(`Saved signal run "${runLabel}". Next: \`fullstackgtm icp judge --signals-from ${runLabel} --save\`.`);
182
- await mirrorSignalRun(runLabel, now, ["job"], { fetched: discovered.summary.rawResults, new: ranked.length, deduped: deduped.length }, ranked, rest);
182
+ await mirrorSignalRun(runLabel, now, ["job"], { fetched: discovered.summary.rawResults, new: ranked.length, deduped: deduped.length }, ranked, rest, {
183
+ provider: discovered.summary.provider,
184
+ searchesUsed: discovered.summary.searchesUsed,
185
+ searchLimit: maxSearches,
186
+ rawResults: discovered.summary.rawResults,
187
+ matchedEvidence: discovered.summary.matchedEvidence,
188
+ resolvedAccounts: discovered.summary.resolvedAccounts,
189
+ unresolvedAccounts: discovered.summary.unresolvedAccounts,
190
+ costUsd: discovered.summary.costUsd,
191
+ costLimitUsd: maxUsd,
192
+ warnings: discovered.summary.warnings,
193
+ });
183
194
  } else {
184
195
  console.error("(not saved — re-run with --save to persist this evidence to the signal ledger)");
185
196
  }
@@ -414,12 +425,13 @@ async function mirrorSignalRun(
414
425
  counts: { fetched: number; new: number; deduped: number },
415
426
  signals: Signal[],
416
427
  args: string[],
428
+ discovery?: Record<string, unknown>,
417
429
  ) {
418
430
  const icpPath = resolve(process.cwd(), option(args, "--icp") ?? "icp.json");
419
431
  const tracked = existsSync(icpPath) ? readIcpSyncState(icpPath) : null;
420
432
  const mirrored = await writeHostedArtifact({
421
433
  kind: "signal_run", key: `signals:${signalRunId(runLabel)}`, label: runLabel,
422
- document: { runLabel, startedAt: startedAt.toISOString(), completedAt: new Date().toISOString(), buckets, counts, signals,
434
+ document: { runLabel, startedAt: startedAt.toISOString(), completedAt: new Date().toISOString(), buckets, counts, signals, ...discovery,
423
435
  icpRef: tracked ? { artifactId: tracked.artifactId, domain: tracked.domain, revision: tracked.revision, localIcpSha256: tracked.localIcpSha256 } : undefined },
424
436
  });
425
437
  if (mirrored.status === "saved") console.error("Mirrored the signal run to the paired hosted workspace.");