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,4 +1,4 @@
1
- /** Best-effort artifact mirroring for a human-paired CLI profile. */
1
+ /** Version-aware hosted artifact transport for a human-paired CLI profile. */
2
2
  import { getCredential } from "./credentials.js";
3
3
  const ENDPOINT = "/api/cli/artifact";
4
4
  const DEFAULT_TIMEOUT_MS = 4000;
@@ -17,27 +17,52 @@ function broker() {
17
17
  return null;
18
18
  }
19
19
  }
20
+ function requestOptions(accessToken, timeoutMs) {
21
+ return { headers: { Authorization: `Bearer ${accessToken}` }, signal: AbortSignal.timeout(timeoutMs) };
22
+ }
23
+ export async function readHostedArtifact(kind, key, options = {}) {
24
+ const paired = broker();
25
+ if (!paired)
26
+ return { status: "unpaired" };
27
+ try {
28
+ const url = new URL(`${paired.baseUrl}${ENDPOINT}`);
29
+ url.searchParams.set("kind", kind);
30
+ url.searchParams.set("key", key);
31
+ const response = await (options.fetchImpl ?? fetch)(url, requestOptions(paired.accessToken, options.timeoutMs ?? DEFAULT_TIMEOUT_MS));
32
+ if (response.status === 404)
33
+ return { status: "missing" };
34
+ if (!response.ok)
35
+ return { status: "unavailable", reason: `hosted artifact request failed (HTTP ${response.status})` };
36
+ const state = await response.json();
37
+ if (!state.artifactId || !Number.isSafeInteger(state.revision) || !state.document)
38
+ return { status: "unavailable", reason: "hosted artifact response was invalid" };
39
+ return { status: "found", state };
40
+ }
41
+ catch {
42
+ return { status: "unavailable", reason: "hosted artifact request was unavailable" };
43
+ }
44
+ }
20
45
  export async function writeHostedArtifact(artifact, options = {}) {
21
- if (!artifact.key || artifact.key.length > 256 || !artifact.label || artifact.label.length > 200) {
46
+ if (!artifact.key || artifact.key.length > 256 || !artifact.label || artifact.label.length > 200)
22
47
  throw new Error("Hosted artifact key/label is invalid.");
23
- }
24
48
  const paired = broker();
25
49
  if (!paired)
26
50
  return { status: "unpaired" };
27
51
  try {
28
52
  const response = await (options.fetchImpl ?? fetch)(`${paired.baseUrl}${ENDPOINT}`, {
29
- method: "POST",
30
- headers: { Authorization: `Bearer ${paired.accessToken}`, "Content-Type": "application/json" },
31
- body: JSON.stringify(artifact),
32
- signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
53
+ method: "POST", ...requestOptions(paired.accessToken, options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
54
+ headers: { Authorization: `Bearer ${paired.accessToken}`, "Content-Type": "application/json" }, body: JSON.stringify(artifact),
33
55
  });
56
+ if (response.status === 409) {
57
+ const body = await response.json();
58
+ return { status: "conflict", reason: "hosted ICP changed since the last sync", currentRevision: body.currentRevision, documentSha256: body.documentSha256 };
59
+ }
34
60
  if (!response.ok)
35
61
  return { status: "unavailable", reason: `hosted artifact request failed (HTTP ${response.status})` };
36
62
  const body = await response.json();
37
- if (typeof body.created !== "boolean" || typeof body.updatedAt !== "number") {
63
+ if (typeof body.artifactId !== "string" || typeof body.created !== "boolean" || typeof body.updatedAt !== "number" || typeof body.revision !== "number" || typeof body.documentSha256 !== "string")
38
64
  return { status: "unavailable", reason: "hosted artifact response was invalid" };
39
- }
40
- return { status: "saved", created: body.created, updatedAt: body.updatedAt };
65
+ return { status: "saved", artifactId: body.artifactId, created: body.created, updatedAt: body.updatedAt, revision: body.revision, documentSha256: body.documentSha256, unchanged: body.unchanged === true };
41
66
  }
42
67
  catch {
43
68
  return { status: "unavailable", reason: "hosted artifact request was unavailable" };
package/dist/icp.d.ts CHANGED
@@ -71,6 +71,9 @@ export type Icp = {
71
71
  scoring?: {
72
72
  /** minimum fit (0..1) for a prospect to become a create_record op. Default 0.5. */
73
73
  threshold?: number;
74
+ /** Require a literal persona.titleKeywords phrase in title/headline. This
75
+ * disables the broader function fallback for high-precision sourcing. */
76
+ requireTitleKeyword?: boolean;
74
77
  };
75
78
  };
76
79
  export declare const DEFAULT_FIT_THRESHOLD = 0.5;
package/dist/icp.js CHANGED
@@ -381,15 +381,18 @@ export function scoreProspectAgainstIcp(prospect, icp) {
381
381
  const keywords = (icp.persona.titleKeywords ?? []).map((k) => k.toLowerCase());
382
382
  const levels = (icp.persona.jobLevels ?? []).map((l) => l.toLowerCase());
383
383
  const depts = (icp.persona.departments ?? []).map((d) => d.toLowerCase());
384
+ const exactTitleKeyword = keywords.find((keyword) => title.includes(keyword));
385
+ if (icp.scoring?.requireTitleKeyword && keywords.length && !exactTitleKeyword) {
386
+ return { score: 0, reasons: ["title does not contain a required ICP keyword"] };
387
+ }
384
388
  let score = 0;
385
389
  let weightSum = 0;
386
390
  if (keywords.length) {
387
391
  weightSum += 0.6;
388
- const exact = keywords.find((k) => title.includes(k));
389
- const functional = exact ? undefined : roleKeywords(icp).find((keyword) => title.includes(keyword));
390
- if (exact) {
392
+ const functional = exactTitleKeyword ? undefined : roleKeywords(icp).find((keyword) => title.includes(keyword));
393
+ if (exactTitleKeyword) {
391
394
  score += 0.6;
392
- reasons.push(`title matches ICP keyword "${exact}"`);
395
+ reasons.push(`title matches ICP keyword "${exactTitleKeyword}"`);
393
396
  }
394
397
  else if (functional) {
395
398
  score += 0.45;
@@ -0,0 +1,55 @@
1
+ import { type Icp } from "./icp.ts";
2
+ import { type HostedArtifactState } from "./hostedArtifacts.ts";
3
+ export type IcpSyncState = {
4
+ version: 1;
5
+ artifactId: string;
6
+ key: string;
7
+ domain: string;
8
+ revision: number;
9
+ localIcpSha256: string;
10
+ hostedDocumentSha256?: string;
11
+ syncedAt: string;
12
+ };
13
+ export type IcpSyncStatus = {
14
+ state: "unpaired" | "missing_hosted" | "in_sync" | "local_changed" | "hosted_changed" | "conflict" | "untracked" | "unavailable";
15
+ localIcpSha256: string;
16
+ hostedRevision?: number;
17
+ trackedRevision?: number;
18
+ reason?: string;
19
+ };
20
+ export declare function classifyIcpSync(localHash: string, hostedRevision: number, hostedHash: string, tracked: IcpSyncState | null): IcpSyncStatus["state"];
21
+ export declare function canonicalJson(value: unknown): string;
22
+ export declare const icpSha256: (icp: Icp) => string;
23
+ export declare function artifactKeyForDomain(domain: string): string;
24
+ export declare const sidecarPathFor: (icpPath: string) => string;
25
+ export declare function readLocalIcp(path: string): Icp;
26
+ export declare function extractHostedIcp(state: HostedArtifactState): Icp;
27
+ export declare function readIcpSyncState(icpPath: string): IcpSyncState | null;
28
+ export declare function writeIcpSyncState(icpPath: string, state: IcpSyncState): void;
29
+ export declare function getIcpSyncStatus(icpPath: string, domain: string): Promise<{
30
+ status: IcpSyncStatus;
31
+ hosted?: HostedArtifactState;
32
+ local: Icp;
33
+ }>;
34
+ export declare function markIcpSynced(icpPath: string, domain: string, hosted: HostedArtifactState | {
35
+ artifactId: string;
36
+ revision: number;
37
+ documentSha256?: string;
38
+ }, icp: Icp): void;
39
+ export declare function pushIcp(icpPath: string, domain: string, changeSummary?: string): Promise<{
40
+ status: "conflict";
41
+ checked: {
42
+ status: IcpSyncStatus;
43
+ hosted?: HostedArtifactState;
44
+ local: Icp;
45
+ };
46
+ result?: undefined;
47
+ } | {
48
+ status: "conflict" | "unpaired" | "unavailable" | "saved";
49
+ result: import("./hostedArtifacts.ts").HostedArtifactResult;
50
+ checked: {
51
+ status: IcpSyncStatus;
52
+ hosted?: HostedArtifactState;
53
+ local: Icp;
54
+ };
55
+ }>;
@@ -0,0 +1,93 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { parseIcp } from "./icp.js";
5
+ import { readSecureRegularFile, writeSecureFileAtomic } from "./secureFile.js";
6
+ import { readHostedArtifact, writeHostedArtifact } from "./hostedArtifacts.js";
7
+ export function classifyIcpSync(localHash, hostedRevision, hostedHash, tracked) {
8
+ if (!tracked)
9
+ return localHash === hostedHash ? "in_sync" : "untracked";
10
+ const localChanged = localHash !== tracked.localIcpSha256;
11
+ const hostedChanged = hostedRevision !== tracked.revision;
12
+ return localChanged && hostedChanged ? "conflict" : localChanged ? "local_changed" : hostedChanged ? "hosted_changed" : "in_sync";
13
+ }
14
+ export function canonicalJson(value) {
15
+ if (value === null || typeof value !== "object")
16
+ return JSON.stringify(value);
17
+ if (Array.isArray(value))
18
+ return `[${value.map(canonicalJson).join(",")}]`;
19
+ const object = value;
20
+ return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(",")}}`;
21
+ }
22
+ export const icpSha256 = (icp) => createHash("sha256").update(canonicalJson(icp)).digest("hex");
23
+ export function artifactKeyForDomain(domain) {
24
+ const candidate = /^https?:\/\//i.test(domain.trim()) ? domain.trim() : `https://${domain.trim()}`;
25
+ let parsed;
26
+ try {
27
+ parsed = new URL(candidate);
28
+ }
29
+ catch {
30
+ throw new Error(`Invalid company domain: ${domain}`);
31
+ }
32
+ const hostname = parsed.hostname.toLowerCase().replace(/^www\./, "").replace(/\.$/, "");
33
+ if (!hostname.includes("."))
34
+ throw new Error(`Invalid public company domain: ${domain}`);
35
+ return `icp:${hostname}`;
36
+ }
37
+ export const sidecarPathFor = (icpPath) => resolve(dirname(resolve(icpPath)), ".fullstackgtm", `${resolve(icpPath).split(/[\\/]/).pop()}.sync.json`);
38
+ export function readLocalIcp(path) { return parseIcp(readSecureRegularFile(resolve(path))); }
39
+ export function extractHostedIcp(state) {
40
+ const root = state.document && typeof state.document === "object" && !Array.isArray(state.document) ? state.document : {};
41
+ return parseIcp(JSON.stringify(root.icp ?? state.document));
42
+ }
43
+ export function readIcpSyncState(icpPath) {
44
+ const path = sidecarPathFor(icpPath);
45
+ if (!existsSync(path))
46
+ return null;
47
+ try {
48
+ const value = JSON.parse(readSecureRegularFile(path));
49
+ return value?.version === 1 ? value : null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ export function writeIcpSyncState(icpPath, state) {
56
+ const path = sidecarPathFor(icpPath);
57
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
58
+ writeSecureFileAtomic(path, `${JSON.stringify(state, null, 2)}\n`);
59
+ }
60
+ export async function getIcpSyncStatus(icpPath, domain) {
61
+ const local = readLocalIcp(icpPath);
62
+ const localHash = icpSha256(local);
63
+ const tracked = readIcpSyncState(icpPath);
64
+ const read = await readHostedArtifact("icp", artifactKeyForDomain(domain));
65
+ if (read.status === "unpaired")
66
+ return { local, status: { state: "unpaired", localIcpSha256: localHash } };
67
+ if (read.status === "missing")
68
+ return { local, status: { state: "missing_hosted", localIcpSha256: localHash } };
69
+ if (read.status === "unavailable")
70
+ return { local, status: { state: "unavailable", localIcpSha256: localHash, reason: read.reason } };
71
+ const hostedHash = icpSha256(extractHostedIcp(read.state));
72
+ const state = classifyIcpSync(localHash, read.state.revision, hostedHash, tracked);
73
+ return { local, hosted: read.state, status: { state, localIcpSha256: localHash, hostedRevision: read.state.revision, trackedRevision: tracked?.revision } };
74
+ }
75
+ export function markIcpSynced(icpPath, domain, hosted, icp) {
76
+ const key = artifactKeyForDomain(domain);
77
+ const normalizedDomain = key.slice("icp:".length);
78
+ writeIcpSyncState(icpPath, { version: 1, artifactId: hosted.artifactId, key, domain: normalizedDomain, revision: hosted.revision, localIcpSha256: icpSha256(icp), hostedDocumentSha256: hosted.documentSha256, syncedAt: new Date().toISOString() });
79
+ }
80
+ export async function pushIcp(icpPath, domain, changeSummary) {
81
+ const checked = await getIcpSyncStatus(icpPath, domain);
82
+ if (checked.status.state === "conflict" || checked.status.state === "hosted_changed" || checked.status.state === "untracked")
83
+ return { status: "conflict", checked };
84
+ const current = checked.hosted;
85
+ const root = current?.document && typeof current.document === "object" && !Array.isArray(current.document) ? current.document : {};
86
+ const key = artifactKeyForDomain(domain);
87
+ const normalizedDomain = key.slice("icp:".length);
88
+ const document = Object.keys(root).length ? { ...root, icp: checked.local, humanReview: { reviewedAt: new Date().toISOString(), source: "cli" } } : { company: { domain: normalizedDomain }, icp: checked.local, evidence: [], humanReview: { reviewedAt: new Date().toISOString(), source: "cli" } };
89
+ const result = await writeHostedArtifact({ kind: "icp", key, label: checked.local.name, domain: normalizedDomain, document, expectedRevision: current?.revision ?? 0, changeSummary });
90
+ if (result.status === "saved")
91
+ markIcpSynced(icpPath, domain, { artifactId: result.artifactId, revision: result.revision, documentSha256: result.documentSha256 }, checked.local);
92
+ return { status: result.status, result, checked };
93
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { auditSnapshot, defaultPolicy } from "./audit.ts";
2
2
  export { DEFAULT_ICP_DERIVATION_MODEL, OPENROUTER_API_BASE, deriveWebsiteIcp, normalizeCompanyWebsite, websiteText, icpReviewSegments, type WebsiteIcpDerivation, type WebsiteIcpEvidence, type IcpReviewSegment, type IcpDerivationProgress, } from "./icpDerive.ts";
3
+ export { artifactKeyForDomain, classifyIcpSync, extractHostedIcp, getIcpSyncStatus, icpSha256, markIcpSynced, pushIcp, readIcpSyncState, sidecarPathFor, type IcpSyncState, type IcpSyncStatus, } from "./icpSync.ts";
3
4
  export { acquireCheckpointId, acquireCheckpointsDir, createFileAcquireCheckpointStore, validateAcquireCheckpoint, validateAcquireCheckpointKey, type AcquireCheckpoint, type AcquireCheckpointKey, type AcquireCheckpointStore, type AcquireContinuation, } from "./acquireCheckpoint.ts";
4
5
  export { matchesTerritory, parseAssignmentPolicy, resolveAssignment, type AssignmentContext, type AssignmentPolicy, type AssignmentResult, type AssignmentStrategy, type TerritoryRule, } from "./assign.ts";
5
6
  export { buildBulkUpdatePlan, isFilterableField, parseWhere, type BulkUpdateOptions } from "./bulkUpdate.ts";
@@ -27,6 +28,7 @@ export { claimHostedPlanApply, hostedPlanDigest, hostedReviewDocument, readHoste
27
28
  export { scaffoldWorkspace, starterEnrichConfig, starterIcp, starterPlaybook, type InitProvider, type InitSource, type ScaffoldFile, type ScaffoldOptions, } from "./init.ts";
28
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";
29
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";
30
32
  export { diffFindings, diffSnapshots, diffToMarkdown, type CollectionDiff, type FieldChange, type FindingsDrift, type RecordChange, type SnapshotDiff, } from "./diff.ts";
31
33
  export { mergeSnapshots, type MergeConflict, type MergeMatch, type MergeReport, type MergeSuggestion, } from "./merge.ts";
32
34
  export { createFilePlanStore, type PlanStore, type StoredPlan } from "./planStore.ts";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { auditSnapshot, defaultPolicy } from "./audit.js";
2
2
  export { DEFAULT_ICP_DERIVATION_MODEL, OPENROUTER_API_BASE, deriveWebsiteIcp, normalizeCompanyWebsite, websiteText, icpReviewSegments, } from "./icpDerive.js";
3
+ export { artifactKeyForDomain, classifyIcpSync, extractHostedIcp, getIcpSyncStatus, icpSha256, markIcpSynced, pushIcp, readIcpSyncState, sidecarPathFor, } from "./icpSync.js";
3
4
  export { acquireCheckpointId, acquireCheckpointsDir, createFileAcquireCheckpointStore, validateAcquireCheckpoint, validateAcquireCheckpointKey, } from "./acquireCheckpoint.js";
4
5
  export { matchesTerritory, parseAssignmentPolicy, resolveAssignment, } from "./assign.js";
5
6
  export { buildBulkUpdatePlan, isFilterableField, parseWhere } from "./bulkUpdate.js";
@@ -27,6 +28,7 @@ export { claimHostedPlanApply, hostedPlanDigest, hostedReviewDocument, readHoste
27
28
  export { scaffoldWorkspace, starterEnrichConfig, starterIcp, starterPlaybook, } from "./init.js";
28
29
  export { appendCoverage, classifyAccount, classifyCoverage, computeCoverage, coverageCountsFromSnapshot, coveredAccounts, coverageToText, crmCheckableCriteria, deriveAcvFromClosedWon, deriveBuyersPerAccount, estimateTam, loadTamModel, projectEta, readCoverageTimeline, saveTamModel, tamDir, tamReportToMarkdown, } from "./tam.js";
29
30
  export { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, } from "./enrichApollo.js";
31
+ export { createZoomInfoClient, pullZoomInfoRecords, } from "./enrichZoomInfo.js";
30
32
  export { diffFindings, diffSnapshots, diffToMarkdown, } from "./diff.js";
31
33
  export { mergeSnapshots, } from "./merge.js";
32
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
 
@@ -103,6 +103,11 @@ replays automatically: reconcile provider state, then `plans recover ...
103
103
  reconciliation. Review documents are immutable/hash-bound; hosted approval
104
104
  becomes executable only after the CLI verifies it and generates local HMAC
105
105
  signatures. Hosted never owns the apply lease for CLI-origin plans.
106
+ - `hostedArtifacts.ts` + `icpSync.ts` — version-aware artifact transport and
107
+ conservative bidirectional ICP reconciliation. Published revisions use
108
+ compare-and-swap; a private sidecar binds a local ICP hash to its last hosted
109
+ revision. Divergent arrays are never auto-merged and hosted changes never
110
+ silently replace the execution-local ICP file.
106
111
  - `assign.ts` — `AssignmentPolicy` (fixed / round-robin / territory /
107
112
  account-owner): the pure owner-routing rule `buildAcquirePlan` stamps onto new
108
113
  leads (never born ownerless) and `reassign --assign-unowned` reuses to backfill.
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.56.1",
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";
@@ -28,11 +29,13 @@ import { unknownSubcommandError } from "./suggest.ts";
28
29
  import { box, colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint, scoreColor, truncateToWidth, type Paint } from "./ui.ts";
29
30
  import { compactPlan, verbosePlanRequested } from "./planOutput.ts";
30
31
  import type { AcquireBudget } from "../acquireMeter.ts";
32
+ import { writeHostedArtifact } from "../hostedArtifacts.ts";
33
+ import { readIcpSyncState } from "../icpSync.ts";
31
34
 
32
35
 
33
36
  /**
34
- * The enrich layer: governed append/refresh of third-party data (Apollo pull,
35
- * 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
36
39
  * contract. State lives in the profile-scoped run store (checkpoint,
37
40
  * staleness ledger, observability in one); scheduling belongs to the
38
41
  * horizontal scheduler — enrich owns no cron logic.
@@ -75,8 +78,9 @@ and leaves them unassigned. Backfill existing ownerless records with
75
78
  \`reassign --assign-unowned --to <ownerId>\`.
76
79
 
77
80
  append pulls from an api source (Apollo — BYO key via \`login apollo\` or
78
- APOLLO_API_KEY) or reads data staged by \`enrich ingest\` (Clay CSV exports,
79
- 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
80
84
  match keys in enrich.config.json (unique hit wins; zero hits falls through to
81
85
  the next key; multiple hits skip or flow into the suggest chain, per
82
86
  onAmbiguous), and emits a fill-blanks-only patch plan. Without --save it
@@ -259,6 +263,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
259
263
  // piped) and, via the composed reporter, heartbeat to a paired hosted app.
260
264
  // The meter reading (creates vs headroom + budget burn) rides the same
261
265
  // emitter, feeding the dashboard's gauge without printing anything new.
266
+ const acquireRunLabel = option(rest, "--run-label") ?? `acquire-${source}-${today}`;
262
267
  let result: ReturnType<typeof buildAcquirePlan>;
263
268
  try {
264
269
  acquireProgress.stage(ACQUIRE_STAGES[2], 2, ACQUIRE_STAGES.length);
@@ -275,7 +280,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
275
280
  source,
276
281
  snapshot,
277
282
  records,
278
- runLabel: option(rest, "--run-label") ?? `acquire-${source}-${today}`,
283
+ runLabel: acquireRunLabel,
279
284
  maxRecords: cap,
280
285
  progress: acquireProgress,
281
286
  });
@@ -293,7 +298,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
293
298
  // Observability: headline metrics for the web run timeline (paired users).
294
299
  reportCounts({
295
300
  sourced: result.counts.fetched,
296
- created: result.counts.created,
301
+ proposed: result.counts.created,
297
302
  withheldByMeter: result.counts.withheldByMeter,
298
303
  skippedInCrm: apiSkippedCrm,
299
304
  skippedSeen: apiSkippedSeen,
@@ -302,6 +307,16 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
302
307
 
303
308
  const meterLine = formatAcquireMeter(headroom, costPerRecord);
304
309
  const gaugeLine = acquireGaugeLine(headroom, config.acquire.budget ?? {}, paint(colorEnabled(process.stdout)));
310
+ const icpPath = resolve(process.cwd(), option(rest, "--icp") ?? "icp.json");
311
+ const trackedIcp = existsSync(icpPath) ? readIcpSyncState(icpPath) : null;
312
+ const leadMirror = await writeHostedArtifact({
313
+ kind: "lead_run", key: `leads:${result.plan.id}`, label: acquireRunLabel,
314
+ domain: trackedIcp?.domain,
315
+ document: { runLabel: acquireRunLabel, createdAt: new Date().toISOString(), source, targetDomain: option(rest, "--company-domain"), counts: result.counts,
316
+ plan: result.plan, icpRef: trackedIcp ? { artifactId: trackedIcp.artifactId, domain: trackedIcp.domain, revision: trackedIcp.revision, localIcpSha256: trackedIcp.localIcpSha256 } : undefined },
317
+ });
318
+ if (leadMirror.status === "saved") console.error(`Recorded lead preview against${trackedIcp ? ` ICP revision ${trackedIcp.revision}` : " the local untracked ICP"}.`);
319
+ else if (leadMirror.status === "unavailable") console.error(`Warning: ${leadMirror.reason}. The local lead preview is unchanged.`);
305
320
  if (!save) {
306
321
  printAcquireOutput({
307
322
  args: rest,
@@ -424,10 +439,19 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
424
439
  );
425
440
  return;
426
441
  }
427
- const client = createApolloClient({
428
- getApiKey: () => apolloApiKey(),
429
- apiBaseUrl: process.env.APOLLO_API_BASE_URL,
430
- });
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;
431
455
  if (save) {
432
456
  run = await openEnrichRun(store, source, mode, option(rest, "--run-label"), today);
433
457
  if (run.cursor) {
@@ -443,7 +467,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
443
467
  let pullProcessed = 0;
444
468
  let result: Awaited<ReturnType<typeof pullApolloRecords>>;
445
469
  try {
446
- result = await pullApolloRecords(client, pullKeys, {
470
+ const pullOptions: ApolloPullOptions = {
447
471
  resumeAfter: run?.cursor ?? null,
448
472
  onProgress: async (progress) => {
449
473
  if (pullBar.active) {
@@ -461,7 +485,10 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
461
485
  if (progress.miss) run.missedKeys = [...(run.missedKeys ?? []), progress.miss.value];
462
486
  await store.update(run);
463
487
  },
464
- });
488
+ };
489
+ result = source === "zoominfo"
490
+ ? await pullZoomInfoRecords(zoomInfoClient!, pullKeys, pullOptions)
491
+ : await pullApolloRecords(apolloClient!, pullKeys, pullOptions);
465
492
  } finally {
466
493
  pullBar.done();
467
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:
@@ -701,14 +701,14 @@ export const COMMAND_FLAGS: Record<string, string[]> = {
701
701
  merge: ["--input", "--out", "--json"],
702
702
  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"],
703
703
  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"],
704
- 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"],
704
+ 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"],
705
705
  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"],
706
706
  draft: ["--from-judge", "--min-score", "--prompt", "--model", "--channel", "--save", "--dry-run", "--verbose", "--json", "--out"],
707
707
  schedule: ["--cron", "--label", "--provider", "--trigger", "--timer", "--runs", "--verbose", "--json"],
708
708
  };
709
709
 
710
710
  export const FLAGS_WITH_VALUES = new Set([
711
- "--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",
711
+ "--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",
712
712
  ]);
713
713
 
714
714
  // Lifecycle-grouped front door. One line per verb, organized by the