fullstackgtm 0.22.0 → 0.23.2

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 CHANGED
@@ -5,6 +5,81 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
  The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
7
7
 
8
+ ## [0.23.2] — 2026-06-12
9
+
10
+ Documentation catch-up: the README, llms.txt, and docs/api.md now cover
11
+ everything that shipped 0.19–0.23. (No code changes.)
12
+
13
+ ### Fixed
14
+
15
+ - README: new "Routine maintenance as governed verbs" section (`bulk-update`,
16
+ `dedupe`, `reassign`, `fix`) and a market-map paragraph for `market overlay`
17
+ (directives from your own ground truth) and `market scale` (citable,
18
+ calibrated size estimates).
19
+ - llms.txt: governed-write-verbs invariants block; overlay/scale invariants
20
+ added to the market-map block.
21
+ - docs/api.md: CLI command list completed (write verbs, `enrich`, market
22
+ `overlay`/`scale`); new "Governed write verbs" and "Enrich" export
23
+ sections; market-map section updated for 0.16–0.23 exports incl.
24
+ `computeDirectives`/`directivesToPlan` and `computeScaleIndex`.
25
+
26
+ ## [0.23.1] — 2026-06-12
27
+
28
+ ### Fixed
29
+
30
+ - **Literal `${vendorHeads}` leaked into expanded claim-group tables** — a
31
+ templating slip in the 0.22.0 narrative restructure left the vendor header
32
+ row unrendered (and masked a use-before-declaration). Fixed, plus a
33
+ regression guard: the HTML must contain no unrendered `${` placeholders
34
+ and expanded groups must carry real vendor headers.
35
+ - Claims section heading renamed to "Market Claims".
36
+
37
+ ## [0.23.0] — 2026-06-12
38
+
39
+ ### Added
40
+
41
+ - **The enrich layer (MVP)** — governed append/refresh of third-party data
42
+ into the CRM (spec: docs/enrich.md in the monorepo). Where every enrichment
43
+ vendor ships fire-and-forget writeback, enrich emits a diff you approve:
44
+ source data → deterministic matcher → fill-blanks patch plan → the
45
+ existing plans approve → apply chain, with the source payload stored as
46
+ `GtmEvidence` on the plan and `beforeValue` set on every operation for
47
+ apply-time compare-and-set.
48
+ - `enrich append [--source <id>] [--objects companies,contacts] [--save] [--config <path>]`
49
+ — pull (Apollo) or read staged ingest data (Clay), match via ordered
50
+ keys (unique-hit-wins, zero-hits-next-key, multi-hit → `onAmbiguous`
51
+ skip-with-candidates-recorded or `requires_human_record_selection`
52
+ placeholders into the suggest chain), emit a fill-blanks-only plan.
53
+ Without `--save`: dry-run diff, nothing written.
54
+ - `enrich refresh [--source <id>] [--stale-days <n>] [--save]` — work set
55
+ from run-store stamps older than the staleness window (per-field
56
+ `staleDays` → `policy.defaultStaleDays` → 90); operations only where the
57
+ source value actually changed, and only on fields the ledger proves
58
+ enrich stamped.
59
+ - `enrich ingest <file.csv|payload.json> --source <id> [--run-label <l>]`
60
+ — stage Clay CSV exports (dependency-free CSV parser) or webhook payload
61
+ JSON for a subsequent append/refresh.
62
+ - `enrich status [--runs] [--source <id>]` — last run per source, counts,
63
+ staleness distribution, interrupted-run cursor.
64
+ - `enrich.config.json` (sources / ordered match keys / field mappings /
65
+ policy) with strict up-front validation; the `system-only` and `always`
66
+ conflict-ladder rungs error as "not yet implemented (phase 2)" instead
67
+ of being silently accepted. MVP policy: `never` (fill blanks only).
68
+ - Apollo source client: raw `fetch` against the people-match /
69
+ organization-enrich endpoints, BYO key via `login apollo` (0600 cred
70
+ store) or `APOLLO_API_KEY`, and 429-aware retry with capped exponential
71
+ backoff honoring `Retry-After` — local to the Apollo client; the shared
72
+ connector contract is unchanged.
73
+ - Profile-scoped append-only run store
74
+ (`~/.fullstackgtm/profiles/<p>/enrich/runs/<runLabel>.json`): resume
75
+ checkpoint (cursor + already-paid-for payloads), per-record/per-field
76
+ `enrichedAt` staleness ledger, and the surface `enrich status` reads.
77
+ State stays local — no `fsgtm_enriched_at`-style properties are written
78
+ into the customer's portal.
79
+ - Every `enrich` subcommand catches `--help`/`-h` before config load,
80
+ credential resolution, or any network call. No scheduling/cron logic —
81
+ that is the horizontal schedule layer's job (docs/schedule.md).
82
+
8
83
  ## [0.22.0] — 2026-06-12
9
84
 
10
85
  The report becomes a narrative: map → claims → where to attack.
package/README.md CHANGED
@@ -109,6 +109,22 @@ npx fullstackgtm report --provider hubspot --client "Acme" --out acme-health.htm
109
109
 
