fullstackgtm 0.44.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/CHANGELOG.md +293 -1
  2. package/INSTALL_FOR_AGENTS.md +13 -11
  3. package/README.md +45 -24
  4. package/dist/audit.d.ts +3 -1
  5. package/dist/audit.js +29 -2
  6. package/dist/backfill.d.ts +86 -0
  7. package/dist/backfill.js +251 -0
  8. package/dist/bin.js +4 -1
  9. package/dist/cli/audit.d.ts +15 -0
  10. package/dist/cli/audit.js +392 -0
  11. package/dist/cli/auth.d.ts +82 -0
  12. package/dist/cli/auth.js +607 -0
  13. package/dist/cli/backfill.d.ts +1 -0
  14. package/dist/cli/backfill.js +125 -0
  15. package/dist/cli/backfillRuns.d.ts +1 -0
  16. package/dist/cli/backfillRuns.js +187 -0
  17. package/dist/cli/call.d.ts +1 -0
  18. package/dist/cli/call.js +387 -0
  19. package/dist/cli/capabilities.d.ts +30 -0
  20. package/dist/cli/capabilities.js +197 -0
  21. package/dist/cli/draft.d.ts +7 -0
  22. package/dist/cli/draft.js +87 -0
  23. package/dist/cli/enrich.d.ts +8 -0
  24. package/dist/cli/enrich.js +806 -0
  25. package/dist/cli/fix.d.ts +33 -0
  26. package/dist/cli/fix.js +346 -0
  27. package/dist/cli/help.d.ts +25 -0
  28. package/dist/cli/help.js +646 -0
  29. package/dist/cli/icp.d.ts +7 -0
  30. package/dist/cli/icp.js +229 -0
  31. package/dist/cli/init.d.ts +7 -0
  32. package/dist/cli/init.js +58 -0
  33. package/dist/cli/market.d.ts +1 -0
  34. package/dist/cli/market.js +391 -0
  35. package/dist/cli/plans.d.ts +5 -0
  36. package/dist/cli/plans.js +456 -0
  37. package/dist/cli/schedule.d.ts +8 -0
  38. package/dist/cli/schedule.js +479 -0
  39. package/dist/cli/shared.d.ts +71 -0
  40. package/dist/cli/shared.js +342 -0
  41. package/dist/cli/signals.d.ts +7 -0
  42. package/dist/cli/signals.js +412 -0
  43. package/dist/cli/suggest.d.ts +49 -0
  44. package/dist/cli/suggest.js +135 -0
  45. package/dist/cli/tam.d.ts +9 -0
  46. package/dist/cli/tam.js +387 -0
  47. package/dist/cli/ui.d.ts +142 -0
  48. package/dist/cli/ui.js +427 -0
  49. package/dist/cli.d.ts +1 -57
  50. package/dist/cli.js +123 -5157
  51. package/dist/connector.d.ts +23 -0
  52. package/dist/connector.js +47 -0
  53. package/dist/connectors/hubspot.d.ts +10 -1
  54. package/dist/connectors/hubspot.js +244 -10
  55. package/dist/connectors/hubspotAuth.js +5 -1
  56. package/dist/connectors/linkedin.js +14 -14
  57. package/dist/connectors/salesforce.d.ts +10 -1
  58. package/dist/connectors/salesforce.js +39 -6
  59. package/dist/connectors/signalSources.js +7 -59
  60. package/dist/connectors/stripe.d.ts +37 -0
  61. package/dist/connectors/stripe.js +103 -31
  62. package/dist/enrich.d.ts +7 -0
  63. package/dist/enrich.js +7 -0
  64. package/dist/health.d.ts +11 -69
  65. package/dist/health.js +4 -134
  66. package/dist/healthScore.d.ts +71 -0
  67. package/dist/healthScore.js +143 -0
  68. package/dist/icp.d.ts +15 -11
  69. package/dist/icp.js +19 -36
  70. package/dist/index.d.ts +3 -1
  71. package/dist/index.js +3 -1
  72. package/dist/judge.d.ts +2 -0
  73. package/dist/judge.js +6 -0
  74. package/dist/llm.d.ts +29 -0
  75. package/dist/llm.js +206 -0
  76. package/dist/market.d.ts +39 -1
  77. package/dist/market.js +116 -44
  78. package/dist/marketClassify.d.ts +11 -1
  79. package/dist/marketClassify.js +17 -2
  80. package/dist/marketTaxonomy.d.ts +6 -1
  81. package/dist/marketTaxonomy.js +4 -2
  82. package/dist/mcp-bin.js +31 -2
  83. package/dist/mcp.js +372 -166
  84. package/dist/progress.d.ts +96 -0
  85. package/dist/progress.js +142 -0
  86. package/dist/runReport.d.ts +24 -0
  87. package/dist/runReport.js +139 -4
  88. package/dist/schedule.d.ts +80 -2
  89. package/dist/schedule.js +254 -1
  90. package/dist/spoolFiles.d.ts +44 -0
  91. package/dist/spoolFiles.js +114 -0
  92. package/dist/types.d.ts +29 -1
  93. package/docs/api.md +75 -8
  94. package/docs/architecture.md +2 -0
  95. package/docs/linkedin-connector-spec.md +1 -1
  96. package/docs/signal-spool-format.md +13 -0
  97. package/llms.txt +18 -9
  98. package/package.json +10 -3
  99. package/skills/fullstackgtm/SKILL.md +2 -1
  100. package/src/audit.ts +27 -1
  101. package/src/backfill.ts +340 -0
  102. package/src/bin.ts +5 -1
  103. package/src/cli/audit.ts +447 -0
  104. package/src/cli/auth.ts +669 -0
  105. package/src/cli/backfill.ts +156 -0
  106. package/src/cli/backfillRuns.ts +198 -0
  107. package/src/cli/call.ts +414 -0
  108. package/src/cli/capabilities.ts +215 -0
  109. package/src/cli/draft.ts +101 -0
  110. package/src/cli/enrich.ts +908 -0
  111. package/src/cli/fix.ts +373 -0
  112. package/src/cli/help.ts +702 -0
  113. package/src/cli/icp.ts +265 -0
  114. package/src/cli/init.ts +65 -0
  115. package/src/cli/market.ts +423 -0
  116. package/src/cli/plans.ts +524 -0
  117. package/src/cli/schedule.ts +526 -0
  118. package/src/cli/shared.ts +392 -0
  119. package/src/cli/signals.ts +434 -0
  120. package/src/cli/suggest.ts +151 -0
  121. package/src/cli/tam.ts +435 -0
  122. package/src/cli/ui.ts +497 -0
  123. package/src/cli.ts +122 -5855
  124. package/src/connector.ts +66 -0
  125. package/src/connectors/hubspot.ts +273 -9
  126. package/src/connectors/hubspotAuth.ts +5 -1
  127. package/src/connectors/linkedin.ts +14 -14
  128. package/src/connectors/salesforce.ts +51 -3
  129. package/src/connectors/signalSources.ts +7 -56
  130. package/src/connectors/stripe.ts +154 -34
  131. package/src/enrich.ts +13 -0
  132. package/src/health.ts +14 -213
  133. package/src/healthScore.ts +223 -0
  134. package/src/icp.ts +19 -35
  135. package/src/index.ts +28 -1
  136. package/src/judge.ts +7 -0
  137. package/src/llm.ts +239 -0
  138. package/src/market.ts +157 -44
  139. package/src/marketClassify.ts +26 -2
  140. package/src/marketTaxonomy.ts +10 -2
  141. package/src/mcp-bin.ts +34 -2
  142. package/src/mcp.ts +270 -63
  143. package/src/progress.ts +197 -0
  144. package/src/runReport.ts +159 -4
  145. package/src/schedule.ts +310 -3
  146. package/src/spoolFiles.ts +116 -0
  147. package/src/types.ts +32 -2
  148. package/docs/dx-punch-list.md +0 -87
  149. package/docs/roadmap-to-1.0.md +0 -211
