fullstackgtm 0.37.0 → 0.38.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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * LinkedIn → acquire bridge (Phase 1, step 2).
3
+ *
4
+ * Connects the provider-agnostic LinkedIn discovery layer (connectors/linkedin.ts)
5
+ * to the `enrich acquire` spine: pull prospects from a LinkedIn source, map them
6
+ * to the canonical `Prospect`, and hand them to the existing ICP-score → dedup →
7
+ * create_record → meter → dry-run/apply pipeline unchanged.
8
+ *
9
+ * Kept out of cli.ts on purpose: the only remaining wiring is a ~3-line
10
+ * discovery branch in `acquireFromApi` (call `createLinkedInProvider` +
11
+ * `discoverLinkedInProspects`), a `builtinAcquirePreset('linkedin')` entry, the
12
+ * source allowlist/help, and a `login heyreach` credential. That lands once
13
+ * cli.ts is clean of other in-flight work — see docs/linkedin-connector-spec.md.
14
+ *
15
+ * HeyReach reads a pre-populated lead LIST (not an ICP-driven search), and the
16
+ * natural identity key is the LinkedIn profile URL — so acquire's matchKey is
17
+ * `linkedin`, which means the spine skips pipe0 email resolution automatically.
18
+ */
19
+
20
+ import {
21
+ createHeyReachProvider,
22
+ type HeyReachProviderOptions,
23
+ type LinkedInProspect,
24
+ type LinkedInProvider,
25
+ } from "./connectors/linkedin.ts";
26
+ import type { Prospect } from "./connectors/prospectSources.ts";
27
+
28
+ /** Map a normalized LinkedIn prospect into the canonical acquire `Prospect`. */
29
+ export function linkedInProspectToProspect(lp: LinkedInProspect): Prospect {
30
+ return {
31
+ firstName: lp.firstName || undefined,
32
+ lastName: lp.lastName || undefined,
33
+ fullName: lp.fullName,
34
+ // Title drives ICP scoring; fall back to the headline when no title is set.
35
+ jobTitle: lp.jobTitle ?? lp.headline,
36
+ companyName: lp.company,
37
+ linkedin: lp.profileUrl || undefined,
38
+ email: lp.email,
39
+ // The profile URL is LinkedIn's stable native id — use it for traceability.
40
+ sourceId: lp.profileUrl || undefined,
41
+ };
42
+ }
43
+
44
+ export type DiscoverLinkedInOptions = {
45
+ /** Which source (HeyReach lead-list id) to read; provider may have a default. */
46
+ sourceId?: string;
47
+ /** Hard cap on prospects pulled. */
48
+ max?: number;
49
+ };
50
+
51
+ /**
52
+ * Discovery branch for `acquireFromApi`: read prospects from a LinkedIn source
53
+ * and return them as canonical `Prospect`s. Read-only — never sends. The
54
+ * provider is injected so this is fully testable against the fake (no key).
55
+ */
56
+ export async function discoverLinkedInProspects(
57
+ provider: LinkedInProvider,
58
+ options: DiscoverLinkedInOptions = {},
59
+ ): Promise<Prospect[]> {
60
+ const raw = await provider.fetchProspects({ sourceId: options.sourceId, max: options.max });
61
+ return raw.map(linkedInProspectToProspect);
62
+ }
63
+
64
+ export type CreateLinkedInProviderOptions = Omit<HeyReachProviderOptions, "apiKey">;
65
+
66
+ /**
67
+ * Build the execution provider for a LinkedIn acquire source. HeyReach is the
68
+ * default; `unipile` is the planned alternative (same interface). Called by the
69
+ * cli acquire branch with the stored/env API key.
70
+ */
71
+ export function createLinkedInProvider(
72
+ source: string,
73
+ apiKey: string,
74
+ options: CreateLinkedInProviderOptions = {},
75
+ ): LinkedInProvider {
76
+ switch (source) {
77
+ case "linkedin":
78
+ case "heyreach":
79
+ return createHeyReachProvider({ apiKey, ...options });
80
+ default:
81
+ throw new Error(`Unknown LinkedIn execution provider "${source}" (supported: heyreach).`);
82
+ }
83
+ }
package/src/assign.ts ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * AssignmentPolicy — the shared rule that decides which owner a record belongs
3
+ * to. One policy, two consumers:
4
+ * 1. `enrich acquire` stamps the resolved owner into each `create_record`
5
+ * payload, so a lead is never born ownerless, and
6
+ * 2. `reassign --assign-unowned` applies the SAME policy to backfill existing
7
+ * ownerless records.
8
+ *
9
+ * Pure + zero-dep: `resolveAssignment` takes plain data (the record's routing
10
+ * attributes + the candidate owners + the within-run index) and returns an
11
+ * ownerId or null. Round-robin distributes by the within-run index, so it needs
12
+ * no persisted rotation cursor — the same plan always resolves the same way
13
+ * (deterministic apply). Every result is gated by the set of KNOWN active
14
+ * owners: a policy that points at an unknown or inactive owner yields null, and
15
+ * the caller leaves that record unassigned + surfaces it — never a bad write.
16
+ */
17
+
18
+ export type AssignmentStrategy = "fixed" | "round-robin" | "territory" | "account-owner";
19
+
20
+ /** The attributes a territory rule can route on, lifted from a record/prospect. */
21
+ export type AssignmentContext = {
22
+ /** ISO country code, lowercased (e.g. "us"). */
23
+ geo?: string;
24
+ /** human industry label, lowercased (e.g. "software"). */
25
+ industry?: string;
26
+ /** provider-agnostic employee band (e.g. "11-50"). */
27
+ employeeBand?: string;
28
+ /** department, lowercased (e.g. "sales"). */
29
+ department?: string;
30
+ /** raw job title, lowercased — matched against rule titleKeywords as substrings. */
31
+ title?: string;
32
+ /** owner of the associated account, if any — enables "account-owner" inherit. */
33
+ accountOwnerId?: string;
34
+ };
35
+
36
+ /**
37
+ * One territory clause. Every present key must match (AND across keys); within a
38
+ * key the candidate value must be one of the listed values (OR). `titleKeywords`
39
+ * matches when the context title CONTAINS any keyword as a substring.
40
+ */
41
+ export type TerritoryRule = {
42
+ when: Partial<{
43
+ geo: string[];
44
+ industry: string[];
45
+ employeeBand: string[];
46
+ department: string[];
47
+ titleKeywords: string[];
48
+ }>;
49
+ ownerId: string;
50
+ };
51
+
52
+ export type AssignmentPolicy =
53
+ | { strategy: "fixed"; ownerId: string }
54
+ | { strategy: "round-robin"; ownerIds: string[] }
55
+ | { strategy: "territory"; rules: TerritoryRule[]; fallbackOwnerId?: string }
56
+ | { strategy: "account-owner"; fallbackOwnerId?: string };
57
+
58
+ export type AssignmentResult = {
59
+ /** the resolved owner, or null when the policy can't place this record. */
60
+ ownerId: string | null;
61
+ /** short human label for why (audit trail): "fixed", "round-robin", "territory:0", "account-owner", "fallback". */
62
+ rule?: string;
63
+ };
64
+
65
+ const STRATEGIES: AssignmentStrategy[] = ["fixed", "round-robin", "territory", "account-owner"];
66
+
67
+ function asStringArray(value: unknown, label: string): string[] {
68
+ if (!Array.isArray(value) || value.length === 0 || value.some((v) => typeof v !== "string" || !v.trim())) {
69
+ throw new Error(`assign: ${label} must be a non-empty array of strings`);
70
+ }
71
+ return value.map((v) => (v as string).trim());
72
+ }
73
+
74
+ /**
75
+ * Validate + normalize a raw assignment policy (from enrich.config.json or a
76
+ * CLI flag). Throws on shape errors so a misconfigured policy fails loud at
77
+ * plan time, never silently mis-routes.
78
+ */
79
+ export function parseAssignmentPolicy(raw: unknown): AssignmentPolicy {
80
+ if (!raw || typeof raw !== "object") throw new Error("assign: expected a policy object");
81
+ const p = raw as Record<string, unknown>;
82
+ const strategy = p.strategy;
83
+ if (typeof strategy !== "string" || !STRATEGIES.includes(strategy as AssignmentStrategy)) {
84
+ throw new Error(`assign: "strategy" must be one of ${STRATEGIES.join(" | ")}`);
85
+ }
86
+ switch (strategy) {
87
+ case "fixed": {
88
+ if (typeof p.ownerId !== "string" || !p.ownerId.trim()) {
89
+ throw new Error('assign: fixed strategy needs a non-empty "ownerId"');
90
+ }
91
+ return { strategy, ownerId: p.ownerId.trim() };
92
+ }
93
+ case "round-robin": {
94
+ const ownerIds = asStringArray(p.ownerIds, "round-robin ownerIds");
95
+ return { strategy, ownerIds };
96
+ }
97
+ case "territory": {
98
+ if (!Array.isArray(p.rules) || p.rules.length === 0) {
99
+ throw new Error('assign: territory strategy needs a non-empty "rules" array');
100
+ }
101
+ const rules: TerritoryRule[] = p.rules.map((r, i) => {
102
+ if (!r || typeof r !== "object") throw new Error(`assign: territory rule ${i} must be an object`);
103
+ const rule = r as Record<string, unknown>;
104
+ if (typeof rule.ownerId !== "string" || !rule.ownerId.trim()) {
105
+ throw new Error(`assign: territory rule ${i} needs a non-empty "ownerId"`);
106
+ }
107
+ const whenRaw = (rule.when ?? {}) as Record<string, unknown>;
108
+ const when: TerritoryRule["when"] = {};
109
+ for (const key of ["geo", "industry", "employeeBand", "department", "titleKeywords"] as const) {
110
+ if (whenRaw[key] !== undefined) when[key] = asStringArray(whenRaw[key], `territory rule ${i} when.${key}`);
111
+ }
112
+ if (Object.keys(when).length === 0) {
113
+ throw new Error(`assign: territory rule ${i} "when" needs at least one clause (else it matches everything — use a fallbackOwnerId instead)`);
114
+ }
115
+ return { when, ownerId: rule.ownerId.trim() };
116
+ });
117
+ const fallbackOwnerId = typeof p.fallbackOwnerId === "string" && p.fallbackOwnerId.trim() ? p.fallbackOwnerId.trim() : undefined;
118
+ return { strategy, rules, ...(fallbackOwnerId ? { fallbackOwnerId } : {}) };
119
+ }
120
+ case "account-owner": {
121
+ const fallbackOwnerId = typeof p.fallbackOwnerId === "string" && p.fallbackOwnerId.trim() ? p.fallbackOwnerId.trim() : undefined;
122
+ return { strategy, ...(fallbackOwnerId ? { fallbackOwnerId } : {}) };
123
+ }
124
+ default:
125
+ throw new Error(`assign: unsupported strategy "${strategy}"`);
126
+ }
127
+ }
128
+
129
+ function clauseMatches(values: string[], candidate: string | undefined): boolean {
130
+ if (candidate === undefined) return false;
131
+ return values.some((v) => v.toLowerCase() === candidate.toLowerCase());
132
+ }
133
+
134
+ function titleMatches(keywords: string[], title: string | undefined): boolean {
135
+ if (!title) return false;
136
+ const lower = title.toLowerCase();
137
+ return keywords.some((k) => lower.includes(k.toLowerCase()));
138
+ }
139
+
140
+ /** Does the context satisfy every present clause of a territory rule? */
141
+ export function matchesTerritory(rule: TerritoryRule, ctx: AssignmentContext): boolean {
142
+ const { when } = rule;
143
+ if (when.geo && !clauseMatches(when.geo, ctx.geo)) return false;
144
+ if (when.industry && !clauseMatches(when.industry, ctx.industry)) return false;
145
+ if (when.employeeBand && !clauseMatches(when.employeeBand, ctx.employeeBand)) return false;
146
+ if (when.department && !clauseMatches(when.department, ctx.department)) return false;
147
+ if (when.titleKeywords && !titleMatches(when.titleKeywords, ctx.title)) return false;
148
+ return true;
149
+ }
150
+
151
+ /** Gate a candidate ownerId by the known-active set: returns it, or null. */
152
+ function known(ownerId: string | undefined, knownOwnerIds: ReadonlySet<string>): string | null {
153
+ return ownerId && knownOwnerIds.has(ownerId) ? ownerId : null;
154
+ }
155
+
156
+ /**
157
+ * Resolve the owner for one record. `index` is the record's 0-based position
158
+ * within the run (used by round-robin for deterministic, even within-run
159
+ * distribution without persisted state). `knownOwnerIds` is the set of active
160
+ * owners from the snapshot; any result outside it collapses to null so callers
161
+ * never write an owner the CRM won't accept.
162
+ */
163
+ export function resolveAssignment(
164
+ policy: AssignmentPolicy,
165
+ ctx: AssignmentContext,
166
+ index: number,
167
+ knownOwnerIds: ReadonlySet<string>,
168
+ ): AssignmentResult {
169
+ switch (policy.strategy) {
170
+ case "fixed":
171
+ return { ownerId: known(policy.ownerId, knownOwnerIds), rule: "fixed" };
172
+ case "round-robin": {
173
+ const pool = policy.ownerIds.filter((id) => knownOwnerIds.has(id));
174
+ if (pool.length === 0) return { ownerId: null, rule: "round-robin" };
175
+ const safeIndex = ((index % pool.length) + pool.length) % pool.length;
176
+ return { ownerId: pool[safeIndex], rule: "round-robin" };
177
+ }
178
+ case "territory": {
179
+ for (let i = 0; i < policy.rules.length; i++) {
180
+ if (matchesTerritory(policy.rules[i], ctx)) {
181
+ const hit = known(policy.rules[i].ownerId, knownOwnerIds);
182
+ if (hit) return { ownerId: hit, rule: `territory:${i}` };
183
+ }
184
+ }
185
+ return { ownerId: known(policy.fallbackOwnerId, knownOwnerIds), rule: "fallback" };
186
+ }
187
+ case "account-owner": {
188
+ const inherited = known(ctx.accountOwnerId, knownOwnerIds);
189
+ if (inherited) return { ownerId: inherited, rule: "account-owner" };
190
+ return { ownerId: known(policy.fallbackOwnerId, knownOwnerIds), rule: "fallback" };
191
+ }
192
+ }
193
+ }
package/src/bin.ts CHANGED
@@ -1,7 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import { runCli } from "./cli.ts";
3
+ import { flushRunReport } from "./runReport.ts";
3
4
 
