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.
- package/CHANGELOG.md +21 -0
- package/README.md +162 -508
- package/dist/cli/enrich.d.ts +2 -2
- package/dist/cli/enrich.js +27 -14
- package/dist/cli/help.js +2 -2
- package/dist/cli/signals.js +14 -3
- package/dist/enrich.d.ts +1 -1
- package/dist/enrich.js +1 -1
- package/dist/enrichZoomInfo.d.ts +33 -0
- package/dist/enrichZoomInfo.js +144 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/runReport.js +11 -0
- package/docs/api.md +1 -1
- package/llms.txt +3 -2
- package/package.json +2 -2
- package/src/cli/enrich.ts +30 -15
- package/src/cli/help.ts +2 -2
- package/src/cli/signals.ts +14 -2
- package/src/enrich.ts +2 -2
- package/src/enrichZoomInfo.ts +197 -0
- package/src/index.ts +11 -0
- package/src/runReport.ts +12 -0
package/dist/cli/enrich.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The enrich layer: governed append/refresh of third-party data (Apollo
|
|
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.
|
package/dist/cli/enrich.js
CHANGED
|
@@ -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
|
|
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
|
|
75
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
276
|
+
kind: "lead_run", key: `leads:${result.plan.id}`, label: acquireRunLabel,
|
|
274
277
|
domain: trackedIcp?.domain,
|
|
275
|
-
document: { runLabel:
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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.",
|
package/dist/cli/signals.js
CHANGED
|
@@ -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
|
|
153
|
-
via `login apollo`/APOLLO_API_KEY) or
|
|
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.
|
|
4
|
-
"description": "Open-source
|
|
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
|
|
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
|
|
81
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
313
|
+
kind: "lead_run", key: `leads:${result.plan.id}`, label: acquireRunLabel,
|
|
311
314
|
domain: trackedIcp?.domain,
|
|
312
|
-
document: { runLabel:
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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:
|
package/src/cli/signals.ts
CHANGED
|
@@ -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.");
|