110
110
  `report` renders the same audit as a deliverable — severity counts up front, a prose summary, per-rule detail with example records, and next steps — as markdown or self-contained HTML (printable, emailable, no external assets).
111
111
 
112
+ ## Routine maintenance as governed verbs: bulk-update, dedupe, reassign, fix
113
+
114
+ The maintenance work RevOps actually does in bulk — backfills, book transfers, duplicate sweeps, "just fix everything that rule found" — gets first-class verbs. Each one *builds a plan*; nothing executes without the same approve → apply gauntlet as everything else.
115
+
116
+ ```bash
117
+ fullstackgtm bulk-update deal --where "stage=closedwon" --where "amount:empty" \
118
+ --set amount=from:account.annualrevenue --save # per-record derived values; empty sources skipped, never guessed
119
+ fullstackgtm dedupe account --key domain --keep richest --save # one merge_records op per duplicate group
120
+ fullstackgtm reassign --from 411 --to 902 --except-deal-stage closing --save # ownership handoff playbook
121
+ fullstackgtm fix --rule missing-deal-owner --provider hubspot --yes # audit one rule → suggest → approve → apply, one command
122
+ ```
123
+
124
+ `bulk-update` filters the snapshot (`=`, `!=`, `~` substring, `:empty`/`:notempty`, `|` any-of, relational pseudo-fields like `account.domain` or `openDealStages`) into a dry-run patch plan — and **the full filter is re-verified per record at apply time**, with mid-apply rechecks, so a record that stopped matching between audit and apply is skipped, not clobbered. Equality filters double as preconditions; `--require` adds explicit ones; `--guard` asserts cross-record conditions; `--max-operations` caps blast radius. `--set field=from:<sourceField>` derives values per record; `--archive` refuses records whose identity key (account domain, contact email) is shared with another record — that's a duplicate, and duplicates are merged with `dedupe`, not archived around.
125
+
126
+ `dedupe` finds duplicate groups by normalized identity key and emits one `merge_records` operation per group with a deterministic survivor (`richest` = most populated fields, ties to lowest id; `oldest`). Merges stay irreversible-and-therefore-low-confidence-capped on approval, exactly like merge suggestions from the audit. `reassign` is the ownership-handoff playbook: one plan per object type, extra scoping account-lifted to deals and contacts, and `--except-deal-stage` excludes both deals in that stage and every record whose account has an open deal in it. `fix` is the one-shot composite for a single rule: audit → save → suggest → approve suggestion-backed operations at the confidence bar → with `--yes`, apply and print the stage-by-stage summary; without it, stop after approval and print the apply command.
127
+
112
128
  ## The market map: the category, observed
113
129
 
114
130
  Your CRM records what happened in your own deals; nothing records the shape of the category you sell into. The **market map** does: vendors and a claim taxonomy live in a reviewable `market.config.json`, vendor pages are captured into a content-addressed cache, every vendor × claim cell gets a messaging-intensity reading (LOUD / QUIET / ABSENT — with UNOBSERVABLE for failed captures, never a fake absence), and deterministic front states fall out per claim: open, contested, owned, saturated.
@@ -126,6 +142,23 @@ The discipline matches the rest of the tool. Intensity readings are *proposals*
126
142
 