4
- runCli(process.argv.slice(2)).catch((error) => {
5
- console.error(error instanceof Error ? error.message : String(error));
6
- process.exitCode = 1;
7
- });
5
+ const args = process.argv.slice(2);
6
+ const startedAt = Date.now();
7
+
8
+ runCli(args)
9
+ .then(async () => {
10
+ // exitCode 2 (audit findings / create-gate) or 1 (no value) = "partial":
11
+ // the command ran but flagged something. No exitCode = clean success.
12
+ const status = process.exitCode && process.exitCode !== 0 ? "partial" : "success";
13
+ await flushRunReport(args, status, startedAt);
14
+ })
15
+ .catch(async (error) => {
16
+ const message = error instanceof Error ? error.message : String(error);
17
+ await flushRunReport(args, "error", startedAt, message);
18
+ console.error(message);
19
+ process.exitCode = 1;
20
+ });
package/src/cli.ts CHANGED
@@ -135,6 +135,8 @@ import {
135
135
  type Prospect,
136
136
  } from "./connectors/prospectSources.ts";
137
137
  import { loadSeen, recordSeen } from "./acquireSeen.ts";
138
+ import { reportCounts, reportEvent } from "./runReport.ts";
139
+ import { createLinkedInProvider, discoverLinkedInProspects } from "./acquireLinkedIn.ts";
138
140
  import {
139
141
  fitThreshold,
140
142
  icpFromAnswers,
@@ -197,7 +199,7 @@ Usage:
197
199
  fullstackgtm login salesforce --instance-url <url> [--no-validate]
198
200
  fullstackgtm login stripe [--no-validate]
199
201
  fullstackgtm login anthropic | openai store an LLM API key for call parse/score
200
- fullstackgtm login apollo store an Apollo API key for enrich pulls\n fullstackgtm login pipe0 | explorium store a discovery-provider key for enrich acquire\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|broker>
202
+ fullstackgtm login apollo store an Apollo API key for enrich pulls\n fullstackgtm login pipe0 | explorium store a discovery-provider key for enrich acquire\n fullstackgtm login heyreach store a HeyReach key for enrich acquire --source linkedin\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|heyreach|broker>
201
203
 
202
204
  Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
203
205
  the process list and shell history. Pipe them on stdin or enter them at the
@@ -272,9 +274,10 @@ Usage:
272
274
  deterministic survivor (richest = most populated data
273
275
  fields, ties to lowest id; oldest = lowest id). Approve and
274
276
  apply like any plan; merges are IRREVERSIBLE on apply.
275
- fullstackgtm reassign --from <ownerId> --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--except-deal-stage <stage>] [--include-closed-deals] [source options] [--save] [--json] [--out <path>]
277
+ fullstackgtm reassign (--from <ownerId> | --assign-unowned) --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--except-deal-stage <stage>] [--include-closed-deals] [source options] [--save] [--json] [--out <path>]
276
278
  ownership handoff playbook: one bulk-update-style plan per