@@ -0,0 +1,908 @@
1
+ // Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
2
+
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { getCredential } from "../credentials.ts";
6
+ import { patchPlanToMarkdown } from "../format.ts";
7
+ import { createFilePlanStore } from "../planStore.ts";
8
+ import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, builtinAcquirePreset, builtinEnrichPreset, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor, type EnrichConfig, type EnrichCounts, type EnrichObjectType, type EnrichRun, type EnrichRunStore, type EnrichSourceRecord } from "../enrich.ts";
9
+ import { loadMeter, remaining, type AcquireRemaining } from "../acquireMeter.ts";
10
+ import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspects, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys, type Prospect } from "../connectors/prospectSources.ts";
11
+ import { loadSeen, recordSeen } from "../acquireSeen.ts";
12
+ import { progressReporter, reportCounts, reportEvent } from "../runReport.ts";
13
+ import { ACQUIRE_STAGES, composeListeners, createProgressEmitter } from "../progress.ts";
14
+ import { createLinkedInProvider, discoverLinkedInProspects } from "../acquireLinkedIn.ts";
15
+ import { fitThreshold, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp, type Icp } from "../icp.ts";
16
+ import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, type ApolloPullKey } from "../enrichApollo.ts";
17
+ import type { CanonicalGtmSnapshot } from "../types.ts";
18
+ import { isSpoolPath, readSpoolPath } from "../spoolFiles.ts";
19
+ import { isOptionValue, loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.ts";
20
+ import { providerKey } from "./tam.ts";
21
+ import { unknownSubcommandError } from "./suggest.ts";
22
+ import { colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint, type Paint } from "./ui.ts";
23
+ import type { AcquireBudget } from "../acquireMeter.ts";
24
+
25
+
26
+ /**
27
+ * The enrich layer: governed append/refresh of third-party data (Apollo pull,
28
+ * Clay ingest) into the CRM through the normal dry-run → approval → apply
29
+ * contract. State lives in the profile-scoped run store (checkpoint,
30
+ * staleness ledger, observability in one); scheduling belongs to the
31
+ * horizontal scheduler — enrich owns no cron logic.
32
+ */
33
+ export async function enrichCommand(args: string[]) {
34
+ const [subcommand, ...rest] = args;
35
+
36
+ // Catch --help BEFORE config load, credential resolution, or any network
37
+ // call (the 0.14.1/0.18 bug class — `enrich append --help` executing a
38
+ // paid Apollo pull would be its worst recurrence).
39
+ if (!subcommand || subcommand === "--help" || subcommand === "-h" || rest.includes("--help") || rest.includes("-h")) {
40
+ console.log(`Usage:
41
+ enrich append [--source apollo] [--objects companies,contacts] [--save] [--config <path>]
42
+ [source options] [--run-label <label>] [--json]
43
+ enrich refresh [--source apollo] [--stale-days <n>] [--save] [--config <path>]
44
+ [source options] [--run-label <label>] [--json]
45
+ enrich ingest <file.csv|payload.json|spool.jsonl|spool-dir> --source clay [--run-label <label>] [--objects companies|contacts] [--config <path>]
46
+ enrich acquire [--source <id>] [--max <n>] [--list <id>] [--assign-owner <id>] [--save] [--config <path>] [--json]
47
+ enrich status [--runs] [--source <id>] [--config <path>] [--json]
48
+
49
+ acquire creates NET-NEW leads from a staged prospect list (ingest first):
50
+ it dedupes each sourced row against the CRM, skips matches and ambiguities
51
+ (resolve-first never creates over a possible duplicate), and proposes a
52
+ \`create_record\` op per confirmed net-new row — capped by the acquire meter's
53
+ remaining budget (records + spend, per day and per month; whichever is hit
54
+ first). Approval-gated like every write: \`--save\` → plans approve → apply.
55
+ The meter is charged only when a create actually lands at apply.
56
+ Discovery sources (zero-config presets): explorium | pipe0 (ICP-driven search),
57
+ and linkedin (reads a HeyReach lead list — pass --list <id>, key on the LinkedIn
58
+ URL, $0/record; \`login heyreach\` or HEYREACH_API_KEY).
59
+
60
+ Leads are never born ownerless: set an \`acquire.assign\` policy (fixed /
61
+ round-robin / territory / account-owner) to stamp an owner at create time, or
62
+ pass \`--assign-owner <id>\`. With a single-owner portal and no policy, acquire
63
+ defaults every lead to that owner; with several owners and no policy it warns
64
+ and leaves them unassigned. Backfill existing ownerless records with
65
+ \`reassign --assign-unowned --to <ownerId>\`.
66
+
67
+ append pulls from an api source (Apollo — BYO key via \`login apollo\` or
68
+ APOLLO_API_KEY) or reads data staged by \`enrich ingest\` (Clay CSV exports,
69
+ webhook payload JSON), matches source records to CRM records via the ordered
70
+ match keys in enrich.config.json (unique hit wins; zero hits falls through to
71
+ the next key; multiple hits skip or flow into the suggest chain, per
72
+ onAmbiguous), and emits a fill-blanks-only patch plan. Without --save it
73
+ prints the dry-run diff and writes NOTHING; with --save the plan lands in the
74
+ plan store as needs_approval and the run (counts, per-field enrichedAt stamps,
75
+ resume cursor) lands in the profile's enrich run store. From there the normal
76
+ chain takes over: plans approve → apply.
77
+
78
+ refresh computes its work set from the run-store stamps — fields enrich
79
+ itself wrote, opted in with "refresh": true, older than the staleness window
80
+ (--stale-days overrides per-field staleDays and policy.defaultStaleDays) —
81
+ re-fetches the source, and proposes updates only where the source value
82
+ actually changed. Every operation carries beforeValue, so apply-time
83
+ compare-and-set rejects writes over a CRM that moved underneath the plan.
84
+
85
+ Conflict policy (MVP): "never" — enrich only fills blank fields and only
86
+ re-touches fields its own ledger proves it stamped. system-only and always
87
+ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
88
+ return;
89
+ }
90
+
91
+ if (!["append", "refresh", "ingest", "status", "acquire"].includes(subcommand)) {
92
+ throw unknownSubcommandError("enrich", subcommand, ["append", "refresh", "ingest", "status", "acquire"]);
93
+ }
94
+
95
+ const configPath = () => resolve(process.cwd(), option(rest, "--config") ?? ENRICH_CONFIG_FILE_NAME);
96
+ const store = createFileEnrichRunStore();
97
+
98
+ if (subcommand === "status") {
99
+ await enrichStatus(store, rest, configPath());
100
+ return;
101
+ }
102
+
103
+ // Config resolution: an explicit --config or an on-disk default always wins
104
+ // (and validates). Only when neither exists do we fall back to a built-in
105
+ // preset for the source (e.g. `--source clay`), so the common Mode-A loop
106
+ // needs no hand-authored config. A present-but-invalid config still errors.
107
+ const explicitConfig = option(rest, "--config");
108
+ const configFile = configPath();
109
+ // No config file + no --config → fall back to a built-in preset so the common
110
+ // paths need zero hand-authored config: `acquire` gets the zero-config acquire
111
+ // preset (targeted/deduped/metered out the gate); other verbs get the source
112
+ // preset (e.g. clay ingest). An explicit/on-disk config always wins.
113
+ const presetFor = (src: string | undefined) =>
114
+ subcommand === "acquire" ? builtinAcquirePreset(src) : builtinEnrichPreset(src);
115
+ const config =
116
+ !explicitConfig && !existsSync(configFile)
117
+ ? (presetFor(option(rest, "--source") ?? undefined) ?? loadEnrichConfig(configFile))
118
+ : loadEnrichConfig(configFile);
119
+
120
+ // `enrich ingest` stages rows. If the same command also names a CRM source
121
+ // (--input/--provider), collapse the two-step into one: stage, then run the
122
+ // append against the just-staged data so `enrich ingest clay.csv --source clay
123
+ // --input snap.json` returns the hygiene verdict end-to-end. Without a CRM
124
+ // source it stays stage-only (the existing two-step is preserved).
125
+ if (subcommand === "ingest") {
126
+ const autoAppend = Boolean(option(rest, "--provider") || option(rest, "--input"));
127
+ await enrichIngest(store, config, rest, autoAppend);
128
+ if (!autoAppend) return;
129
+ }
130
+
131
+ if (subcommand === "acquire") {
132
+ if (!config.acquire) {
133
+ throw new Error(
134
+ 'enrich acquire: config has no "acquire" section. Add e.g. { "acquire": { "create": { "contact": { "matchKey": "email", "properties": { "email": "email", "firstname": "first_name", "lastname": "last_name" } } }, "budget": { "records": { "perDay": 50, "perMonth": 500 } }, "costPerRecord": { "clay": 0.10 } } } to enrich.config.json.',
135
+ );
136
+ }
137
+ const source = resolveEnrichSource(config, rest);
138
+ const sourceConfig = config.sources[source];
139
+ const save = saveRequested(rest);
140
+ const today = new Date().toISOString().slice(0, 10);
141
+
142
+ // Prospects come from an API source (net-new discovery, e.g. Explorium +
143
+ // pipe0 work-email) or a staged ingest list (Clay/CSV/webhook).
144
+ const snapshot = await readSnapshot(rest);
145
+ const icp = loadIcp(rest);
146
+ if (sourceConfig.kind === "api" && !icp) {
147
+ console.error(
148
+ "⚠ No ICP loaded — discovery will use the raw discovery.filters and skip fit scoring. " +
149
+ "Develop one with `fullstackgtm icp interview` (or pass --icp <path>) so leads map to your ICP.",
150
+ );
151
+ }
152
+ // Recommend the strong dedup key when the CRM carries no LinkedIn URLs:
153
+ // without them, pre-email dedup falls back to the weaker name+domain match.
154
+ if (sourceConfig.kind === "api" && !(snapshot.contacts ?? []).some((c) => c.linkedin)) {
155
+ console.error(
156
+ "⚠ No LinkedIn URLs found on your contacts — pre-email dedup falls back to name+domain (weaker). " +
157
+ "Populate the HubSpot \"LinkedIn URL\" (hs_linkedin_url) property for exact dedup; " +
158
+ "acquire writes it on every new contact it creates, so coverage grows automatically.",
159
+ );
160
+ }
161
+ const seen = loadSeen();
162
+ let records: EnrichSourceRecord[];
163
+ let apiSkippedCrm = 0;
164
+ let apiSkippedSeen = 0;
165
+ let apiProcessedKeys: string[] = [];
166
+ if (sourceConfig.kind === "api") {
167
+ const api = await acquireFromApi(config, source, rest, icp, snapshot, seen);
168
+ records = api.records;
169
+ apiSkippedCrm = api.skippedCrm;
170
+ apiSkippedSeen = api.skippedSeen;
171
+ apiProcessedKeys = api.processedKeys;
172
+ } else {
173
+ const stagedLabel = option(rest, "--staged-run");
174
+ const stagedRun = stagedLabel
175
+ ? await store.get(stagedLabel)
176
+ : await store.latest({ source, mode: "ingest" });
177
+ if (!stagedRun || stagedRun.mode !== "ingest") {
178
+ throw new Error(
179
+ `No staged data for source "${source}". Stage prospects first: fullstackgtm enrich ingest <prospects.csv|payload.json> --source ${source}`,
180
+ );
181
+ }
182
+ records = stagedSourceRecords(config, source, stagedRun);
183
+ }
184
+
185
+ // Meter: how many MORE leads may we create right now? --max is an
186
+ // additional per-run ceiling, never a way to exceed the budget.
187
+ const now = new Date();
188
+ const costPerRecord = config.acquire.costPerRecord?.[source] ?? 0;
189
+ if (apiSkippedCrm || apiSkippedSeen) {
190
+ console.error(
191
+ `Pre-email dedup: skipped ${apiSkippedCrm} already-in-CRM + ${apiSkippedSeen} seen before paying for emails ` +
192
+ `(≈$${((apiSkippedCrm + apiSkippedSeen) * costPerRecord).toFixed(2)} enrichment saved).`,
193
+ );
194
+ }
195
+ const headroom = remaining(loadMeter(now), config.acquire.budget, costPerRecord, now);
196
+ const explicitMax = numericOption(rest, "--max");
197
+ let cap = headroom.maxRecords;
198
+ if (explicitMax !== undefined) cap = cap === null ? explicitMax : Math.min(cap, explicitMax);
199
+
200
+ // Assignment: never create an ownerless lead. An explicit `acquire.assign`
201
+ // policy wins; a `--assign-owner <id>` flag is a quick fixed override;
202
+ // otherwise, when the portal has exactly one active owner, default every
203
+ // lead to them. With multiple owners and no policy we refuse to guess —
204
+ // leads are left unassigned and the operator is told to configure a rule.
205
+ const assignOwnerFlag = option(rest, "--assign-owner");
206
+ if (!config.acquire.assign) {
207
+ if (assignOwnerFlag) {
208
+ config.acquire.assign = { strategy: "fixed", ownerId: assignOwnerFlag };
209
+ } else {
210
+ const activeOwners = (snapshot.users ?? []).filter((u) => u.active !== false);
211
+ if (activeOwners.length === 1) {
212
+ const sole = activeOwners[0].crmId ?? activeOwners[0].id;
213
+ config.acquire.assign = { strategy: "fixed", ownerId: sole };
214
+ console.error(
215
+ `Assignment: no policy set — defaulting every lead to the portal's sole owner ` +
216
+ `${activeOwners[0].name} (${sole}). Configure "acquire.assign" to route across reps.`,
217
+ );
218
+ } else if (activeOwners.length > 1) {
219
+ console.error(
220
+ `⚠ Assignment: ${activeOwners.length} active owners and no "acquire.assign" policy — ` +
221
+ `leads will be created OWNERLESS. Add an acquire.assign rule (fixed / round-robin / territory) ` +
222
+ `or pass --assign-owner <id> so every lead lands with an owner.`,
223
+ );
224
+ }
225
+ }
226
+ }
227
+
228
+ // Progress: candidate rows tick on an interactive stderr board (inert when
229
+ // piped) and, via the composed reporter, heartbeat to a paired hosted app.
230
+ // The meter reading (creates vs headroom + budget burn) rides the same
231
+ // emitter, feeding the dashboard's gauge without printing anything new.
232
+ const renderer = createProgressRenderer(ACQUIRE_STAGES);
233
+ const acquireProgress = createProgressEmitter(
234
+ composeListeners(renderer.listener, progressReporter()),
235
+ );
236
+ let result: ReturnType<typeof buildAcquirePlan>;
237
+ try {
238
+ acquireProgress.stage(ACQUIRE_STAGES[0], 0, ACQUIRE_STAGES.length);
239
+ if (config.acquire.budget?.records?.perDay && headroom.records.day !== null) {
240
+ acquireProgress.meter(
241
+ config.acquire.budget.records.perDay - headroom.records.day,
242
+ config.acquire.budget.records.perDay,
243
+ "records/day",
244
+ );
245
+ }
246
+ result = buildAcquirePlan({
247
+ config,
248
+ source,
249
+ snapshot,
250
+ records,
251
+ runLabel: option(rest, "--run-label") ?? `acquire-${source}-${today}`,
252
+ maxRecords: cap,
253
+ progress: acquireProgress,
254
+ });
255
+ } finally {
256
+ renderer.done();
257
+ }
258
+
259
+ if (result.counts.unassigned > 0 && result.counts.created > 0) {
260
+ console.error(
261
+ `⚠ ${result.counts.unassigned}/${result.counts.created} proposed lead(s) have no owner ` +
262
+ `(policy could not place them). They will be created ownerless.`,
263
+ );
264
+ }
265
+
266
+ // Observability: headline metrics for the web run timeline (paired users).
267
+ reportCounts({
268
+ sourced: result.counts.fetched,
269
+ created: result.counts.created,
270
+ withheldByMeter: result.counts.withheldByMeter,
271
+ skippedInCrm: apiSkippedCrm,
272
+ skippedSeen: apiSkippedSeen,
273
+ estCostUsd: result.estCostUsd,
274
+ });
275
+
276
+ const meterLine = formatAcquireMeter(headroom, costPerRecord);
277
+ const gaugeLine = acquireGaugeLine(headroom, config.acquire.budget ?? {}, paint(colorEnabled(process.stdout)));
278
+ if (!save) {
279
+ if (rest.includes("--json")) {
280
+ console.log(
281
+ JSON.stringify(
282
+ { plan: result.plan, counts: result.counts, estCostUsd: result.estCostUsd, meter: headroom },
283
+ null,
284
+ 2,
285
+ ),
286
+ );
287
+ } else {
288
+ console.log(patchPlanToMarkdown(result.plan));
289
+ console.log(meterLine);
290
+ if (gaugeLine) console.log(gaugeLine);
291
+ console.log("\nDry run — nothing written. Re-run with --save to persist the plan, then approve + apply.");
292
+ }
293
+ return;
294
+ }
295
+
296
+ const run = await openEnrichRun(store, source, "append", option(rest, "--run-label"), today);
297
+ const planIds: string[] = [];
298
+ if (result.plan.operations.length > 0) {
299
+ await createFilePlanStore().save(result.plan);
300
+ planIds.push(result.plan.id);
301
+ reportEvent("plan_saved", result.plan.id);
302
+ }
303
+ await store.update({
304
+ ...run,
305
+ completedAt: new Date().toISOString(),
306
+ cursor: null,
307
+ planIds: [...(run.planIds ?? []), ...planIds],
308
+ });
309
+ // Remember everyone we email-resolved this run so the next run skips them
310
+ // pre-email (cross-run credit saver). Committed (--save) runs only.
311
+ if (apiProcessedKeys.length > 0) recordSeen(apiProcessedKeys, now);
312
+ console.log(meterLine);
313
+ if (gaugeLine) console.log(gaugeLine);
314
+ if (planIds.length > 0) {
315
+ console.log(
316
+ `Saved plan ${result.plan.id} — ${result.counts.created} net-new lead(s), est. $${result.estCostUsd.toFixed(2)}. ` +
317
+ `Review \`fullstackgtm plans show ${result.plan.id}\`, approve \`fullstackgtm plans approve ${result.plan.id} --operations all\`, ` +
318
+ `then \`fullstackgtm apply --plan-id ${result.plan.id} --provider hubspot\`. The meter is charged only when a create lands at apply.`,
319
+ );
320
+ } else {
321
+ console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
322
+ }
323
+ return;
324
+ }
325
+
326
+ const mode: "append" | "refresh" = subcommand === "refresh" ? "refresh" : "append";
327
+ const source = resolveEnrichSource(config, rest);
328
+ const sourceConfig = config.sources[source];
329
+ const save = saveRequested(rest);
330
+ const today = new Date().toISOString().slice(0, 10);
331
+
332
+ // Refresh work set comes from the staleness ledger, before any fetch.
333
+ const allRuns = await store.list();
334
+ let workSet: ReturnType<typeof selectStaleWork> = [];
335
+ if (mode === "refresh") {
336
+ const staleDaysOverride = numericOption(rest, "--stale-days");
337
+ workSet = selectStaleWork(config, allRuns, source, { staleDaysOverride });
338
+ if (workSet.length === 0) {
339
+ const stamped = latestStamps(allRuns, source).size;
340
+ console.log(
341
+ stamped === 0
342
+ ? `Nothing to refresh: no ${source} enrichment stamps yet. Run \`enrich append --source ${source} --save\` first.`
343
+ : `Nothing to refresh: all ${stamped} stamped field(s) from ${source} are within their staleness window.`,
344
+ );
345
+ return;
346
+ }
347
+ }
348
+
349
+ const snapshot = await readSnapshot(rest);
350
+
351
+ // Assemble source records: api pull (checkpointed when --save) or staged ingest data.
352
+ let run: EnrichRun | null = null;
353
+ let records: EnrichSourceRecord[];
354
+ let missCount = 0;
355
+ if (sourceConfig.kind === "api") {
356
+ const objectTypes = parseEnrichObjects(rest, config, source);
357
+ const fieldsFor = (objectType: EnrichObjectType) =>
358
+ (config.fields[objectType] ?? []).filter((field) => field.from[source] !== undefined);
359
+ const pullKeys: ApolloPullKey[] =
360
+ mode === "append"
361
+ ? apolloPullKeysForAppend(snapshot, objectTypes, (objectType, record) =>
362
+ fieldsFor(objectType).some((field) => {
363
+ const value = record[resolveCrmField(objectType, field.crm)];
364
+ return value === undefined || value === null || String(value).trim() === "";
365
+ }),
366
+ )
367
+ : apolloPullKeysForRefresh(snapshot, workSet);
368
+ if (pullKeys.length === 0) {
369
+ console.log(
370
+ mode === "append"
371
+ ? "Nothing to enrich: no records with a blank mapped field and a pull key (companies need a domain, contacts an email)."
372
+ : "Nothing to refresh: no stale records carry a pull key (companies need a domain, contacts an email).",
373
+ );
374
+ return;
375
+ }
376
+ const client = createApolloClient({
377
+ getApiKey: () => apolloApiKey(),
378
+ apiBaseUrl: process.env.APOLLO_API_BASE_URL,
379
+ });
380
+ if (save) {
381
+ run = await openEnrichRun(store, source, mode, option(rest, "--run-label"), today);
382
+ if (run.cursor) {
383
+ console.error(
384
+ `Resuming interrupted run ${run.runLabel} from cursor ${run.cursor} (${run.pulled?.length ?? 0} record(s) already pulled).`,
385
+ );
386
+ }
387
+ }
388
+ // Interactive terminals get a stderr progress bar over the pull (rate +
389
+ // ETA); the checkpointing callback below is unchanged. Inert otherwise.
390
+ const pullBar = createStatusLine();
391
+ const pullStarted = Date.now();
392
+ let pullProcessed = 0;
393
+ let result: Awaited<ReturnType<typeof pullApolloRecords>>;
394
+ try {
395
+ result = await pullApolloRecords(client, pullKeys, {
396
+ resumeAfter: run?.cursor ?? null,
397
+ onProgress: async (progress) => {
398
+ if (pullBar.active) {
399
+ pullProcessed += 1;
400
+ const elapsed = Date.now() - pullStarted;
401
+ const rate = pullProcessed / Math.max(1, elapsed / 1000);
402
+ const left = Math.max(0, pullKeys.length - pullProcessed);
403
+ pullBar.set(
404
+ `Enriching via ${source}… ${formatBar(pullProcessed / pullKeys.length, 12)} ${pullProcessed}/${pullKeys.length} · ${rate.toFixed(1)}/s · ETA ${formatDuration((left / Math.max(rate, 0.01)) * 1000)}`,
405
+ );
406
+ }
407
+ if (!run) return;
408
+ run.cursor = progress.lastKeyValue;
409
+ if (progress.record) run.pulled = [...(run.pulled ?? []), progress.record];
410
+ if (progress.miss) run.missedKeys = [...(run.missedKeys ?? []), progress.miss.value];
411
+ await store.update(run);
412
+ },
413
+ });
414
+ } finally {
415
+ pullBar.done();
416
+ }
417
+ records = run ? [...(run.pulled ?? [])] : result.records;
418
+ missCount = run ? (run.missedKeys?.length ?? 0) : result.misses.length;
419
+ } else {
420
+ const stagedLabel = option(rest, "--staged-run");
421
+ const stagedRun = stagedLabel
422
+ ? await store.get(stagedLabel)
423
+ : await store.latest({ source, mode: "ingest" });
424
+ if (!stagedRun || stagedRun.mode !== "ingest") {
425
+ throw new Error(
426
+ `No staged data for source "${source}". Stage it first: fullstackgtm enrich ingest <file.csv|payload.json> --source ${source}`,
427
+ );
428
+ }
429
+ records = stagedSourceRecords(config, source, stagedRun);
430
+ if (save) run = await openEnrichRun(store, source, mode, option(rest, "--run-label"), today);
431
+ }
432
+
433
+ const result = buildEnrichPlan({
434
+ config,
435
+ source,
436
+ mode,
437
+ snapshot,
438
+ records,
439
+ workSet: mode === "refresh" ? workSet : undefined,
440
+ runLabel: run?.runLabel ?? `${mode}-${source}-${today}`,
441
+ });
442
+ // Pull keys the source had no data for count as fetched-but-unmatched.
443
+ result.counts.fetched += missCount;
444
+ result.counts.unmatched += missCount;
445
+
446
+ reportCounts({
447
+ fetched: result.counts.fetched,
448
+ matched: result.counts.matched,
449
+ unmatched: result.counts.unmatched,
450
+ opsEmitted: result.counts.opsEmitted,
451
+ });
452
+
453
+ if (!save) {
454
+ if (rest.includes("--json")) {
455
+ console.log(JSON.stringify(result.plan, null, 2));
456
+ } else {
457
+ console.log(patchPlanToMarkdown(result.plan));
458
+ console.log(formatEnrichCounts(result.counts, result.ambiguities.length));
459
+ console.log("\nDry run — nothing written. Re-run with --save to persist the plan and the run record.");
460
+ }
461
+ return;
462
+ }
463
+
464
+ // --save: persist the plan (when it proposes anything) and finalize the run.
465
+ const planIds: string[] = [];
466
+ if (result.plan.operations.length > 0) {
467
+ await createFilePlanStore().save(result.plan);
468
+ planIds.push(result.plan.id);
469
+ }
470
+ const finalized: EnrichRun = {
471
+ ...(run as EnrichRun),
472
+ completedAt: new Date().toISOString(),
473
+ cursor: null,
474
+ counts: result.counts,
475
+ planIds: [...((run as EnrichRun).planIds ?? []), ...planIds],
476
+ stamps: [...((run as EnrichRun).stamps ?? []), ...result.stamps],
477
+ ambiguities: result.ambiguities,
478
+ };
479
+ await store.update(finalized);
480
+ console.log(formatEnrichCounts(result.counts, result.ambiguities.length));
481
+ if (planIds.length > 0) {
482
+ console.log(
483
+ `Saved plan ${result.plan.id} (run ${finalized.runLabel}). Review with \`fullstackgtm plans show ${result.plan.id}\`, ` +
484
+ `approve with \`fullstackgtm plans approve ${result.plan.id} --operations <ids|all>\`, then ` +
485
+ `\`fullstackgtm apply --plan-id ${result.plan.id} --provider <name>\`.`,
486
+ );
487
+ } else {
488
+ console.log(`Run ${finalized.runLabel} recorded; no operations to propose.`);
489
+ }
490
+ }
491
+
492
+ function formatEnrichCounts(counts: EnrichCounts, ambiguities: number) {
493
+ return (
494
+ `Source records: ${counts.fetched} fetched · ${counts.matched} matched · ` +
495
+ `${counts.unmatched} unmatched · ${counts.ambiguous} ambiguous (${ambiguities} collision(s) recorded) · ` +
496
+ `${counts.opsEmitted} operation(s) proposed`
497
+ );
498
+ }
499
+
500
+ /**
501
+ * Pull net-new prospects from an API acquire source into source records the
502
+ * acquire builder dedupes + turns into create_record ops. Explorium discovers;
503
+ * pipe0 (when resolveEmailsWith=pipe0) fills real work emails. Only rows that
504
+ * carry the dedupe key (email) survive — you cannot resolve-first without it.
505
+ */
506
+ async function acquireFromApi(
507
+ config: EnrichConfig,
508
+ source: string,
509
+ rest: string[],
510
+ icp: Icp | undefined,
511
+ snapshot: CanonicalGtmSnapshot,
512
+ seen: Set<string>,
513
+ ): Promise<{ records: EnrichSourceRecord[]; skippedCrm: number; skippedSeen: number; processedKeys: string[] }> {
514
+ const acquire = config.acquire!;
515
+ const disc = acquire.discovery?.[source];
516
+ if (!disc) {
517
+ throw new Error(`enrich acquire: api source "${source}" needs an "acquire.discovery.${source}" config (provider + size).`);
518
+ }
519
+ const matchKey = acquire.create.contact?.matchKey ?? "email";
520
+ const maxOverride = numericOption(rest, "--max");
521
+ const size = maxOverride !== undefined ? Math.min(maxOverride, disc.size ?? 25) : disc.size ?? 25;
522
+
523
+ // 1. Discover. Filters come from the ICP when one is loaded (the whole point —
524
+ // targeted, not random); otherwise from the hand-written disc.filters.
525
+ let prospects: Prospect[];
526
+ if (disc.provider === "explorium") {
527
+ const filters = icp
528
+ ? icpToExploriumFilters(icp)
529
+ : ((disc.filters ?? {}) as Record<string, { values?: string[]; value?: boolean }>);
530
+ prospects = await fetchExploriumProspects({ apiKey: providerKey("explorium"), filters, size });
531
+ } else if (disc.provider === "pipe0") {
532
+ const filters = icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {});
533
+ prospects = await fetchPipe0CrustdataProspects({ apiKey: providerKey("pipe0"), filters, limit: size });
534
+ } else if (disc.provider === "linkedin" || disc.provider === "heyreach") {
535
+ // LinkedIn reads a pre-populated lead list (not an ICP-driven query); the ICP
536
+ // scores the pulled list below. List id: disc.listId or --list <id>.
537
+ const listId = disc.listId ?? option(rest, "--list") ?? undefined;
538
+ if (!listId) {
539
+ throw new Error(
540
+ "enrich acquire --source linkedin needs a HeyReach lead-list id: pass --list <id> or set acquire.discovery.linkedin.listId.",
541
+ );
542
+ }
543
+ const provider = createLinkedInProvider(disc.provider, providerKey("heyreach"));
544
+ prospects = await discoverLinkedInProspects(provider, { sourceId: listId, max: size });
545
+ } else {
546
+ throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0 | linkedin).`);
547
+ }
548
+
549
+ // Surface a zero-discovery result LOUDLY rather than emit a silent empty plan.
550
+ // A provider that returns 0 with no error usually means an over-narrow ICP
551
+ // filter (most often the industry/seniority vocab) — not "no market exists".
552
+ const discoveredCount = prospects.length;
553
+ if (discoveredCount === 0) {
554
+ console.error(
555
+ `enrich acquire: ${disc.provider} discovered 0 prospects for this ICP — the plan will be empty. ` +
556
+ "This is usually an over-narrow filter, not an empty market: check the ICP's industry and seniority " +
557
+ "(provider vocab is finicky), widen one constraint, or run `fullstackgtm icp show` to inspect the " +
558
+ "generated discovery filters. (Distinct from dedup: nothing was returned to filter.)",
559
+ );
560
+ }
561
+
562
+ // 2. ICP fit scoring BEFORE paying for emails — only above-threshold prospects
563
+ // proceed to (credit-spending) email resolution.
564
+ if (icp) {
565
+ const threshold = fitThreshold(icp);
566
+ prospects = prospects
567
+ .map((p) => ({ ...p, fitScore: scoreProspectAgainstIcp(p, icp).score }))
568
+ .filter((p) => (p.fitScore ?? 0) >= threshold);
569
+ if (discoveredCount > 0 && prospects.length === 0) {
570
+ console.error(
571
+ `enrich acquire: all ${discoveredCount} discovered prospect(s) scored below the ICP fit threshold ` +
572
+ `(${fitThreshold(icp)}) — none qualified. Loosen the ICP persona or lower scoring.threshold.`,
573
+ );
574
+ }
575
+ }
576
+
577
+ // 2.5 Pre-email dedup — drop prospects already in the CRM (name+domain match
578
+ // vs the snapshot) or already processed in a prior run (the seen cache),
579
+ // BEFORE paying pipe0 for emails. The email-level dedup (buildAcquirePlan)
580
+ // and apply-time resolve-first remain the precise backstop.
581
+ const { fresh, skippedCrm, skippedSeen } = partitionFreshProspects(prospects, crmContactKeys(snapshot), seen);
582
+ prospects = fresh;
583
+
584
+ // 3. Resolve real work emails. Triggered either when email IS the dedupe key
585
+ // (Explorium's email is hashed, Crustdata returns none) or when a source
586
+ // that keys on something else opts in via `resolveEmailsWith: "pipe0"`
587
+ // (e.g. LinkedIn keys on the profile URL but still wants outreach emails).
588
+ // pipe0 waterfall, chunked; resolves from name + company domain/name.
589
+ if (matchKey === "email" || disc.resolveEmailsWith === "pipe0") {
590
+ // The waterfall needs a company DOMAIN; LinkedIn/HeyReach lists carry only
591
+ // names. Resolve domains first (pipe0 company:identity) so resolution can
592
+ // actually land — without it, name-only resolution fails for every lead.
593
+ if (prospects.some((p) => !p.companyDomain && p.companyName)) {
594
+ prospects = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects });
595
+ }
596
+ prospects = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects });
597
+ }
598
+
599
+ const processedKeys = prospects.flatMap(prospectIdentityKeys);
600
+ const records = prospects
601
+ .map((p) => {
602
+ const keyValue = (p as unknown as Record<string, unknown>)[matchKey] as string | undefined;
603
+ return {
604
+ id: p.sourceId ?? `${source}:${keyValue ?? p.fullName ?? ""}`,
605
+ objectType: "contact" as const,
606
+ keys: { [matchKey]: keyValue },
607
+ payload: p as unknown as Record<string, unknown>,
608
+ };
609
+ })
610
+ .filter((record) => Boolean(record.keys[matchKey]));
611
+ return { records, skippedCrm, skippedSeen, processedKeys };
612
+ }
613
+
614
+ /**
615
+ * Rich-only fuel gauge under the acquire meter line: one bar per configured
616
+ * budget window, colored by how much is burned (green → yellow ≥70% → red
617
+ * ≥90%). Returns null in plain mode or with no caps configured, keeping
618
+ * piped output byte-identical.
619
+ */
620
+ function acquireGaugeLine(headroom: AcquireRemaining, budget: AcquireBudget, p: Paint): string | null {
621
+ if (!p.enabled) return null;
622
+ const parts: string[] = [];
623
+ const segment = (
624
+ label: string,
625
+ left: number | null,
626
+ cap: number | undefined,
627
+ fmt: (value: number) => string,
628
+ ) => {
629
+ if (left === null || cap === undefined || cap <= 0) return;
630
+ const used = Math.max(0, cap - left);
631
+ const fraction = used / cap;
632
+ const painter = fraction >= 0.9 ? p.red : fraction >= 0.7 ? p.yellow : p.green;
633
+ parts.push(`${p.dim(label)} ${painter(formatBar(fraction, 10))} ${fmt(used)}/${fmt(cap)}`);
634
+ };
635
+ segment("records/day", headroom.records.day, budget.records?.perDay, String);
636
+ segment("records/mo", headroom.records.month, budget.records?.perMonth, String);
637
+ segment("spend/day", headroom.spendUsd.day, budget.spend?.perDay, (value) => `$${value.toFixed(2)}`);
638
+ segment("spend/mo", headroom.spendUsd.month, budget.spend?.perMonth, (value) => `$${value.toFixed(2)}`);
639
+ return parts.length > 0 ? parts.join(" · ") : null;
640
+ }
641
+
642
+ function formatAcquireMeter(headroom: AcquireRemaining, costPerRecord: number): string {
643
+ const n = (v: number | null) => (v === null ? "∞" : String(v));
644
+ const money = (v: number | null) => (v === null ? "∞" : `$${v.toFixed(2)}`);
645
+ const max = headroom.maxRecords === null ? "unlimited" : `${headroom.maxRecords}`;
646
+ return (
647
+ `Acquire meter — creatable now: ${max} lead(s). ` +
648
+ `Records left: ${n(headroom.records.day)}/day, ${n(headroom.records.month)}/month · ` +
649
+ `Spend left: ${money(headroom.spendUsd.day)}/day, ${money(headroom.spendUsd.month)}/month ` +
650
+ `(≈$${costPerRecord.toFixed(2)}/lead).`
651
+ );
652
+ }
653
+
654
+ function resolveEnrichSource(config: EnrichConfig, rest: string[]): string {
655
+ const requested = option(rest, "--source");
656
+ const declared = Object.keys(config.sources);
657
+ if (requested) {
658
+ if (!config.sources[requested]) {
659
+ throw new Error(`Unknown enrich source "${requested}" (declared: ${declared.join(", ")})`);
660
+ }
661
+ return requested;
662
+ }
663
+ if (declared.length === 1) return declared[0];
664
+ if (config.sources.apollo) return "apollo";
665
+ throw new Error(`Multiple sources declared (${declared.join(", ")}) — pass --source <id>`);
666
+ }
667
+
668
+ function parseEnrichObjects(rest: string[], config: EnrichConfig, source: string): EnrichObjectType[] {
669
+ const configured = (["company", "contact"] as EnrichObjectType[]).filter((objectType) =>
670
+ (config.fields[objectType] ?? []).some((field) => field.from[source] !== undefined),
671
+ );
672
+ const flag = option(rest, "--objects");
673
+ if (!flag) {
674
+ if (configured.length === 0) {
675
+ throw new Error(`No fields map from source "${source}" — add "from": { "${source}": ... } entries to the config.`);
676
+ }
677
+ return configured;
678
+ }
679
+ const requested = Array.from(new Set(flag.split(",").map((part) => parseSingleObjectType(part))));
680
+ for (const objectType of requested) {
681
+ if (!configured.includes(objectType)) {
682
+ throw new Error(`--objects ${flag}: no ${objectType} fields map from source "${source}" in the config.`);
683
+ }
684
+ }
685
+ return requested;
686
+ }
687
+
688
+ function apolloApiKey(): string {
689
+ if (process.env.APOLLO_API_KEY) return process.env.APOLLO_API_KEY;
690
+ const stored = getCredential("apollo");
691
+ if (stored) return stored.accessToken;
692
+ throw new Error(
693
+ 'No Apollo credentials. Run `echo "$APOLLO_API_KEY" | fullstackgtm login apollo` once, or set APOLLO_API_KEY.',
694
+ );
695
+ }
696
+
697
+ /**
698
+ * Open (or resume) a saved run. An interrupted run — same label, same source
699
+ * and mode, never completed — is resumed from its cursor; a completed run
700
+ * with the default label gets a -2/-3 suffix (runs are append-only).
701
+ */
702
+ async function openEnrichRun(
703
+ store: EnrichRunStore,
704
+ source: string,
705
+ mode: "append" | "refresh",
706
+ requestedLabel: string | null,
707
+ today: string,
708
+ ): Promise<EnrichRun> {
709
+ const baseLabel = requestedLabel ?? `${mode}-${source}-${today}`;
710
+ let label = baseLabel;
711
+ for (let suffix = 2; ; suffix += 1) {
712
+ const existing = await store.get(label);
713
+ if (!existing) break;
714
+ if (existing.source === source && existing.mode === mode && existing.completedAt === null) {
715
+ return existing; // resume the interrupted run
716
+ }
717
+ if (requestedLabel) {
718
+ throw new Error(`Run "${requestedLabel}" already exists and is completed — enrich runs are append-only.`);
719
+ }
720
+ label = `${baseLabel}-${suffix}`;
721
+ }
722
+ return store.append({
723
+ id: enrichRunId(source, label),
724
+ runLabel: label,
725
+ source,
726
+ mode,
727
+ startedAt: new Date().toISOString(),
728
+ completedAt: null,
729
+ cursor: null,
730
+ counts: { fetched: 0, matched: 0, unmatched: 0, ambiguous: 0, opsEmitted: 0 },
731
+ planIds: [],
732
+ stamps: [],
733
+ });
734
+ }
735
+
736
+ async function enrichIngest(store: EnrichRunStore, config: EnrichConfig, rest: string[], autoAppend = false) {
737
+ const file = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
738
+ if (!file) throw new Error("Usage: fullstackgtm enrich ingest <file.csv|payload.json|spool.jsonl|spool-dir> --source <id> [--run-label <label>]");
739
+ const source = option(rest, "--source");
740
+ if (!source) throw new Error("enrich ingest requires --source <id> (the ingest source the data belongs to)");
741
+ const sourceConfig = config.sources[source];
742
+ if (!sourceConfig) {
743
+ throw new Error(`Unknown enrich source "${source}" (declared: ${Object.keys(config.sources).join(", ")})`);
744
+ }
745
+ if (sourceConfig.kind !== "ingest") {
746
+ throw new Error(`Source "${source}" is kind "${sourceConfig.kind}" — only ingest sources accept staged data.`);
747
+ }
748
+
749
+ const filePath = resolve(process.cwd(), file);
750
+ let rows: Array<Record<string, unknown>>;
751
+ if (isSpoolPath(filePath)) {
752
+ // Spool convention (docs/signal-spool-format.md): a *.jsonl file (one JSON
753
+ // row per line) or a directory of *.jsonl / *.json spool files — so a
754
+ // webhook landing zone can be staged directly. Additive: .csv / .json
755
+ // files parse exactly as before.
756
+ rows = readSpoolPath(filePath, "enrich ingest").map((entry) => entry.row);
757
+ } else if ((file.toLowerCase().endsWith(".csv") || sourceConfig.format === "csv") && !file.toLowerCase().endsWith(".json")) {
758
+ const raw = readFileSync(filePath, "utf8");
759
+ rows = parseCsv(raw);
760
+ } else {
761
+ const raw = readFileSync(filePath, "utf8");
762
+ const parsed = JSON.parse(raw) as unknown;
763
+ if (Array.isArray(parsed)) rows = parsed as Array<Record<string, unknown>>;
764
+ else if (parsed && typeof parsed === "object" && Array.isArray((parsed as { rows?: unknown }).rows)) {
765
+ rows = (parsed as { rows: Array<Record<string, unknown>> }).rows;
766
+ } else if (parsed && typeof parsed === "object") {
767
+ rows = [parsed as Record<string, unknown>];
768
+ } else {
769
+ throw new Error(`${file}: expected a JSON array, an object, or { "rows": [...] }`);
770
+ }
771
+ }
772
+ if (rows.length === 0) throw new Error(`${file}: no rows to stage`);
773
+
774
+ const objectsFlag = option(rest, "--objects");
775
+ const objectType: EnrichObjectType = objectsFlag
776
+ ? parseSingleObjectType(objectsFlag)
777
+ : inferIngestObjectType(config, source, rows);
778
+
779
+ const today = new Date().toISOString().slice(0, 10);
780
+ const baseLabel = option(rest, "--run-label") ?? `ingest-${source}-${today}`;
781
+ let label = baseLabel;
782
+ for (let suffix = 2; await store.get(label); suffix += 1) {
783
+ if (option(rest, "--run-label")) {
784
+ throw new Error(`Run "${baseLabel}" already exists — enrich runs are append-only; pick a new --run-label.`);
785
+ }
786
+ label = `${baseLabel}-${suffix}`;
787
+ }
788
+ const now = new Date().toISOString();
789
+ await store.append({
790
+ id: enrichRunId(source, label),
791
+ runLabel: label,
792
+ source,
793
+ mode: "ingest",
794
+ startedAt: now,
795
+ completedAt: now,
796
+ cursor: null,
797
+ counts: { fetched: rows.length, matched: 0, unmatched: 0, ambiguous: 0, opsEmitted: 0 },
798
+ planIds: [],
799
+ stamps: [],
800
+ staged: rows,
801
+ stagedObjectType: objectType,
802
+ });
803
+ console.log(
804
+ autoAppend
805
+ ? `Staged ${rows.length} ${objectType} row(s) from ${file} as run ${label}; matching against the CRM…`
806
+ : `Staged ${rows.length} ${objectType} row(s) from ${file} as run ${label}. ` +
807
+ `Next: fullstackgtm enrich append --source ${source} [source options] [--save]`,
808
+ );
809
+ }
810
+
811
+ function parseSingleObjectType(value: string): EnrichObjectType {
812
+ const normalized = value.trim().toLowerCase();
813
+ if (normalized === "companies" || normalized === "company") return "company";
814
+ if (normalized === "contacts" || normalized === "contact") return "contact";
815
+ throw new Error(`--objects must be companies or contacts (got "${value}")`);
816
+ }
817
+
818
+ async function enrichStatus(store: EnrichRunStore, rest: string[], configFile: string) {
819
+ const sourceFilter = option(rest, "--source");
820
+ const allRuns = (await store.list()).filter((run) => !sourceFilter || run.source === sourceFilter);
821
+ if (allRuns.length === 0) {
822
+ console.log(
823
+ sourceFilter
824
+ ? `No enrich runs for source "${sourceFilter}".`
825
+ : "No enrich runs yet. Start with `fullstackgtm enrich append --save` or stage data with `enrich ingest`.",
826
+ );
827
+ return;
828
+ }
829
+
830
+ // Staleness windows come from the config when one is readable; status must
831
+ // not REQUIRE a config (the run store alone is enough to report on).
832
+ let config: EnrichConfig | null = null;
833
+ if (existsSync(configFile)) {
834
+ try {
835
+ config = loadEnrichConfig(configFile);
836
+ } catch {
837
+ config = null;
838
+ }
839
+ }
840
+
841
+ const now = Date.now();
842
+ const sources = Array.from(new Set(allRuns.map((run) => run.source)));
843
+ const report = sources.map((source) => {
844
+ const runs = allRuns.filter((run) => run.source === source);
845
+ const last = runs[runs.length - 1];
846
+ const interrupted = runs.filter((run) => run.completedAt === null);
847
+ const stamps = Array.from(latestStamps(runs, source).values());
848
+ const ages = stamps.map((stamp) => (now - Date.parse(stamp.enrichedAt)) / 86_400_000);
849
+ const staleness = stamps.map((stamp, index) => {
850
+ const windowDays = config
851
+ ? staleDaysFor(config, stamp.objectType, stamp.field)
852
+ : DEFAULT_STALE_DAYS;
853
+ return ages[index] > windowDays;
854
+ });
855
+ return {
856
+ source,
857
+ runs: runs.length,
858
+ lastRun: {
859
+ runLabel: last.runLabel,
860
+ mode: last.mode,
861
+ startedAt: last.startedAt,
862
+ completedAt: last.completedAt,
863
+ counts: last.counts,
864
+ planIds: last.planIds,
865
+ },
866
+ interrupted: interrupted.map((run) => ({ runLabel: run.runLabel, cursor: run.cursor })),
867
+ stamps: {
868
+ total: stamps.length,
869
+ stale: staleness.filter(Boolean).length,
870
+ oldestDays: ages.length ? Math.round(Math.max(...ages)) : null,
871
+ newestDays: ages.length ? Math.round(Math.min(...ages)) : null,
872
+ windowSource: config ? "enrich.config.json" : `default ${DEFAULT_STALE_DAYS}d`,
873
+ },
874
+ };
875
+ });
876
+
877
+ if (rest.includes("--json")) {
878
+ console.log(JSON.stringify({ sources: report, runs: rest.includes("--runs") ? allRuns : undefined }, null, 2));
879
+ return;
880
+ }
881
+
882
+ for (const entry of report) {
883
+ const last = entry.lastRun;
884
+ console.log(`${entry.source} — ${entry.runs} run(s)`);
885
+ console.log(
886
+ ` last: ${last.runLabel} (${last.mode}) ${last.completedAt ? `completed ${last.completedAt}` : "INTERRUPTED"}` +
887
+ ` · ${last.counts.fetched} fetched, ${last.counts.matched} matched, ${last.counts.unmatched} unmatched,` +
888
+ ` ${last.counts.ambiguous} ambiguous, ${last.counts.opsEmitted} ops` +
889
+ (last.planIds.length ? ` · plans: ${last.planIds.join(", ")}` : ""),
890
+ );
891
+ for (const run of entry.interrupted) {
892
+ console.log(` interrupted: ${run.runLabel} at cursor ${run.cursor ?? "(start)"} — re-run with --save to resume`);
893
+ }
894
+ console.log(
895
+ ` stamps: ${entry.stamps.total} field(s) enriched · ${entry.stamps.stale} stale (window: ${entry.stamps.windowSource})` +
896
+ (entry.stamps.total ? ` · age ${entry.stamps.newestDays}–${entry.stamps.oldestDays}d` : ""),
897
+ );
898
+ }
899
+ if (rest.includes("--runs")) {
900
+ console.log("");
901
+ for (const run of allRuns) {
902
+ console.log(
903
+ `${run.runLabel} ${run.source.padEnd(8)} ${run.mode.padEnd(8)} ${run.completedAt ? "done" : "interrupted"}` +
904
+ ` ${run.counts.opsEmitted} ops ${run.stamps.length} stamps${run.staged ? ` ${run.staged.length} staged` : ""}`,
905
+ );
906
+ }
907
+ }
908
+ }