fullstackgtm 0.56.1 → 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,15 +19,18 @@ 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";
25
26
  import { unknownSubcommandError } from "./suggest.js";
26
27
  import { box, colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint, scoreColor, truncateToWidth } from "./ui.js";
27
28
  import { compactPlan, verbosePlanRequested } from "./planOutput.js";
29
+ import { writeHostedArtifact } from "../hostedArtifacts.js";
30
+ import { readIcpSyncState } from "../icpSync.js";
28
31
  /**
29
- * The enrich layer: governed append/refresh of third-party data (Apollo pull,
30
- * 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
31
34
  * contract. State lives in the profile-scoped run store (checkpoint,
32
35
  * staleness ledger, observability in one); scheduling belongs to the
33
36
  * horizontal scheduler — enrich owns no cron logic.
@@ -69,8 +72,9 @@ and leaves them unassigned. Backfill existing ownerless records with
69
72
  \`reassign --assign-unowned --to <ownerId>\`.
70
73
 
71
74
  append pulls from an api source (Apollo — BYO key via \`login apollo\` or
72
- APOLLO_API_KEY) or reads data staged by \`enrich ingest\` (Clay CSV exports,
73
- 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
74
78
  match keys in enrich.config.json (unique hit wins; zero hits falls through to
75
79
  the next key; multiple hits skip or flow into the suggest chain, per
76
80
  onAmbiguous), and emits a fill-blanks-only patch plan. Without --save it
@@ -230,6 +234,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
230
234
  // piped) and, via the composed reporter, heartbeat to a paired hosted app.
231
235
  // The meter reading (creates vs headroom + budget burn) rides the same
232
236
  // emitter, feeding the dashboard's gauge without printing anything new.
237
+ const acquireRunLabel = option(rest, "--run-label") ?? `acquire-${source}-${today}`;
233
238
  let result;
234
239
  try {
235
240
  acquireProgress.stage(ACQUIRE_STAGES[2], 2, ACQUIRE_STAGES.length);
@@ -242,7 +247,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
242
247
  source,
243
248
  snapshot,
244
249
  records,
245
- runLabel: option(rest, "--run-label") ?? `acquire-${source}-${today}`,
250
+ runLabel: acquireRunLabel,
246
251
  maxRecords: cap,
247
252
  progress: acquireProgress,
248
253
  });
@@ -257,7 +262,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
257
262
  // Observability: headline metrics for the web run timeline (paired users).
258
263
  reportCounts({
259
264
  sourced: result.counts.fetched,
260
- created: result.counts.created,
265
+ proposed: result.counts.created,
261
266
  withheldByMeter: result.counts.withheldByMeter,
262
267
  skippedInCrm: apiSkippedCrm,
263
268
  skippedSeen: apiSkippedSeen,
@@ -265,6 +270,18 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
265
270
  });
266
271
  const meterLine = formatAcquireMeter(headroom, costPerRecord);
267
272
  const gaugeLine = acquireGaugeLine(headroom, config.acquire.budget ?? {}, paint(colorEnabled(process.stdout)));
273
+ const icpPath = resolve(process.cwd(), option(rest, "--icp") ?? "icp.json");
274
+ const trackedIcp = existsSync(icpPath) ? readIcpSyncState(icpPath) : null;
275
+ const leadMirror = await writeHostedArtifact({
276
+ kind: "lead_run", key: `leads:${result.plan.id}`, label: acquireRunLabel,
277
+ domain: trackedIcp?.domain,
278
+ document: { runLabel: acquireRunLabel, createdAt: new Date().toISOString(), source, targetDomain: option(rest, "--company-domain"), counts: result.counts,
279
+ plan: result.plan, icpRef: trackedIcp ? { artifactId: trackedIcp.artifactId, domain: trackedIcp.domain, revision: trackedIcp.revision, localIcpSha256: trackedIcp.localIcpSha256 } : undefined },
280
+ });
281
+ if (leadMirror.status === "saved")
282
+ console.error(`Recorded lead preview against${trackedIcp ? ` ICP revision ${trackedIcp.revision}` : " the local untracked ICP"}.`);
283
+ else if (leadMirror.status === "unavailable")
284
+ console.error(`Warning: ${leadMirror.reason}. The local lead preview is unchanged.`);
268
285
  if (!save) {
269
286
  printAcquireOutput({
270
287
  args: rest,
@@ -372,10 +389,17 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
372
389
  : "Nothing to refresh: no stale records carry a pull key (companies need a domain, contacts an email).");
373
390
  return;
374
391
  }
375
- const client = createApolloClient({
376
- getApiKey: () => apolloApiKey(),
377
- apiBaseUrl: process.env.APOLLO_API_BASE_URL,
378
- });
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;
379
403
  if (save) {
380
404
  run = await openEnrichRun(store, source, mode, option(rest, "--run-label"), today);
381
405
  if (run.cursor) {
@@ -389,7 +413,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
389
413
  let pullProcessed = 0;
390
414
  let result;
391
415
  try {
392
- result = await pullApolloRecords(client, pullKeys, {
416
+ const pullOptions = {
393
417
  resumeAfter: run?.cursor ?? null,
394
418
  onProgress: async (progress) => {
395
419
  if (pullBar.active) {
@@ -408,7 +432,10 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
408
432
  run.missedKeys = [...(run.missedKeys ?? []), progress.miss.value];
409
433
  await store.update(run);
410
434
  },
411
- });
435
+ };
436
+ result = source === "zoominfo"
437
+ ? await pullZoomInfoRecords(zoomInfoClient, pullKeys, pullOptions)
438
+ : await pullApolloRecords(apolloClient, pullKeys, pullOptions);
412
439
  }
413
440
  finally {
414
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.",
@@ -639,13 +639,13 @@ export const COMMAND_FLAGS = {
639
639
  merge: ["--input", "--out", "--json"],
640
640
  market: ["--category", "--config", "--vendor", "--snapshot", "--task-account", "--task-deal", "--capture-run", "--run", "--prior-run", "--diff", "--from", "--calls", "--anchor", "--min-mentions", "--max-claims", "--promote-lift", "--model", "--format", "--auto", "--unverified", "--save", "--dry-run", "--verbose", "--json", "--out"],
641
641
  tam: [...SOURCE_FLAGS, "--source", "--name", "--icp", "--accounts", "--acv", "--acv-basis", "--acv-from-crm", "--deal-period", "--buyers-per-account", "--cross-checks", "--max", "--max-credits", "--usd-per-credit", "--dry-run", "--confirm", "--cron", "--label", "--save", "--verbose", "--json", "--out"],
642
- icp: [...SOURCE_FLAGS, "--name", "--icp", "--domain", "--no-interactive", "--signals-from", "--with-history", "--prompt", "--min-score", "--from-judge", "--golden", "--against-outcomes", "--min-accuracy", "--model", "--label", "--save", "--verbose", "--json", "--out"],
642
+ icp: [...SOURCE_FLAGS, "--name", "--icp", "--domain", "--no-interactive", "--signals-from", "--with-history", "--prompt", "--min-score", "--from-judge", "--golden", "--against-outcomes", "--min-accuracy", "--model", "--label", "--change-summary", "--force", "--save", "--verbose", "--json", "--out"],
643
643
  signals: ["--bucket", "--source", "--connector", "--connector-opt", "--watchlist", "--keywords", "--from", "--label", "--since", "--icp", "--max-accounts", "--max-results", "--max-searches", "--max-usd", "--account", "--contact", "--unjudged", "--touch", "--result", "--save", "--verbose", "--json", "--explain"],
644
644
  draft: ["--from-judge", "--min-score", "--prompt", "--model", "--channel", "--save", "--dry-run", "--verbose", "--json", "--out"],
645
645
  schedule: ["--cron", "--label", "--provider", "--trigger", "--timer", "--runs", "--verbose", "--json"],
646
646
  };
647
647
  export const FLAGS_WITH_VALUES = new Set([
648
- "--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", "--company-domain", "--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-accounts", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--max-results", "--max-searches", "--max-usd", "--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",
648
+ "--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", "--change-summary", "--channel", "--client", "--client-id", "--client-secret", "--company-domain", "--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-accounts", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--max-results", "--max-searches", "--max-usd", "--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",
649
649
  ]);
650
650
  // Lifecycle-grouped front door. One line per verb, organized by the
651
651
  // Prevent→Detect→Remediate→Verify loop so a new user sees ~6 jobs, not 22 verbs.
package/dist/cli/icp.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
2
- import { readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { emitKeypressEvents } from "node:readline";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { resolve } from "node:path";
@@ -8,13 +8,15 @@ import { fitThreshold, icpFromAnswers, icpToCrustdataFilters, icpToExploriumFilt
8
8
  import { createFileSignalStore, DEFAULT_SIGNALS_CONFIG } from "../signals.js";
9
9
  import { createFileJudgeStore, DEFAULT_JUDGE_PROMPT, judgeRunId, judgeSignals } from "../judge.js";
10
10
  import { DEFAULT_GOLDEN_NOW_ISO, DEFAULT_GOLDEN_SET, DEFAULT_MIN_ACCURACY, defaultJudgeFn, gradeAgainstOutcomes, gradeJudge, parseGoldenSet } from "../judgeEval.js";
11
- import { loadIcp, numericOption, option, readSecret, readSnapshot, resolveLlmBaseUrls, saveRequested } from "./shared.js";
11
+ import { loadIcp, numericOption, option, readPackageInfo, readSecret, readSnapshot, resolveLlmBaseUrls, saveRequested } from "./shared.js";
12
12
  import { createStatusLine } from "./ui.js";
13
13
  import { box, colorEnabled, paint, truncateToWidth } from "./ui.js";
14
14
  import { getCredential, storeCredential } from "../credentials.js";
15
15
  import { deriveWebsiteIcp, icpReviewSegments, OPENROUTER_API_BASE } from "../icpDerive.js";
16
16
  import { unknownSubcommandError } from "./suggest.js";
17
- import { writeHostedArtifact } from "../hostedArtifacts.js";
17
+ import { readHostedArtifact, writeHostedArtifact } from "../hostedArtifacts.js";
18
+ import { artifactKeyForDomain, extractHostedIcp, getIcpSyncStatus, markIcpSynced, pushIcp, readIcpSyncState } from "../icpSync.js";
19
+ import { writeSecureFileAtomic } from "../secureFile.js";
18
20
  function renderJudgeDecisions(decisions) {
19
21
  if (decisions.length === 0)
20
22
  return "No accounts cleared the score threshold.";
@@ -192,6 +194,14 @@ export async function icpCommand(args) {
192
194
  fullstackgtm icp interview emit the interview spec (an agent drives it with AskUserQuestion)
193
195
  fullstackgtm icp derive --domain <site> [--model <id>] [--out icp.json] [--json]
194
196
  derive an evidence-backed ICP from a public website (OpenRouter/OpenAI/Anthropic)
197
+ fullstackgtm icp status --domain <site> [--icp icp.json] [--json]
198
+ compare local and hosted revisions without changing either
199
+ fullstackgtm icp pull --domain <site> [--out icp.json] [--force] [--json]
200
+ explicitly download hosted canonical ICP; refuses to replace an existing file without --force
201
+ fullstackgtm icp push --domain <site> [--icp icp.json] [--change-summary <text>] [--json]
202
+ publish the local ICP with revision conflict protection
203
+ fullstackgtm icp sync --domain <site> [--icp icp.json] [--json]
204
+ reconcile safe one-sided changes; hosted changes become a sibling review file
195
205
  fullstackgtm icp set <answers.json> [--name <n>] [--out <path>] write icp.json from interview answers
196
206
  fullstackgtm icp show [--icp <path>] render the ICP + the discovery filters it produces
197
207
  fullstackgtm icp judge [--signals-from latest|<label>] [--with-history] [--prompt <path>] [--min-score 0] [--save] rank unjudged signals into send/nurture/skip decisions (timing x fit x memory)
@@ -245,10 +255,15 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
245
255
  }
246
256
  const mirrored = await writeHostedArtifact({
247
257
  kind: "icp", key: `icp:${derived.company.domain}`, label: derived.icp.name,
248
- domain: derived.company.domain, document: derived,
258
+ domain: derived.company.domain, document: derived, sourceVersion: readPackageInfo().version,
249
259
  });
250
- if (mirrored.status === "saved")
251
- console.error("Mirrored the reviewed ICP to the paired hosted workspace.");
260
+ if (mirrored.status === "saved") {
261
+ if (out)
262
+ markIcpSynced(resolve(process.cwd(), out), derived.company.domain, mirrored, derived.icp);
263
+ console.error(`Mirrored the reviewed ICP to the paired hosted workspace at revision ${mirrored.revision}.`);
264
+ }
265
+ else if (mirrored.status === "conflict")
266
+ console.error(`Warning: ${mirrored.reason}. Run \`fullstackgtm icp sync --domain ${derived.company.domain}${out ? ` --icp ${out}` : ""}\` to reconcile.`);
252
267
  else if (mirrored.status === "unavailable")
253
268
  console.error(`Warning: ${mirrored.reason}. The local ICP is still authoritative.`);
254
269
  if (rest.includes("--json"))
@@ -257,6 +272,84 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
257
272
  console.log(renderDerivedIcp(derived));
258
273
  return;
259
274
  }
275
+ if (["status", "pull", "push", "sync"].includes(sub)) {
276
+ const domain = option(rest, "--domain");
277
+ if (!domain)
278
+ throw new Error(`icp ${sub}: --domain <company.com> is required.`);
279
+ const asJson = rest.includes("--json");
280
+ const icpPath = resolve(process.cwd(), option(rest, "--icp") ?? option(rest, "--out") ?? "icp.json");
281
+ if (sub === "status") {
282
+ if (!existsSync(icpPath))
283
+ throw new Error(`icp status: local ICP not found at ${icpPath}.`);
284
+ const checked = await getIcpSyncStatus(icpPath, domain);
285
+ if (asJson)
286
+ console.log(JSON.stringify({ path: icpPath, domain, ...checked.status }, null, 2));
287
+ else
288
+ console.log(`ICP ${checked.status.state.replaceAll("_", " ")} · local ${checked.status.localIcpSha256.slice(0, 12)}${checked.status.hostedRevision ? ` · hosted r${checked.status.hostedRevision}` : ""}${checked.status.trackedRevision ? ` · last synced r${checked.status.trackedRevision}` : ""}`);
289
+ return;
290
+ }
291
+ if (sub === "pull") {
292
+ const out = resolve(process.cwd(), option(rest, "--out") ?? "icp.json");
293
+ const hosted = await readHostedArtifact("icp", artifactKeyForDomain(domain));
294
+ if (hosted.status !== "found")
295
+ throw new Error(hosted.status === "missing" ? `No hosted ICP exists for ${domain}.` : hosted.status === "unpaired" ? "This CLI is not paired with a hosted workspace." : hosted.reason);
296
+ const icp = extractHostedIcp(hosted.state);
297
+ if (existsSync(out) && !rest.includes("--force"))
298
+ throw new Error(`Refusing to replace ${out}. Re-run with --force after reviewing hosted revision ${hosted.state.revision}.`);
299
+ writeSecureFileAtomic(out, `${JSON.stringify(icp, null, 2)}\n`);
300
+ markIcpSynced(out, domain, hosted.state, icp);
301
+ if (asJson)
302
+ console.log(JSON.stringify({ status: "pulled", path: out, revision: hosted.state.revision, documentSha256: hosted.state.documentSha256 }, null, 2));
303
+ else
304
+ console.log(`Pulled hosted ICP revision ${hosted.state.revision} to ${out}.`);
305
+ return;
306
+ }
307
+ if (!existsSync(icpPath))
308
+ throw new Error(`icp ${sub}: local ICP not found at ${icpPath}.`);
309
+ if (sub === "push") {
310
+ const pushed = await pushIcp(icpPath, domain, option(rest, "--change-summary") ?? undefined);
311
+ if (pushed.status === "conflict")
312
+ throw new Error(`ICP conflict: hosted revision ${pushed.checked.status.hostedRevision ?? "?"} changed since local revision ${pushed.checked.status.trackedRevision ?? "untracked"}. Run \`icp sync\` to write a review copy.`);
313
+ if (pushed.result.status !== "saved")
314
+ throw new Error(pushed.result.status === "unpaired" ? "This CLI is not paired with a hosted workspace." : pushed.result.status === "conflict" ? pushed.result.reason : pushed.result.reason);
315
+ if (asJson)
316
+ console.log(JSON.stringify({ status: "pushed", revision: pushed.result.revision, documentSha256: pushed.result.documentSha256, unchanged: pushed.result.unchanged }, null, 2));
317
+ else
318
+ console.log(`${pushed.result.unchanged ? "Already at" : "Published"} hosted ICP revision ${pushed.result.revision}.`);
319
+ return;
320
+ }
321
+ const checked = await getIcpSyncStatus(icpPath, domain);
322
+ if (checked.status.state === "local_changed" || checked.status.state === "missing_hosted") {
323
+ const pushed = await pushIcp(icpPath, domain, "Synchronized local ICP");
324
+ if (pushed.result?.status !== "saved")
325
+ throw new Error("ICP sync could not publish the local change.");
326
+ if (asJson)
327
+ console.log(JSON.stringify({ status: "pushed", revision: pushed.result.revision }, null, 2));
328
+ else
329
+ console.log(`Published local ICP as hosted revision ${pushed.result.revision}.`);
330
+ return;
331
+ }
332
+ if ((checked.status.state === "hosted_changed" || checked.status.state === "conflict" || checked.status.state === "untracked") && checked.hosted) {
333
+ const reviewPath = `${icpPath}.hosted-r${checked.hosted.revision}.json`;
334
+ writeSecureFileAtomic(reviewPath, `${JSON.stringify(extractHostedIcp(checked.hosted), null, 2)}\n`);
335
+ const result = { status: checked.status.state, reviewPath, hostedRevision: checked.hosted.revision, trackedRevision: readIcpSyncState(icpPath)?.revision };
336
+ if (asJson)
337
+ console.log(JSON.stringify(result, null, 2));
338
+ else
339
+ console.log(`Hosted changes need review. Wrote revision ${checked.hosted.revision} to ${reviewPath}; your local ICP was not replaced.`);
340
+ return;
341
+ }
342
+ if (checked.status.state === "in_sync") {
343
+ if (!readIcpSyncState(icpPath) && checked.hosted)
344
+ markIcpSynced(icpPath, domain, checked.hosted, checked.local);
345
+ if (asJson)
346
+ console.log(JSON.stringify({ status: "in_sync", revision: checked.status.hostedRevision }, null, 2));
347
+ else
348
+ console.log(`ICP is in sync${checked.status.hostedRevision ? ` at revision ${checked.status.hostedRevision}` : ""}.`);
349
+ return;
350
+ }
351
+ throw new Error(checked.status.reason ?? `ICP sync unavailable (${checked.status.state}).`);
352
+ }
260
353
  if (sub === "set") {
261
354
  const file = rest.find((a) => !a.startsWith("--"));
262
355
  if (!file)
@@ -11,6 +11,7 @@ import { loadIcp, option, readSnapshot, repeatedOption, saveRequested } from "./
11
11
  import { createStatusLine } from "./ui.js";
12
12
  import { unknownSubcommandError } from "./suggest.js";
13
13
  import { writeHostedArtifact } from "../hostedArtifacts.js";
14
+ import { readIcpSyncState } from "../icpSync.js";
14
15
  /**
15
16
  * Resolve a signals config: explicit --config, else signals.config.json in cwd,
16
17
  * else the zero-config DEFAULT_SIGNALS_CONFIG (preset-first, like enrich).
@@ -170,7 +171,18 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
170
171
  await store.appendRun({ id: signalRunId(runLabel), runLabel, startedAt: now.toISOString(), completedAt: new Date().toISOString(),
171
172
  buckets: ["job"], counts: { fetched: discovered.summary.rawResults, new: ranked.length, deduped: deduped.length }, signals: ranked });
172
173
  console.error(`Saved signal run "${runLabel}". Next: \`fullstackgtm icp judge --signals-from ${runLabel} --save\`.`);
173
- await mirrorSignalRun(runLabel, now, ["job"], { fetched: discovered.summary.rawResults, new: ranked.length, deduped: deduped.length }, ranked);
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
+ });
174
186
  }
175
187
  else {
176
188
  console.error("(not saved — re-run with --save to persist this evidence to the signal ledger)");
@@ -299,7 +311,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
299
311
  signals: ranked,
300
312
  });
301
313
  console.error(`Saved signal run "${runLabel}" (${fresh.length} fresh). Next: \`fullstackgtm icp judge --save\`.`);
302
- await mirrorSignalRun(runLabel, now, buckets, { fetched, new: fresh.length, deduped: deduped.length }, ranked);
314
+ await mirrorSignalRun(runLabel, now, buckets, { fetched, new: fresh.length, deduped: deduped.length }, ranked, rest);
303
315
  }
304
316
  else {
305
317
  console.error("(not saved — re-run with --save to persist this run to the signal ledger)");
@@ -388,10 +400,13 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
388
400
  }
389
401
  throw unknownSubcommandError("signals", sub, ["fetch", "discover", "list", "outcome", "weights"]);
390
402
  }
391
- async function mirrorSignalRun(runLabel, startedAt, buckets, counts, signals) {
403
+ async function mirrorSignalRun(runLabel, startedAt, buckets, counts, signals, args, discovery) {
404
+ const icpPath = resolve(process.cwd(), option(args, "--icp") ?? "icp.json");
405
+ const tracked = existsSync(icpPath) ? readIcpSyncState(icpPath) : null;
392
406
  const mirrored = await writeHostedArtifact({
393
407
  kind: "signal_run", key: `signals:${signalRunId(runLabel)}`, label: runLabel,
394
- 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,
409
+ icpRef: tracked ? { artifactId: tracked.artifactId, domain: tracked.domain, revision: tracked.revision, localIcpSha256: tracked.localIcpSha256 } : undefined },
395
410
  });
396
411
  if (mirrored.status === "saved")
397
412
  console.error("Mirrored the signal run to the paired hosted workspace.");
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
+ }
@@ -1,21 +1,55 @@
1
+ export type HostedArtifactKind = "icp" | "signal_run" | "lead_run";
1
2
  export type HostedArtifact = {
2
- kind: "icp" | "signal_run";
3
+ kind: HostedArtifactKind;
3
4
  key: string;
4
5
  label: string;
5
6
  domain?: string;
6
7
  document: unknown;
7
8
  sourceVersion?: string;
9
+ expectedRevision?: number;
10
+ changeSummary?: string;
11
+ };
12
+ export type HostedArtifactState = HostedArtifact & {
13
+ artifactId: string;
14
+ revision: number;
15
+ documentSha256?: string;
16
+ origin: "cli" | "hosted";
17
+ updatedAt: number;
8
18
  };
9
19
  export type HostedArtifactResult = {
10
20
  status: "unpaired";
11
21
  } | {
12
22
  status: "saved";
23
+ artifactId: string;
13
24
  created: boolean;
14
25
  updatedAt: number;
26
+ revision: number;
27
+ documentSha256: string;
28
+ unchanged: boolean;
29
+ } | {
30
+ status: "conflict";
31
+ reason: string;
32
+ currentRevision?: number;
33
+ documentSha256?: string;
15
34
  } | {
16
35
  status: "unavailable";
17
36
  reason: string;
18
37
  };
38
+ export type HostedArtifactReadResult = {
39
+ status: "unpaired";
40
+ } | {
41
+ status: "found";
42
+ state: HostedArtifactState;
43
+ } | {
44
+ status: "missing";
45
+ } | {
46
+ status: "unavailable";
47
+ reason: string;
48
+ };
49
+ export declare function readHostedArtifact(kind: HostedArtifactKind, key: string, options?: {
50
+ fetchImpl?: typeof fetch;
51
+ timeoutMs?: number;
52
+ }): Promise<HostedArtifactReadResult>;
19
53
  export declare function writeHostedArtifact(artifact: HostedArtifact, options?: {
20
54
  fetchImpl?: typeof fetch;
21
55
  timeoutMs?: number;