277
- object type (ownerId=<from> → <to>). Extra --where scoping
279
+ object type (ownerId=<from> → <to>). --assign-unowned instead
280
+ claims every ownerless record (ownerId:empty) for --to. Extra --where scoping
278
281
  is account-lifted for deals/contacts (domain~.de becomes
279
282
  account.domain~.de); --except-deal-stage <stage> excludes
280
283
  deals in that stage AND every record whose account has an
@@ -530,7 +533,7 @@ const HELP: Record<string, HelpEntry> = {
530
533
  reassign: {
531
534
  summary: "ownership handoff: one plan per object type",
532
535
  phase: "Remediate",
533
- synopsis: ["fullstackgtm reassign --from <ownerId> --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--save] [--json]"],
536
+ synopsis: ["fullstackgtm reassign (--from <ownerId> | --assign-unowned) --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--save] [--json]"],
534
537
  detail:
535
538
  "Extra `--where` scoping is account-lifted for deals/contacts. `--except-deal-stage <stage>` excludes that stage and any record whose account has an open deal in it, re-verified per record at apply.",
536
539
  seeAlso: ["bulk-update", "plans", "apply"],
@@ -929,6 +932,11 @@ async function audit(args: string[]) {
929
932
  if (staleDealDays !== undefined) policy.staleDealDays = staleDealDays;
930
933
 
931
934
  const plan = auditSnapshot(snapshot, policy, rules);
935
+ reportCounts({
936
+ findings: plan.findings.length,
937
+ critical: plan.findings.filter((f) => f.severity === "critical").length,
938
+ warning: plan.findings.filter((f) => f.severity === "warning").length,
939
+ });
932
940
  const out = option(args, "--out");
933
941
  if (out) {
934
942
  writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(plan, null, 2)}\n`);
@@ -1877,7 +1885,7 @@ enrich append [--source apollo] [--objects companies,contacts] [--save] [--conf
1877
1885
  enrich refresh [--source apollo] [--stale-days <n>] [--save] [--config <path>]
1878
1886
  [source options] [--run-label <label>] [--json]
1879
1887
  enrich ingest <file.csv|payload.json> --source clay [--run-label <label>] [--objects companies|contacts] [--config <path>]
1880
- enrich acquire [--source <id>] [--max <n>] [--save] [--config <path>] [--json]
1888
+ enrich acquire [--source <id>] [--max <n>] [--list <id>] [--assign-owner <id>] [--save] [--config <path>] [--json]
1881
1889
  enrich status [--runs] [--source <id>] [--config <path>] [--json]
1882
1890
 
1883
1891
  acquire creates NET-NEW leads from a staged prospect list (ingest first):
@@ -1887,6 +1895,16 @@ it dedupes each sourced row against the CRM, skips matches and ambiguities
1887
1895
  remaining budget (records + spend, per day and per month; whichever is hit
1888
1896
  first). Approval-gated like every write: \`--save\` → plans approve → apply.
1889
1897
  The meter is charged only when a create actually lands at apply.
1898
+ Discovery sources (zero-config presets): explorium | pipe0 (ICP-driven search),
1899
+ and linkedin (reads a HeyReach lead list — pass --list <id>, key on the LinkedIn
1900
+ URL, $0/record; \`login heyreach\` or HEYREACH_API_KEY).
1901
+
1902
+ Leads are never born ownerless: set an \`acquire.assign\` policy (fixed /
1903
+ round-robin / territory / account-owner) to stamp an owner at create time, or
1904
+ pass \`--assign-owner <id>\`. With a single-owner portal and no policy, acquire
1905
+ defaults every lead to that owner; with several owners and no policy it warns
1906
+ and leaves them unassigned. Backfill existing ownerless records with
1907
+ \`reassign --assign-unowned --to <ownerId>\`.
1890
1908
 
1891
1909
  append pulls from an api source (Apollo — BYO key via \`login apollo\` or
1892
1910
  APOLLO_API_KEY) or reads data staged by \`enrich ingest\` (Clay CSV exports,
@@ -2021,6 +2039,34 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
2021
2039
  let cap = headroom.maxRecords;
2022
2040
  if (explicitMax !== undefined) cap = cap === null ? explicitMax : Math.min(cap, explicitMax);
2023
2041
 
2042
+ // Assignment: never create an ownerless lead. An explicit `acquire.assign`
2043
+ // policy wins; a `--assign-owner <id>` flag is a quick fixed override;
2044
+ // otherwise, when the portal has exactly one active owner, default every
2045
+ // lead to them. With multiple owners and no policy we refuse to guess —
2046
+ // leads are left unassigned and the operator is told to configure a rule.
2047
+ const assignOwnerFlag = option(rest, "--assign-owner");
2048
+ if (!config.acquire.assign) {
2049
+ if (assignOwnerFlag) {
2050
+ config.acquire.assign = { strategy: "fixed", ownerId: assignOwnerFlag };
2051
+ } else {
2052
+ const activeOwners = (snapshot.users ?? []).filter((u) => u.active !== false);
2053
+ if (activeOwners.length === 1) {
2054
+ const sole = activeOwners[0].crmId ?? activeOwners[0].id;
2055
+ config.acquire.assign = { strategy: "fixed", ownerId: sole };
2056
+ console.error(
2057
+ `Assignment: no policy set — defaulting every lead to the portal's sole owner ` +
2058
+ `${activeOwners[0].name} (${sole}). Configure "acquire.assign" to route across reps.`,
2059
+ );
2060
+ } else if (activeOwners.length > 1) {
2061
+ console.error(
2062
+ `⚠ Assignment: ${activeOwners.length} active owners and no "acquire.assign" policy — ` +
2063
+ `leads will be created OWNERLESS. Add an acquire.assign rule (fixed / round-robin / territory) ` +
2064
+ `or pass --assign-owner <id> so every lead lands with an owner.`,
2065
+ );
2066
+ }
2067
+ }
2068
+ }
2069
+
2024
2070
  const result = buildAcquirePlan({
2025
2071
  config,
2026
2072
  source,
@@ -2030,6 +2076,23 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
2030
2076
  maxRecords: cap,
2031
2077
  });