127
143
  `market axes` is for earning a strategic 2×2 instead of asserting one: PCA over the intensity matrix (PC1 = the category's own primary axis, PC2 = the most differentiating direction orthogonal to it), triangulation of your configured axes against the data, and an orthogonality screen that flags two axes that are secretly one. Axes are claim-scoring rubrics in the config; the report renders the primary pair as the strategic map. Captures and observations are profile-scoped (`~/.fullstackgtm/market/<category>`), so one client's category intel never bleeds into another's.
128
144
 
145
+ Two more derivations close the loop from map to action. `market overlay --snapshot <crm.json> [--calls <files>]` joins the observation store against your own ground truth — which claims and vendors actually come up in your deals and call transcripts (deterministic word-boundary matching, no LLM) — and emits OCCUPY / PROMOTE / URGENT / RETREAT directives, each carrying at least one observation and one CRM stat with its sample size; below the evidence thresholds the honest answer is *no directive*. `--save` turns directives into approval-gated `create_task` operations through the normal plan chain. `market scale` estimates each vendor's size from **citable** signals you record in the config (G2 review counts, LinkedIn headcount, revenue claims — each with source URL, verbatim quote, and caveat): every signal is converted into revenue space first, calibrated within the vendor set and stratified by ACV band, then combined as a weighted geometric mean with the uncertainty spread and calibration table disclosed. The report's strategic-map bubbles become area-proportional to estimated revenue share — captioned "citable but NOT audited" — and without signals, dots are uniform and the caption says size carries no meaning.
146
+
147
+ ## Governed enrichment: a diff you approve before third-party data touches your CRM
148
+
149
+ Every enrichment vendor ships fire-and-forget writeback. The **enrich layer** inverts that: declare once which fields come from which source under which conflict policy (`enrich.config.json` — sources, ordered match keys, field mappings, policy), then `enrich append` fills the gaps and `enrich refresh` keeps them current — with every write passing through the normal dry-run → approval → apply contract, and every value traceable to the source payload that produced it.
150
+
151
+ ```bash
152
+ echo "$APOLLO_API_KEY" | fullstackgtm login apollo # BYO key, stored 0600
153
+ fullstackgtm enrich append --provider hubspot # pull → match → dry-run diff, writes NOTHING
154
+ fullstackgtm enrich append --provider hubspot --save # persist the plan (needs_approval) + run record
155
+ fullstackgtm enrich ingest clay-export.csv --source clay # stage a push-style source (Clay CSV / webhook JSON)
156
+ fullstackgtm enrich refresh --source apollo --save # re-check stale stamped fields; ops only where the source changed
157
+ fullstackgtm enrich status --runs # last run per source, counts, staleness, interrupted-run cursor
158
+ ```
159
+
160
+ Matching is deterministic: ordered keys, unique hit wins, zero hits falls through to the next key, and multiple hits are never guessed away — they skip (recorded with candidate ids) or flow into the existing `suggest` → `plans approve` chain as `requires_human_record_selection` placeholders. The MVP conflict policy is `never`: enrich only fills blank fields, and `refresh` only re-touches fields its own run-store ledger proves it stamped (per-record/per-field `enrichedAt`, profile-scoped, never written into your portal as custom properties). The `system-only` and `always` rungs of the ladder are phase 2 and are refused explicitly, not silently accepted. Recurring execution belongs to the scheduler — enrich owns no cron logic.
161
+
129
162
  ### Working across organizations
130
163
 
131
164
  Consultants and fractional operators hold credentials for several CRMs at once. A profile scopes stored logins *and* stored plans to one organization:
package/dist/cli.js CHANGED
@@ -25,6 +25,8 @@ import { computeScaleIndex, scaleReportToText } from "./marketScale.js";
25
25
  import { buildWorksheet, classifyMarket } from "./marketClassify.js";
26
26
  import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
27
27
  import { DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
28
+ import { buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor, } from "./enrich.js";
29
+ import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, } from "./enrichApollo.js";
28
30
  import { resolveRecord } from "./resolve.js";
29
31
  import { buildBulkUpdatePlan } from "./bulkUpdate.js";
30
32
  import { buildDedupePlan } from "./dedupe.js";
@@ -41,7 +43,8 @@ Usage:
41
43
  fullstackgtm login salesforce --device --client-id <consumer key> [--login-url <url>]
42
44
  fullstackgtm login salesforce --instance-url <url> [--no-validate]
43
45
  fullstackgtm login stripe [--no-validate]
44
- fullstackgtm login anthropic | openai store an LLM API key for call parse/score\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|broker>
46
+ fullstackgtm login anthropic | openai store an LLM API key for call parse/score
47
+ fullstackgtm login apollo store an Apollo API key for enrich pulls\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|broker>
45
48
 
46
49
  Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
47
50
  the process list and shell history. Pipe them on stdin or enter them at the
@@ -81,6 +84,15 @@ Usage:
81
84
  against the stored capture it cites before it's accepted — then
82
85
  compute deterministic front states and drift, render the field
83
86
  report. refresh = capture → classify → drift → report in one step
87
+ fullstackgtm enrich append [--source apollo] [--objects companies,contacts] [--save] [--config <path>] [source options]
88
+ fullstackgtm enrich refresh [--source apollo] [--stale-days <n>] [--save] [--config <path>] [source options]
89
+ fullstackgtm enrich ingest <file.csv|payload.json> --source clay [--run-label <label>]
90
+ fullstackgtm enrich status [--runs] [--source <id>] [--json]
91
+ governed enrichment: pull (Apollo) or stage (Clay) third-party
92
+ data, match it to CRM records deterministically, and emit a
93
+ fill-blanks-only patch plan through the normal dry-run →
94
+ approve → apply gate. refresh re-checks stale stamped fields
95
+ and proposes updates only where the source value changed.
84
96
  fullstackgtm bulk-update <account|contact|deal> --where <expr> [--where …] (--set <field>=<value> [--set …] | --archive [--force-archive-duplicates] | --create-task <text>) [--require <field>=<value> …] [--guard <object>:<where>[;<where>]:<none|some> …] [source options] [--save] [--json] [--out <path>]
85
97
  governed generic writes: filter the snapshot
86
98
  (field=value, field!=value, field~substr, field!~substr,
@@ -1103,6 +1115,423 @@ recomputed deterministically on every invocation — never stored.`);
1103
1115
  }
1104
1116
  throw new Error(`Unknown market subcommand: ${subcommand} (try: init, capture, classify, worksheet, observe, fronts, axes, overlay, scale, report, refresh)`);
1105
1117
  }
1118
+ /**
1119
+ * The enrich layer: governed append/refresh of third-party data (Apollo pull,
1120
+ * Clay ingest) into the CRM through the normal dry-run → approval → apply
1121
+ * contract. State lives in the profile-scoped run store (checkpoint,
1122
+ * staleness ledger, observability in one); scheduling belongs to the
1123
+ * horizontal scheduler — enrich owns no cron logic.
1124
+ */
1125
+ async function enrichCommand(args) {
1126
+ const [subcommand, ...rest] = args;
1127
+ // Catch --help BEFORE config load, credential resolution, or any network
1128
+ // call (the 0.14.1/0.18 bug class — `enrich append --help` executing a
1129
+ // paid Apollo pull would be its worst recurrence).
1130
+ if (!subcommand || subcommand === "--help" || subcommand === "-h" || rest.includes("--help") || rest.includes("-h")) {
1131
+ console.log(`Usage:
1132
+ enrich append [--source apollo] [--objects companies,contacts] [--save] [--config <path>]
1133
+ [source options] [--run-label <label>] [--json]
1134
+ enrich refresh [--source apollo] [--stale-days <n>] [--save] [--config <path>]
1135
+ [source options] [--run-label <label>] [--json]
1136
+ enrich ingest <file.csv|payload.json> --source clay [--run-label <label>] [--objects companies|contacts] [--config <path>]
1137
+ enrich status [--runs] [--source <id>] [--config <path>] [--json]
1138
+
1139
+ append pulls from an api source (Apollo — BYO key via \`login apollo\` or
1140
+ APOLLO_API_KEY) or reads data staged by \`enrich ingest\` (Clay CSV exports,
1141
+ webhook payload JSON), matches source records to CRM records via the ordered
1142
+ match keys in enrich.config.json (unique hit wins; zero hits falls through to
1143
+ the next key; multiple hits skip or flow into the suggest chain, per
1144
+ onAmbiguous), and emits a fill-blanks-only patch plan. Without --save it
1145
+ prints the dry-run diff and writes NOTHING; with --save the plan lands in the
1146
+ plan store as needs_approval and the run (counts, per-field enrichedAt stamps,
1147
+ resume cursor) lands in the profile's enrich run store. From there the normal
1148
+ chain takes over: plans approve → apply.
1149
+
1150
+ refresh computes its work set from the run-store stamps — fields enrich
1151
+ itself wrote, opted in with "refresh": true, older than the staleness window
1152
+ (--stale-days overrides per-field staleDays and policy.defaultStaleDays) —
1153
+ re-fetches the source, and proposes updates only where the source value
1154
+ actually changed. Every operation carries beforeValue, so apply-time
1155
+ compare-and-set rejects writes over a CRM that moved underneath the plan.
1156
+
1157
+ Conflict policy (MVP): "never" — enrich only fills blank fields and only
1158
+ re-touches fields its own ledger proves it stamped. system-only and always
1159
+ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
1160
+ return;
1161
+ }
1162
+ if (!["append", "refresh", "ingest", "status"].includes(subcommand)) {
1163
+ throw new Error(`Unknown enrich subcommand: ${subcommand} (try: append, refresh, ingest, status)`);
1164
+ }
1165
+ const configPath = () => resolve(process.cwd(), option(rest, "--config") ?? ENRICH_CONFIG_FILE_NAME);
1166
+ const store = createFileEnrichRunStore();
1167
+ if (subcommand === "status") {
1168
+ await enrichStatus(store, rest, configPath());
1169
+ return;
1170
+ }
1171
+ const config = loadEnrichConfig(configPath());
1172
+ if (subcommand === "ingest") {
1173
+ await enrichIngest(store, config, rest);
1174
+ return;
1175
+ }
1176
+ const mode = subcommand;
1177
+ const source = resolveEnrichSource(config, rest);
1178
+ const sourceConfig = config.sources[source];
1179
+ const save = rest.includes("--save");
1180
+ const today = new Date().toISOString().slice(0, 10);
1181
+ // Refresh work set comes from the staleness ledger, before any fetch.
1182
+ const allRuns = await store.list();
1183
+ let workSet = [];
1184
+ if (mode === "refresh") {
1185
+ const staleDaysOverride = numericOption(rest, "--stale-days");
1186
+ workSet = selectStaleWork(config, allRuns, source, { staleDaysOverride });
1187
+ if (workSet.length === 0) {
1188
+ const stamped = latestStamps(allRuns, source).size;
1189
+ console.log(stamped === 0
1190
+ ? `Nothing to refresh: no ${source} enrichment stamps yet. Run \`enrich append --source ${source} --save\` first.`
1191
+ : `Nothing to refresh: all ${stamped} stamped field(s) from ${source} are within their staleness window.`);
1192
+ return;
1193
+ }
1194
+ }
1195
+ const snapshot = await readSnapshot(rest);
1196
+ // Assemble source records: api pull (checkpointed when --save) or staged ingest data.
1197
+ let run = null;
1198
+ let records;
1199
+ let missCount = 0;
1200
+ if (sourceConfig.kind === "api") {
1201
+ const objectTypes = parseEnrichObjects(rest, config, source);
1202
+ const fieldsFor = (objectType) => (config.fields[objectType] ?? []).filter((field) => field.from[source] !== undefined);
1203
+ const pullKeys = mode === "append"
1204
+ ? apolloPullKeysForAppend(snapshot, objectTypes, (objectType, record) => fieldsFor(objectType).some((field) => {
1205
+ const value = record[resolveCrmField(objectType, field.crm)];
1206
+ return value === undefined || value === null || String(value).trim() === "";
1207
+ }))
1208
+ : apolloPullKeysForRefresh(snapshot, workSet);
1209
+ if (pullKeys.length === 0) {
1210
+ console.log(mode === "append"
1211
+ ? "Nothing to enrich: no records with a blank mapped field and a pull key (companies need a domain, contacts an email)."
1212
+ : "Nothing to refresh: no stale records carry a pull key (companies need a domain, contacts an email).");
1213
+ return;
1214
+ }
1215
+ const client = createApolloClient({
1216
+ getApiKey: () => apolloApiKey(),
1217
+ apiBaseUrl: process.env.APOLLO_API_BASE_URL,
1218
+ });
1219
+ if (save) {
1220
+ run = await openEnrichRun(store, source, mode, option(rest, "--run-label"), today);
1221
+ if (run.cursor) {
1222
+ console.error(`Resuming interrupted run ${run.runLabel} from cursor ${run.cursor} (${run.pulled?.length ?? 0} record(s) already pulled).`);
1223
+ }
1224
+ }
1225
+ const result = await pullApolloRecords(client, pullKeys, {
1226
+ resumeAfter: run?.cursor ?? null,
1227
+ onProgress: run
1228
+ ? async (progress) => {
1229
+ run.cursor = progress.lastKeyValue;
1230
+ if (progress.record)
1231
+ run.pulled = [...(run.pulled ?? []), progress.record];
1232
+ if (progress.miss)
1233
+ run.missedKeys = [...(run.missedKeys ?? []), progress.miss.value];
1234
+ await store.update(run);
1235
+ }
1236
+ : undefined,
1237
+ });
1238
+ records = run ? [...(run.pulled ?? [])] : result.records;
1239
+ missCount = run ? (run.missedKeys?.length ?? 0) : result.misses.length;
1240
+ }
1241
+ else {
1242
+ const stagedLabel = option(rest, "--staged-run");
1243
+ const stagedRun = stagedLabel
1244
+ ? await store.get(stagedLabel)
1245
+ : await store.latest({ source, mode: "ingest" });
1246
+ if (!stagedRun || stagedRun.mode !== "ingest") {
1247
+ throw new Error(`No staged data for source "${source}". Stage it first: fullstackgtm enrich ingest <file.csv|payload.json> --source ${source}`);
1248
+ }
1249
+ records = stagedSourceRecords(config, source, stagedRun);
1250
+ if (save)
1251
+ run = await openEnrichRun(store, source, mode, option(rest, "--run-label"), today);
1252
+ }
1253
+ const result = buildEnrichPlan({
1254
+ config,
1255
+ source,
1256
+ mode,
1257
+ snapshot,
1258
+ records,
1259
+ workSet: mode === "refresh" ? workSet : undefined,
1260
+ runLabel: run?.runLabel ?? `${mode}-${source}-${today}`,
1261
+ });
1262
+ // Pull keys the source had no data for count as fetched-but-unmatched.
1263
+ result.counts.fetched += missCount;
1264
+ result.counts.unmatched += missCount;
1265
+ if (!save) {
1266
+ if (rest.includes("--json")) {
1267
+ console.log(JSON.stringify(result.plan, null, 2));
1268
+ }
1269
+ else {
1270
+ console.log(patchPlanToMarkdown(result.plan));
1271
+ console.log(formatEnrichCounts(result.counts, result.ambiguities.length));
1272
+ console.log("\nDry run — nothing written. Re-run with --save to persist the plan and the run record.");
1273
+ }
1274
+ return;
1275
+ }
1276
+ // --save: persist the plan (when it proposes anything) and finalize the run.
1277
+ const planIds = [];
1278
+ if (result.plan.operations.length > 0) {
1279
+ await createFilePlanStore().save(result.plan);
1280
+ planIds.push(result.plan.id);
1281
+ }
1282
+ const finalized = {
1283
+ ...run,
1284
+ completedAt: new Date().toISOString(),
1285
+ cursor: null,
1286
+ counts: result.counts,
1287
+ planIds: [...(run.planIds ?? []), ...planIds],
1288
+ stamps: [...(run.stamps ?? []), ...result.stamps],
1289
+ ambiguities: result.ambiguities,
1290
+ };
1291
+ await store.update(finalized);
1292
+ console.log(formatEnrichCounts(result.counts, result.ambiguities.length));
1293
+ if (planIds.length > 0) {
1294
+ console.log(`Saved plan ${result.plan.id} (run ${finalized.runLabel}). Review with \`fullstackgtm plans show ${result.plan.id}\`, ` +
1295
+ `approve with \`fullstackgtm plans approve ${result.plan.id} --operations <ids|all>\`, then ` +
1296
+ `\`fullstackgtm apply --plan-id ${result.plan.id} --provider <name>\`.`);
1297
+ }
1298
+ else {
1299
+ console.log(`Run ${finalized.runLabel} recorded; no operations to propose.`);
1300
+ }
1301
+ }
1302
+ function formatEnrichCounts(counts, ambiguities) {
1303
+ return (`Source records: ${counts.fetched} fetched · ${counts.matched} matched · ` +
1304
+ `${counts.unmatched} unmatched · ${counts.ambiguous} ambiguous (${ambiguities} collision(s) recorded) · ` +
1305
+ `${counts.opsEmitted} operation(s) proposed`);
1306
+ }
1307
+ function resolveEnrichSource(config, rest) {
1308
+ const requested = option(rest, "--source");
1309
+ const declared = Object.keys(config.sources);
1310
+ if (requested) {
1311
+ if (!config.sources[requested]) {
1312
+ throw new Error(`Unknown enrich source "${requested}" (declared: ${declared.join(", ")})`);
1313
+ }
1314
+ return requested;
1315
+ }
1316
+ if (declared.length === 1)
1317
+ return declared[0];
1318
+ if (config.sources.apollo)
1319
+ return "apollo";
1320
+ throw new Error(`Multiple sources declared (${declared.join(", ")}) — pass --source <id>`);
1321
+ }
1322
+ function parseEnrichObjects(rest, config, source) {
1323
+ const configured = ["company", "contact"].filter((objectType) => (config.fields[objectType] ?? []).some((field) => field.from[source] !== undefined));
1324
+ const flag = option(rest, "--objects");
1325
+ if (!flag) {
1326
+ if (configured.length === 0) {
1327
+ throw new Error(`No fields map from source "${source}" — add "from": { "${source}": ... } entries to the config.`);
1328
+ }
1329
+ return configured;
1330
+ }
1331
+ const requested = Array.from(new Set(flag.split(",").map((part) => parseSingleObjectType(part))));
1332
+ for (const objectType of requested) {
1333
+ if (!configured.includes(objectType)) {
1334
+ throw new Error(`--objects ${flag}: no ${objectType} fields map from source "${source}" in the config.`);
1335
+ }
1336
+ }
1337
+ return requested;
1338
+ }
1339
+ function apolloApiKey() {
1340
+ if (process.env.APOLLO_API_KEY)
1341
+ return process.env.APOLLO_API_KEY;
1342
+ const stored = getCredential("apollo");
1343
+ if (stored)
1344
+ return stored.accessToken;
1345
+ throw new Error('No Apollo credentials. Run `echo "$APOLLO_API_KEY" | fullstackgtm login apollo` once, or set APOLLO_API_KEY.');
1346
+ }
1347
+ /**
1348
+ * Open (or resume) a saved run. An interrupted run — same label, same source
1349
+ * and mode, never completed — is resumed from its cursor; a completed run
1350
+ * with the default label gets a -2/-3 suffix (runs are append-only).
1351
+ */
1352
+ async function openEnrichRun(store, source, mode, requestedLabel, today) {
1353
+ const baseLabel = requestedLabel ?? `${mode}-${source}-${today}`;
1354
+ let label = baseLabel;
1355
+ for (let suffix = 2;; suffix += 1) {
1356
+ const existing = await store.get(label);
1357
+ if (!existing)
1358
+ break;
1359
+ if (existing.source === source && existing.mode === mode && existing.completedAt === null) {
1360
+ return existing; // resume the interrupted run
1361
+ }
1362
+ if (requestedLabel) {
1363
+ throw new Error(`Run "${requestedLabel}" already exists and is completed — enrich runs are append-only.`);
1364
+ }
1365
+ label = `${baseLabel}-${suffix}`;
1366
+ }
1367
+ return store.append({
1368
+ id: enrichRunId(source, label),
1369
+ runLabel: label,
1370
+ source,
1371
+ mode,
1372
+ startedAt: new Date().toISOString(),
1373
+ completedAt: null,
1374
+ cursor: null,
1375
+ counts: { fetched: 0, matched: 0, unmatched: 0, ambiguous: 0, opsEmitted: 0 },
1376
+ planIds: [],
1377
+ stamps: [],
1378
+ });
1379
+ }
1380
+ async function enrichIngest(store, config, rest) {
1381
+ const file = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
1382
+ if (!file)
1383
+ throw new Error("Usage: fullstackgtm enrich ingest <file.csv|payload.json> --source <id> [--run-label <label>]");
1384
+ const source = option(rest, "--source");
1385
+ if (!source)
1386
+ throw new Error("enrich ingest requires --source <id> (the ingest source the data belongs to)");
1387
+ const sourceConfig = config.sources[source];
1388
+ if (!sourceConfig) {
1389
+ throw new Error(`Unknown enrich source "${source}" (declared: ${Object.keys(config.sources).join(", ")})`);
1390
+ }
1391
+ if (sourceConfig.kind !== "ingest") {
1392
+ throw new Error(`Source "${source}" is kind "${sourceConfig.kind}" — only ingest sources accept staged data.`);
1393
+ }
1394
+ const raw = readFileSync(resolve(process.cwd(), file), "utf8");
1395
+ let rows;
1396
+ const isCsv = file.toLowerCase().endsWith(".csv") || sourceConfig.format === "csv";
1397
+ if (isCsv && !file.toLowerCase().endsWith(".json")) {
1398
+ rows = parseCsv(raw);
1399
+ }
1400
+ else {
1401
+ const parsed = JSON.parse(raw);
1402
+ if (Array.isArray(parsed))
1403
+ rows = parsed;
1404
+ else if (parsed && typeof parsed === "object" && Array.isArray(parsed.rows)) {
1405
+ rows = parsed.rows;
1406
+ }
1407
+ else if (parsed && typeof parsed === "object") {
1408
+ rows = [parsed];
1409
+ }
1410
+ else {
1411
+ throw new Error(`${file}: expected a JSON array, an object, or { "rows": [...] }`);
1412
+ }
1413
+ }
1414
+ if (rows.length === 0)
1415
+ throw new Error(`${file}: no rows to stage`);
1416
+ const objectsFlag = option(rest, "--objects");
1417
+ const objectType = objectsFlag
1418
+ ? parseSingleObjectType(objectsFlag)
1419
+ : inferIngestObjectType(config, source, rows);
1420
+ const today = new Date().toISOString().slice(0, 10);
1421
+ const baseLabel = option(rest, "--run-label") ?? `ingest-${source}-${today}`;
1422
+ let label = baseLabel;
1423
+ for (let suffix = 2; await store.get(label); suffix += 1) {
1424
+ if (option(rest, "--run-label")) {
1425
+ throw new Error(`Run "${baseLabel}" already exists — enrich runs are append-only; pick a new --run-label.`);
1426
+ }
1427
+ label = `${baseLabel}-${suffix}`;
1428
+ }
1429
+ const now = new Date().toISOString();
1430
+ await store.append({
1431
+ id: enrichRunId(source, label),
1432
+ runLabel: label,
1433
+ source,
1434
+ mode: "ingest",
1435
+ startedAt: now,
1436
+ completedAt: now,
1437
+ cursor: null,
1438
+ counts: { fetched: rows.length, matched: 0, unmatched: 0, ambiguous: 0, opsEmitted: 0 },
1439
+ planIds: [],
1440
+ stamps: [],
1441
+ staged: rows,
1442
+ stagedObjectType: objectType,
1443
+ });
1444
+ console.log(`Staged ${rows.length} ${objectType} row(s) from ${file} as run ${label}. ` +
1445
+ `Next: fullstackgtm enrich append --source ${source} [source options] [--save]`);
1446
+ }
1447
+ function parseSingleObjectType(value) {
1448
+ const normalized = value.trim().toLowerCase();
1449
+ if (normalized === "companies" || normalized === "company")
1450
+ return "company";
1451
+ if (normalized === "contacts" || normalized === "contact")
1452
+ return "contact";
1453
+ throw new Error(`--objects must be companies or contacts (got "${value}")`);
1454
+ }
1455
+ async function enrichStatus(store, rest, configFile) {
1456
+ const sourceFilter = option(rest, "--source");
1457
+ const allRuns = (await store.list()).filter((run) => !sourceFilter || run.source === sourceFilter);
1458
+ if (allRuns.length === 0) {
1459
+ console.log(sourceFilter
1460
+ ? `No enrich runs for source "${sourceFilter}".`
1461
+ : "No enrich runs yet. Start with `fullstackgtm enrich append --save` or stage data with `enrich ingest`.");
1462
+ return;
1463
+ }
1464
+ // Staleness windows come from the config when one is readable; status must
1465
+ // not REQUIRE a config (the run store alone is enough to report on).
1466
+ let config = null;
1467
+ if (existsSync(configFile)) {
1468
+ try {
1469
+ config = loadEnrichConfig(configFile);
1470
+ }
1471
+ catch {
1472
+ config = null;
1473
+ }
1474
+ }
1475
+ const now = Date.now();
1476
+ const sources = Array.from(new Set(allRuns.map((run) => run.source)));
1477
+ const report = sources.map((source) => {
1478
+ const runs = allRuns.filter((run) => run.source === source);
1479
+ const last = runs[runs.length - 1];
1480
+ const interrupted = runs.filter((run) => run.completedAt === null);
1481
+ const stamps = Array.from(latestStamps(runs, source).values());
1482
+ const ages = stamps.map((stamp) => (now - Date.parse(stamp.enrichedAt)) / 86_400_000);
1483
+ const staleness = stamps.map((stamp, index) => {
1484
+ const windowDays = config
1485
+ ? staleDaysFor(config, stamp.objectType, stamp.field)
1486
+ : DEFAULT_STALE_DAYS;
1487
+ return ages[index] > windowDays;
1488
+ });
1489
+ return {
1490
+ source,
1491
+ runs: runs.length,
1492
+ lastRun: {
1493
+ runLabel: last.runLabel,
1494
+ mode: last.mode,
1495
+ startedAt: last.startedAt,
1496
+ completedAt: last.completedAt,
1497
+ counts: last.counts,
1498
+ planIds: last.planIds,
1499
+ },
1500
+ interrupted: interrupted.map((run) => ({ runLabel: run.runLabel, cursor: run.cursor })),
1501
+ stamps: {
1502
+ total: stamps.length,
1503
+ stale: staleness.filter(Boolean).length,
1504
+ oldestDays: ages.length ? Math.round(Math.max(...ages)) : null,
1505
+ newestDays: ages.length ? Math.round(Math.min(...ages)) : null,
1506
+ windowSource: config ? "enrich.config.json" : `default ${DEFAULT_STALE_DAYS}d`,
1507
+ },
1508
+ };
1509
+ });
1510
+ if (rest.includes("--json")) {
1511
+ console.log(JSON.stringify({ sources: report, runs: rest.includes("--runs") ? allRuns : undefined }, null, 2));
1512
+ return;
1513
+ }
1514
+ for (const entry of report) {
1515
+ const last = entry.lastRun;
1516
+ console.log(`${entry.source} — ${entry.runs} run(s)`);
1517
+ console.log(` last: ${last.runLabel} (${last.mode}) ${last.completedAt ? `completed ${last.completedAt}` : "INTERRUPTED"}` +
1518
+ ` · ${last.counts.fetched} fetched, ${last.counts.matched} matched, ${last.counts.unmatched} unmatched,` +
1519
+ ` ${last.counts.ambiguous} ambiguous, ${last.counts.opsEmitted} ops` +
1520
+ (last.planIds.length ? ` · plans: ${last.planIds.join(", ")}` : ""));
1521
+ for (const run of entry.interrupted) {
1522
+ console.log(` interrupted: ${run.runLabel} at cursor ${run.cursor ?? "(start)"} — re-run with --save to resume`);
1523
+ }
1524
+ console.log(` stamps: ${entry.stamps.total} field(s) enriched · ${entry.stamps.stale} stale (window: ${entry.stamps.windowSource})` +
1525
+ (entry.stamps.total ? ` · age ${entry.stamps.newestDays}–${entry.stamps.oldestDays}d` : ""));
1526
+ }
1527
+ if (rest.includes("--runs")) {
1528
+ console.log("");
1529
+ for (const run of allRuns) {
1530
+ console.log(`${run.runLabel} ${run.source.padEnd(8)} ${run.mode.padEnd(8)} ${run.completedAt ? "done" : "interrupted"}` +
1531
+ ` ${run.counts.opsEmitted} ops ${run.stamps.length} stamps${run.staged ? ` ${run.staged.length} staged` : ""}`);
1532
+ }
1533
+ }
1534
+ }
1106
1535
  /**
1107
1536
  * The resolve gate: exit 0 = safe to create, exit 2 = match found (exists or
1108
1537
  * ambiguous — do NOT blind-create), exit 1 = error. Built for sync jobs and
@@ -1850,8 +2279,27 @@ async function login(args) {
1850
2279
  console.log(`Stored ${provider} API key in ${credentialsPath()}. \`fullstackgtm call parse\` and \`call score\` use it automatically.`);
1851
2280
  return;
1852
2281
  }
2282
+ if (provider === "apollo") {
2283
+ rejectArgvSecret(args, "--token", "--key", "--api-key");
2284
+ const key = await readSecret("Apollo API key");
2285
+ if (!key)
2286
+ throw new Error("No Apollo key provided.");
2287
+ if (!args.includes("--no-validate")) {
2288
+ const response = await fetch("https://api.apollo.io/api/v1/auth/health", {
2289
+ headers: { "X-Api-Key": key, Accept: "application/json" },
2290
+ });
2291
+ if (!response.ok) {
2292
+ throw new Error(`Apollo rejected the key: ${safeStatus(response)}`);
2293
+ }
2294
+ console.log("Key accepted by the Apollo API.");
2295
+ }
2296
+ const stamp = new Date().toISOString();
2297
+ storeCredential("apollo", { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
2298
+ console.log(`Stored Apollo API key in ${credentialsPath()}. \`fullstackgtm enrich append|refresh\` use it automatically.`);
2299
+ return;
2300
+ }
1853
2301
  if (provider !== "hubspot") {
1854
- throw new Error("login supports: hubspot, salesforce, stripe, anthropic, openai, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
2302
+ throw new Error("login supports: hubspot, salesforce, stripe, anthropic, openai, apollo, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
1855
2303
  }
1856
2304
  const now = new Date().toISOString();
1857
2305
  if (args.includes("--oauth")) {
@@ -2058,8 +2506,8 @@ export async function runCli(argv) {
2058
2506
  }
2059
2507
  // Commands without bespoke help fall back to the top-level usage on --help
2060
2508
  // instead of executing (audit used to silently run the sample audit).
2061
- // call/market/bulk-update print their own richer help.
2062
- if (!["call", "market", "bulk-update"].includes(command) && (args.includes("--help") || args.includes("-h"))) {
2509
+ // call/market/enrich/bulk-update print their own richer help.
2510
+ if (!["call", "market", "enrich", "bulk-update"].includes(command) && (args.includes("--help") || args.includes("-h"))) {
2063
2511
  console.log(usage());
2064
2512
  return;
2065
2513
  }
@@ -2123,6 +2571,10 @@ export async function runCli(argv) {
2123
2571
  await marketCommand(args);
2124
2572
  return;
2125
2573
  }
2574
+ if (command === "enrich") {
2575
+ await enrichCommand(args);
2576
+ return;
2577
+ }
2126
2578
  if (command === "profiles") {
2127
2579
  profilesCommand(args);
2128
2580
  return;