2032
2078
 
2079
+ if (result.counts.unassigned > 0 && result.counts.created > 0) {
2080
+ console.error(
2081
+ `⚠ ${result.counts.unassigned}/${result.counts.created} proposed lead(s) have no owner ` +
2082
+ `(policy could not place them). They will be created ownerless.`,
2083
+ );
2084
+ }
2085
+
2086
+ // Observability: headline metrics for the web run timeline (paired users).
2087
+ reportCounts({
2088
+ sourced: result.counts.fetched,
2089
+ created: result.counts.created,
2090
+ withheldByMeter: result.counts.withheldByMeter,
2091
+ skippedInCrm: apiSkippedCrm,
2092
+ skippedSeen: apiSkippedSeen,
2093
+ estCostUsd: result.estCostUsd,
2094
+ });
2095
+
2033
2096
  const meterLine = formatAcquireMeter(headroom, costPerRecord);
2034
2097
  if (!save) {
2035
2098
  if (rest.includes("--json")) {
@@ -2053,6 +2116,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
2053
2116
  if (result.plan.operations.length > 0) {
2054
2117
  await createFilePlanStore().save(result.plan);
2055
2118
  planIds.push(result.plan.id);
2119
+ reportEvent("plan_saved", result.plan.id);
2056
2120
  }
2057
2121
  await store.update({
2058
2122
  ...run,
@@ -2178,6 +2242,13 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
2178
2242
  result.counts.fetched += missCount;
2179
2243
  result.counts.unmatched += missCount;
2180
2244
 
2245
+ reportCounts({
2246
+ fetched: result.counts.fetched,
2247
+ matched: result.counts.matched,
2248
+ unmatched: result.counts.unmatched,
2249
+ opsEmitted: result.counts.opsEmitted,
2250
+ });
2251
+
2181
2252
  if (!save) {
2182
2253
  if (rest.includes("--json")) {
2183
2254
  console.log(JSON.stringify(result.plan, null, 2));
@@ -2227,8 +2298,9 @@ function formatEnrichCounts(counts: EnrichCounts, ambiguities: number) {
2227
2298
 
2228
2299
  /** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
2229
2300
  /** Provider API key: env override first, then the credential store (`login`). */
2230
- function providerKey(provider: "explorium" | "pipe0"): string {
2231
- const envName = provider === "explorium" ? "EXPLORIUM_API_KEY" : "PIPE0_API_KEY";
2301
+ function providerKey(provider: "explorium" | "pipe0" | "heyreach"): string {
2302
+ const envName =
2303
+ provider === "explorium" ? "EXPLORIUM_API_KEY" : provider === "pipe0" ? "PIPE0_API_KEY" : "HEYREACH_API_KEY";
2232
2304
  if (process.env[envName]) return process.env[envName] as string;
2233
2305
  const stored = getCredential(provider);
2234
2306
  if (stored) return stored.accessToken;
@@ -2347,8 +2419,19 @@ async function acquireFromApi(
2347
2419
  } else if (disc.provider === "pipe0") {
2348
2420
  const filters = icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {});
2349
2421
  prospects = await fetchPipe0CrustdataProspects({ apiKey: providerKey("pipe0"), filters, limit: size });
2422
+ } else if (disc.provider === "linkedin" || disc.provider === "heyreach") {
2423
+ // LinkedIn reads a pre-populated lead list (not an ICP-driven query); the ICP
2424
+ // scores the pulled list below. List id: disc.listId or --list <id>.
2425
+ const listId = disc.listId ?? option(rest, "--list") ?? undefined;
2426
+ if (!listId) {
2427
+ throw new Error(
2428
+ "enrich acquire --source linkedin needs a HeyReach lead-list id: pass --list <id> or set acquire.discovery.linkedin.listId.",
2429
+ );
2430
+ }
2431
+ const provider = createLinkedInProvider(disc.provider, providerKey("heyreach"));
2432
+ prospects = await discoverLinkedInProspects(provider, { sourceId: listId, max: size });
2350
2433
  } else {
2351
- throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0).`);
2434
+ throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0 | linkedin).`);
2352
2435
  }
2353
2436
 
2354
2437
  // 2. ICP fit scoring BEFORE paying for emails — only above-threshold prospects
@@ -3213,9 +3296,10 @@ async function dedupeCommand(args: string[]) {
3213
3296
  async function reassignCommand(args: string[]) {
3214
3297
  const from = option(args, "--from");
3215
3298
  const to = option(args, "--to");
3216
- if (!from || !to) {
3299
+ const assignUnowned = args.includes("--assign-unowned");
3300
+ if (!to || (!from && !assignUnowned)) {
3217
3301
  throw new Error(
3218
- "Usage: fullstackgtm reassign --from <ownerId> --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--except-deal-stage <stage>] [--include-closed-deals] [source options] [--reason <text>] [--max-operations <n>] [--save] [--out <path>] [--json]",
3302
+ "Usage: fullstackgtm reassign (--from <ownerId> | --assign-unowned) --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--except-deal-stage <stage>] [--include-closed-deals] [source options] [--reason <text>] [--max-operations <n>] [--save] [--out <path>] [--json]\n\n--assign-unowned claims every OWNERLESS record (ownerId:empty) for --to — the backfill twin of `enrich acquire`'s create-time assignment.",
3219
3303
  );
3220
3304
  }
3221
3305
  const objects = option(args, "--objects")
@@ -3224,8 +3308,9 @@ async function reassignCommand(args: string[]) {
3224
3308
  .filter(Boolean) as ReassignObjectType[] | undefined;
3225
3309
  const snapshot = await readSnapshot(args);
3226
3310
  const plans = buildReassignPlans(snapshot, {
3227
- fromOwnerId: from,
3311
+ fromOwnerId: from ?? undefined,
3228
3312
  toOwnerId: to,
3313
+ assignUnowned,
3229
3314
  objects,
3230
3315
  where: repeatedOption(args, "--where"),
3231
3316
  exceptDealStage: option(args, "--except-deal-stage") ?? undefined,
@@ -3601,6 +3686,11 @@ async function apply(args: string[]) {
3601
3686
  }
3602
3687
  }
3603
3688
 
3689
+ // Observability: apply-outcome tallies for the web run timeline.
3690
+ const applyTally: Record<string, number> = { applied: 0, conflict: 0, skipped: 0, failed: 0 };
3691
+ for (const result of run.results) applyTally[result.status] = (applyTally[result.status] ?? 0) + 1;
3692
+ reportCounts(applyTally);
3693
+
3604
3694
  if (args.includes("--json")) {
3605
3695
  console.log(JSON.stringify(run, null, 2));
3606
3696
  } else {
@@ -4097,7 +4187,7 @@ async function login(args: string[]) {
4097
4187
  console.log(`Stored Apollo API key in ${credentialsPath()}. \`fullstackgtm enrich append|refresh\` use it automatically.`);
4098
4188
  return;
4099
4189
  }
4100
- if (provider === "pipe0" || provider === "explorium") {
4190
+ if (provider === "pipe0" || provider === "explorium" || provider === "heyreach") {
4101
4191
  rejectArgvSecret(args, "--token", "--key", "--api-key");
4102
4192
  const key = await readSecret(`${provider} API key`);
4103
4193
  if (!key) throw new Error(`No ${provider} key provided.`);
@@ -608,6 +608,11 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
608
608
  }
609
609
 
610
610
  const properties: Record<string, string> = { ...payload.properties };
611
+ // Stamp ownership at create time so the lead is never born ownerless. The
612
+ // canonical ownerId maps to HubSpot's standard owner property.
613
+ if (payload.ownerId && !properties.hubspot_owner_id) {
614
+ properties.hubspot_owner_id = String(payload.ownerId);
615
+ }
611
616
  let created;
612
617
  try {
613
618
  created = await request(`/crm/v3/objects/${objectPath}`, {