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.
package/dist/enrich.js CHANGED
@@ -2,13 +2,14 @@ import { mkdirSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.js";
4
4
  import { HUBSPOT_DEFAULT_FIELD_MAPPINGS } from "./mappings.js";
5
+ import { parseAssignmentPolicy, resolveAssignment } from "./assign.js";
5
6
  export const ENRICH_CONFIG_FILE_NAME = "enrich.config.json";
6
7
  export const DEFAULT_STALE_DAYS = 90;
7
8
  const OBJECT_TYPES = ["company", "contact"];
8
9
  /** Match keys the matcher knows how to read off canonical snapshot records. */
9
10
  const MATCH_KEYS = {
10
11
  company: ["domain", "name"],
11
- contact: ["email", "name"],
12
+ contact: ["email", "name", "linkedin"],
12
13
  };
13
14
  /** API source ids the MVP can pull from. */
14
15
  export const SUPPORTED_API_SOURCES = ["apollo", "explorium", "pipe0"];
@@ -178,6 +179,16 @@ export function parseEnrichConfig(raw) {
178
179
  if (!anyField && !hasAcquireCreate) {
179
180
  fail('"fields" maps nothing — add at least one field entry (or an "acquire.create" mapping)');
180
181
  }
182
+ // Normalize + validate the assignment policy (fails loud on shape errors so a
183
+ // misconfigured rule never silently mis-routes a created lead).
184
+ if (config.acquire?.assign !== undefined) {
185
+ try {
186
+ config.acquire.assign = parseAssignmentPolicy(config.acquire.assign);
187
+ }
188
+ catch (error) {
189
+ fail(error instanceof Error ? error.message : String(error));
190
+ }
191
+ }
181
192
  return config;
182
193
  }
183
194
  /**
@@ -212,6 +223,35 @@ export function builtinEnrichPreset(source) {
212
223
  */
213
224
  export function builtinAcquirePreset(source) {
214
225
  const provider = source ?? "pipe0";
226
+ if (provider === "linkedin" || provider === "heyreach") {
227
+ // LinkedIn discovery reads our OWN HeyReach lead list, so it costs $0/record
228
+ // (no spend budget) and keys on the LinkedIn URL — leads are sparse on email,
229
+ // so email-keying would drop most of them. listId is supplied via --list.
230
+ return {
231
+ sources: { linkedin: { kind: "api" } },
232
+ match: { contact: { keys: ["linkedin", "email"], onAmbiguous: "skip" } },
233
+ fields: {},
234
+ policy: { overwrite: "never" },
235
+ acquire: {
236
+ budget: { records: { perDay: 50, perMonth: 500 } },
237
+ costPerRecord: { linkedin: 0 },
238
+ discovery: { linkedin: { provider: "linkedin", size: 25 } },
239
+ create: {
240
+ contact: {
241
+ matchKey: "linkedin",
242
+ properties: {
243
+ hs_linkedin_url: "linkedin",
244
+ firstname: "firstName",
245
+ lastname: "lastName",
246
+ jobtitle: "jobTitle",
247
+ company: "companyName",
248
+ email: "email",
249
+ },
250
+ },
251
+ },
252
+ },
253
+ };
254
+ }
215
255
  if (provider !== "pipe0" && provider !== "explorium")
216
256
  return undefined;
217
257
  return {
@@ -387,6 +427,10 @@ function normalizeKeyValue(key, value) {
387
427
  .replace(/^www\./, "")
388
428
  .replace(/\/.*$/, "");
389
429
  }
430
+ if (key === "linkedin") {
431
+ // Match crmContactKeys' `li:` normalization (lowercased, trailing slash stripped).
432
+ return text.trim().replace(/\/+$/, "");
433
+ }
390
434
  return text.replace(/\s+/g, " ");
391
435
  }
392
436
  function crmKeyValue(objectType, record, key) {
@@ -399,6 +443,8 @@ function crmKeyValue(objectType, record, key) {
399
443
  }
400
444
  if (key === "email")
401
445
  return normalizeKeyValue("email", record.email);
446
+ if (key === "linkedin")
447
+ return normalizeKeyValue("linkedin", record.linkedin);
402
448
  if (key === "name") {
403
449
  return normalizeKeyValue("name", `${record.firstName ?? ""} ${record.lastName ?? ""}`.trim());
404
450
  }
@@ -652,6 +698,45 @@ export function buildEnrichPlan(options) {
652
698
  };
653
699
  return { plan, counts, stamps, ambiguities, unmatchedSourceIds };
654
700
  }
701
+ /** First non-empty value among several candidate paths in a source payload. */
702
+ function firstSourceValue(payload, paths) {
703
+ for (const path of paths) {
704
+ const value = sourceValueAt(payload, path);
705
+ if (!isEmptyValue(value))
706
+ return String(value).trim();
707
+ }
708
+ return undefined;
709
+ }
710
+ /**
711
+ * Lift a prospect/source payload into the routing attributes a territory rule
712
+ * reads. Tolerant of provider spellings (explorium vs pipe0); absent attributes
713
+ * stay undefined, so a rule that routes on them simply won't match (falling to
714
+ * the policy fallback) instead of mis-routing.
715
+ */
716
+ export function acquireAssignmentContext(payload, companyName, accountOwnerByName) {
717
+ const ctx = {};
718
+ const title = firstSourceValue(payload, ["jobTitle", "job_title", "title"]);
719
+ if (title)
720
+ ctx.title = title.toLowerCase();
721
+ const geo = firstSourceValue(payload, ["geo", "country", "company_country_code", "companyCountry"]);
722
+ if (geo)
723
+ ctx.geo = geo.toLowerCase();
724
+ const industry = firstSourceValue(payload, ["industry", "company_industry", "companyIndustry"]);
725
+ if (industry)
726
+ ctx.industry = industry.toLowerCase();
727
+ const employeeBand = firstSourceValue(payload, ["employeeBand", "company_size", "companySize"]);
728
+ if (employeeBand)
729
+ ctx.employeeBand = employeeBand;
730
+ const department = firstSourceValue(payload, ["department", "job_department", "jobDepartment"]);
731
+ if (department)
732
+ ctx.department = department.toLowerCase();
733
+ if (companyName) {
734
+ const owner = accountOwnerByName.get(companyName.trim().toLowerCase());
735
+ if (owner)
736
+ ctx.accountOwnerId = owner;
737
+ }
738
+ return ctx;
739
+ }
655
740
  /**
656
741
  * Match each sourced record against the snapshot and route it: matched =
657
742
  * already in the CRM (skip), ambiguous = a possible duplicate exists (skip —
@@ -680,8 +765,31 @@ export function buildAcquirePlan(options) {
680
765
  unmatched: 0,
681
766
  created: 0,
682
767
  withheldByMeter: 0,
768
+ assigned: 0,
769
+ unassigned: 0,
683
770
  };
684
771
  let estCostUsd = 0;
772
+ // Assignment context (built once): the set of owner ids the CRM will accept
773
+ // (active users), and a company-name → account-owner lookup for the
774
+ // "account-owner" inherit strategy. Empty when no assign policy is set.
775
+ const assignPolicy = acquire.assign;
776
+ const knownOwnerIds = new Set();
777
+ const accountOwnerByName = new Map();
778
+ if (assignPolicy) {
779
+ for (const user of snapshot.users ?? []) {
780
+ if (user.active === false)
781
+ continue;
782
+ if (user.crmId)
783
+ knownOwnerIds.add(user.crmId);
784
+ if (user.id)
785
+ knownOwnerIds.add(user.id);
786
+ }
787
+ for (const account of snapshot.accounts ?? []) {
788
+ if (account.name && account.ownerId) {
789
+ accountOwnerByName.set(account.name.trim().toLowerCase(), account.ownerId);
790
+ }
791
+ }
792
+ }
685
793
  for (const record of records) {
686
794
  const createMap = acquire.create[record.objectType];
687
795
  const match = config.match[record.objectType];
@@ -725,6 +833,19 @@ export function buildAcquirePlan(options) {
725
833
  }
726
834
  const recordEvidence = evidenceFor(source, sourceConfig.kind, sourceConfig.format, record, undefined, nowIso);
727
835
  evidence.push(recordEvidence);
836
+ // Resolve ownership BEFORE the meter-charged create lands, so the lead is
837
+ // never born ownerless. `counts.created` is the within-run index, giving
838
+ // round-robin deterministic, even distribution with no persisted cursor.
839
+ let ownerId;
840
+ let assignedBy;
841
+ if (assignPolicy) {
842
+ const ctx = acquireAssignmentContext(record.payload, associateCompanyName, accountOwnerByName);
843
+ const resolved = resolveAssignment(assignPolicy, ctx, counts.created, knownOwnerIds);
844
+ if (resolved.ownerId) {
845
+ ownerId = resolved.ownerId;
846
+ assignedBy = resolved.rule;
847
+ }
848
+ }
728
849
  const payload = {
729
850
  properties,
730
851
  matchKey: createMap.matchKey,
@@ -732,6 +853,7 @@ export function buildAcquirePlan(options) {
732
853
  source,
733
854
  estCostUsd: costPerRecord,
734
855
  associateCompanyName,
856
+ ...(ownerId ? { ownerId, assignedBy } : {}),
735
857
  };
736
858
  operations.push({
737
859
  id: `op_acq_${fnv1a(`${source}:${record.objectType}:${matchValue}`)}`,
@@ -748,6 +870,10 @@ export function buildAcquirePlan(options) {
748
870
  rollback: "Archive the created record (it was net-new).",
749
871
  evidenceIds: [recordEvidence.id],
750
872
  });
873
+ if (ownerId)
874
+ counts.assigned += 1;
875
+ else
876
+ counts.unassigned += 1;
751
877
  counts.created += 1;
752
878
  estCostUsd += costPerRecord;
753
879
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { auditSnapshot, defaultPolicy } from "./audit.ts";
2
+ export { matchesTerritory, parseAssignmentPolicy, resolveAssignment, type AssignmentContext, type AssignmentPolicy, type AssignmentResult, type AssignmentStrategy, type TerritoryRule, } from "./assign.ts";
2
3
  export { buildBulkUpdatePlan, isFilterableField, parseWhere, type BulkUpdateOptions } from "./bulkUpdate.ts";
3
4
  export { buildDedupePlan, dedupeKey, type DedupeOptions } from "./dedupe.ts";
4
5
  export { buildReassignPlans, type ReassignObjectType, type ReassignOptions } from "./reassign.ts";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { auditSnapshot, defaultPolicy } from "./audit.js";
2
+ export { matchesTerritory, parseAssignmentPolicy, resolveAssignment, } from "./assign.js";
2
3
  export { buildBulkUpdatePlan, isFilterableField, parseWhere } from "./bulkUpdate.js";
3
4
  export { buildDedupePlan, dedupeKey } from "./dedupe.js";
4
5
  export { buildReassignPlans } from "./reassign.js";
@@ -1,10 +1,19 @@
1
1
  import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
2
2
  export type ReassignObjectType = "account" | "contact" | "deal";
3
3
  export type ReassignOptions = {
4
- /** the owner whose records are being handed off */
5
- fromOwnerId: string;
4
+ /**
5
+ * the owner whose records are being handed off. Ignored (and not required)
6
+ * when `assignUnowned` is set — that mode targets ownerless records instead.
7
+ */
8
+ fromOwnerId?: string;
6
9
  /** the receiving owner — must be a known user in the snapshot */
7
10
  toOwnerId: string;
11
+ /**
12
+ * Backfill mode: instead of moving records from one owner to another, claim
13
+ * every OWNERLESS record (ownerId:empty) for `toOwnerId`. Shares the create-
14
+ * time assignment intent with `enrich acquire`, applied to existing debt.
15
+ */
16
+ assignUnowned?: boolean;
8
17
  /** which object types to compile plans for (default all three) */
9
18
  objects?: ReassignObjectType[];
10
19
  /** extra --where scoping, AND-ed into every plan (account fields lifted) */
package/dist/reassign.js CHANGED
@@ -40,11 +40,16 @@ function scopeWhere(objectType, raw) {
40
40
  return raw;
41
41
  }
42
42
  export function buildReassignPlans(snapshot, options) {
43
- if (!options.fromOwnerId || !options.toOwnerId) {
44
- throw new Error("reassign requires both --from <ownerId> and --to <ownerId>.");
43
+ if (!options.toOwnerId) {
44
+ throw new Error("reassign requires --to <ownerId>.");
45
45
  }
46
- if (options.fromOwnerId === options.toOwnerId) {
47
- throw new Error("reassign --from and --to are the same owner — nothing to hand off.");
46
+ if (!options.assignUnowned) {
47
+ if (!options.fromOwnerId) {
48
+ throw new Error("reassign requires --from <ownerId> (or --assign-unowned to claim ownerless records).");
49
+ }
50
+ if (options.fromOwnerId === options.toOwnerId) {
51
+ throw new Error("reassign --from and --to are the same owner — nothing to hand off.");
52
+ }
48
53
  }
49
54
  // The receiving owner must exist: a typo'd --to would otherwise write an
50
55
  // invalid owner onto every matched record.
@@ -60,7 +65,7 @@ export function buildReassignPlans(snapshot, options) {
60
65
  }
61
66
  }
62
67
  return objects.map((objectType) => {
63
- const where = [`ownerId=${options.fromOwnerId}`];
68
+ const where = [options.assignUnowned ? "ownerId:empty" : `ownerId=${options.fromOwnerId}`];
64
69
  if (objectType === "deal" && !options.includeClosedDeals) {
65
70
  where.push("isClosed=false"); // closed deals keep their historical owner
66
71
  }
@@ -80,7 +85,9 @@ export function buildReassignPlans(snapshot, options) {
80
85
  where,
81
86
  set: { ownerId: options.toOwnerId },
82
87
  reason: options.reason ??
83
- `reassign: hand off ${objectType}s from owner ${options.fromOwnerId} to ${options.toOwnerId}`,
88
+ (options.assignUnowned
89
+ ? `reassign: claim ownerless ${objectType}s for owner ${options.toOwnerId}`
90
+ : `reassign: hand off ${objectType}s from owner ${options.fromOwnerId} to ${options.toOwnerId}`),
84
91
  maxOperations: options.maxOperations,
85
92
  });
86
93
  });
@@ -0,0 +1,9 @@
1
+ /** A command annotates its headline metrics (merged). */
2
+ export declare function reportCounts(values: Record<string, unknown>): void;
3
+ /** A command annotates a structured event (plan saved, meter charged, …). */
4
+ export declare function reportEvent(type: string, detail?: string): void;
5
+ /**
6
+ * Send the run record if the CLI is paired. Best-effort: a 4s-capped POST that
7
+ * swallows every error. Call once per process from the entry point.
8
+ */
9
+ export declare function flushRunReport(args: string[], status: "success" | "partial" | "error", startedAt: number, error?: string): Promise<void>;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Opt-in CLI → hosted-app observability. When the user has paired the CLI
3
+ * (`login --via <url>` → broker token), each command fire-and-forgets a run
4
+ * record to the hosted app's `/api/cli/run`, authenticated by the broker token.
5
+ *
6
+ * Invariants: the CLI never requires web login; this does nothing when unpaired;
7
+ * it never throws and never changes the CLI's exit code or output. Reporting is
8
+ * pure added value for users who granted it.
9
+ */
10
+ import { getCredential } from "./credentials.js";
11
+ let counts;
12
+ const events = [];
13
+ /** A command annotates its headline metrics (merged). */
14
+ export function reportCounts(values) {
15
+ counts = { ...(counts ?? {}), ...values };
16
+ }
17
+ /** A command annotates a structured event (plan saved, meter charged, …). */
18
+ export function reportEvent(type, detail) {
19
+ events.push({ ts: Date.now(), type, detail });
20
+ }
21
+ // Setup/inspection verbs aren't interesting as "runs" — skip the noise.
22
+ const SKIP_COMMANDS = new Set([
23
+ "login",
24
+ "logout",
25
+ "doctor",
26
+ "help",
27
+ "profiles",
28
+ "--help",
29
+ "-h",
30
+ "--version",
31
+ "-v",
32
+ ]);
33
+ /**
34
+ * Send the run record if the CLI is paired. Best-effort: a 4s-capped POST that
35
+ * swallows every error. Call once per process from the entry point.
36
+ */
37
+ export async function flushRunReport(args, status, startedAt, error) {
38
+ const command = args[0];
39
+ if (!command || SKIP_COMMANDS.has(command))
40
+ return;
41
+ const broker = getCredential("broker");
42
+ if (!broker?.baseUrl || !broker.accessToken)
43
+ return; // opt-in: only when paired
44
+ const sub = args[1] && !args[1].startsWith("-") ? ` ${args[1]}` : "";
45
+ const finishedAt = Date.now();
46
+ try {
47
+ await fetch(`${broker.baseUrl.replace(/\/+$/, "")}/api/cli/run`, {
48
+ method: "POST",
49
+ headers: { Authorization: `Bearer ${broker.accessToken}`, "Content-Type": "application/json" },
50
+ body: JSON.stringify({
51
+ command: `${command}${sub}`,
52
+ status,
53
+ startedAt,
54
+ finishedAt,
55
+ durationMs: finishedAt - startedAt,
56
+ counts,
57
+ events: events.length ? events : undefined,
58
+ error,
59
+ }),
60
+ signal: AbortSignal.timeout(4000),
61
+ });
62
+ }
63
+ catch {
64
+ // Observability is best-effort; never affect the CLI outcome.
65
+ }
66
+ }
package/dist/types.d.ts CHANGED
@@ -24,6 +24,14 @@ export type CreateRecordPayload = {
24
24
  source: string;
25
25
  estCostUsd?: number;
26
26
  associateCompanyName?: string;
27
+ /**
28
+ * Owner to stamp on the new record (canonical owner id). The connector maps
29
+ * it to the CRM's owner property (HubSpot `hubspot_owner_id`) at create time
30
+ * so a lead is never born ownerless. Absent = leave unassigned.
31
+ */
32
+ ownerId?: string;
33
+ /** Audit label for how the owner was chosen (e.g. "fixed", "territory:0"). */
34
+ assignedBy?: string;
27
35
  };
28
36
  export type AuditFindingSeverity = "info" | "warning" | "critical";
29
37
  /**
package/docs/api.md CHANGED
@@ -102,7 +102,10 @@ emits a standard dry-run `PatchPlan` for the normal approve → apply chain:
102
102
  duplicate groups by normalized identity key, one `merge_records` per group,
103
103
  deterministic survivor selection (`richest` / `oldest`).
104
104
  - `buildReassignPlans(snapshot, options: ReassignOptions)` — one plan per
105
- `ReassignObjectType`, account-lifted scoping, stage exclusions.
105
+ `ReassignObjectType`, account-lifted scoping, stage exclusions. With
106
+ `assignUnowned` (CLI `--assign-unowned`) it targets ownerless records
107
+ (`ownerId:empty`) and claims them for `--to` — the backfill twin of
108
+ `enrich acquire`'s create-time assignment, for clearing existing ownerless debt.
106
109
 
107
110
  `fix` is CLI-only composition of existing surfaces (audit → suggest →
108
111
  approve → apply for one rule).
@@ -138,11 +141,31 @@ zero-config `EnrichConfig.acquire` (budget, provider, create-mapping) so
138
141
  waterfall, chunked, surfaces upstream errors), `fetchPipe0CrustdataProspects`
139
142
  (people search). `prospectIdentityKeys` / `crmContactKeys` /
140
143
  `partitionFreshProspects` power the pre-email dedup.
144
+ - **LinkedIn source** (`connectors/linkedin.ts`, `acquireLinkedIn.ts`): the
145
+ injectable `LinkedInProvider` interface with a default HeyReach adapter
146
+ (`createHeyReachProvider`, `HEYREACH_BASE`, `X-API-KEY`, `normalizeHeyReachLead`)
147
+ and `createFakeLinkedInProvider` for keyless/offline tests; `createLinkedInProvider`
148
+ selects the adapter (`heyreach` only; rejects unknown). `discoverLinkedInProspects`
149
+ pulls a HeyReach lead list and `linkedInProspectToProspect` maps each lead to the
150
+ canonical `Prospect` (match key = LinkedIn URL), so ICP scoring / dedup / meter /
151
+ apply are reused unchanged. Phase 1 is discovery-only — read-only, never sends.
141
152
  - **Meter** (`acquireMeter.ts`): `AcquireBudget`, `loadMeter` / `remaining` /
142
153
  `recordConsumption` — per-profile windowed record+spend budget, charged only
143
154
  for landed creates.
144
155
  - **Seen cache** (`acquireSeen.ts`): `loadSeen` / `recordSeen` — cross-run
145
156
  memory so a re-run never re-pays to enrich a known prospect.
157
+ - **Assignment** (`assign.ts`): `AssignmentPolicy` (`fixed` / `round-robin` /
158
+ `territory` / `account-owner`), `parseAssignmentPolicy`, pure
159
+ `resolveAssignment(policy, ctx, index, knownOwnerIds)`. Set
160
+ `EnrichConfig.acquire.assign` and `buildAcquirePlan` stamps `ownerId` into each
161
+ `create_record` payload so a **lead is never born ownerless** — the connector
162
+ maps it to HubSpot `hubspot_owner_id` at create time. Round-robin distributes
163
+ by the within-run index (deterministic, no persisted cursor); every result is
164
+ gated by the snapshot's active owners (an unknown/inactive owner collapses to
165
+ unassigned, never a bad write). The CLI defaults to the portal's sole active
166
+ owner when no policy is set (or `--assign-owner <id>`); with multiple owners
167
+ and no policy it warns and leaves leads unassigned rather than guess. The same
168
+ policy backfills existing debt via `reassign --assign-unowned`.
146
169
 
147
170
  `CanonicalContact.linkedin` (mapped from HubSpot `hs_linkedin_url`) is the
148
171
  strong dedup key across all of the above.
@@ -78,6 +78,9 @@ provider ──fetchSnapshot()──► CanonicalGtmSnapshot
78
78
  translation, prospect fit scoring, and the agent-driven interview spec.
79
79
  - `acquireMeter.ts` — per-profile windowed budget (records + spend) for acquire.
80
80
  - `acquireSeen.ts` — cross-run "seen" cache so re-runs don't re-pay for dupes.
81
+ - `assign.ts` — `AssignmentPolicy` (fixed / round-robin / territory /
82
+ account-owner): the pure owner-routing rule `buildAcquirePlan` stamps onto new
83
+ leads (never born ownerless) and `reassign --assign-unowned` reuses to backfill.
81
84
  - `connectors/prospectSources.ts` — API prospect sources (Explorium discovery,
82
85
  pipe0 work-email waterfall, pipe0/Crustdata search) + the pre-email dedup keys.
83
86
 
@@ -0,0 +1,87 @@
1
+ # `linkedin` connector — spec (Phase 1)
2
+
3
+ Status: scoped 2026-06-20. Decisions locked: **first slice = Phase 1 discovery,
4
+ dry-run; default provider = HeyReach.** Rationale + the full white-label
5
+ investigation that motivated owning this: see the LinkedIn connector research.
6
+
7
+ ## Why this shape
8
+
9
+ The research verdict (≈85%): Gojiberry runs its **own** LinkedIn session-automation
10
+ stack — it is **not** white-labeling HeyReach/Unipile, so there is no upstream to
11
+ go "direct" to. But Gojiberry proves a small team can build the architecture, and
12
+ the *execution* layer is rentable. So FSGTM's play is: **rent execution
13
+ (HeyReach), own the targeting + scoring + plan/apply governance** — "governed
14
+ LinkedIn outbound," which nobody else has.
15
+
16
+ This does **not** rebuild anything. The in-flight `enrich acquire` pipeline
17
+ already is the spine:
18
+
19
+ ```
20
+ Icp → discovery source (Explorium/pipe0/Clay) → scoreProspectAgainstIcp
21
+ → partitionFreshProspects (dedup vs CRM + acquireSeen) → create_record op
22
+ → acquireMeter budget cap → approve → apply
23
+ ```
24
+
25
+ LinkedIn is just a **new discovery source** on that spine.
26
+
27
+ ## Phase 1 deliverable: `enrich acquire --source linkedin` (dry-run)
28
+
29
+ ICP → LinkedIn people-search via HeyReach → `Prospect[]` → ICP-scored → deduped →
30
+ **dry-run `create_record` plan** (no `--save` ⇒ nothing written, zero send/ToS
31
+ exposure). Net-new code is small; scoring/dedup/metering/apply are reused.
32
+
33
+ ### Components
34
+ - **`src/connectors/linkedin.ts`** (mirrors `prospectSources.ts`):
35
+ - `icpToLinkedInFilters(icp)` — `Icp` → LinkedIn/Sales-Nav search params (mirror
36
+ of `icpToExploriumFilters` / `icpToCrustdataFilters` in `icp.ts`).
37
+ - `discoverProspects(provider, filters, max)` — search mode for Phase 1; returns
38
+ the existing `Prospect` type so everything downstream is free.
39
+ - **`LinkedInProvider` interface** (injectable, like `CrontabIo`): default
40
+ **HeyReach** adapter (`searchLeads` / lead-list pull), plus a **`FakeLinkedInProvider`**
41
+ for tests so the whole pipeline runs with no key and no network.
42
+ - **`builtinAcquirePreset('linkedin')`** — zero-config source preset (match-key
43
+ email/linkedinUrl; cost-per-record for the meter).
44
+ - **Wiring**: register `linkedin` in the `enrich acquire` source switch; add to
45
+ the source allowlist + help; `login heyreach` (or `HEYREACH_API_KEY`) for the key.
46
+
47
+ ### Reused as-is (no change)
48
+ `scoreProspectAgainstIcp`, `partitionFreshProspects`, `acquireSeen`,
49
+ `acquireMeter` (records/day+month, cost/record), the `create_record` op + apply
50
+ path, the dry-run→approve→apply gate.
51
+
52
+ ## Out of scope for Phase 1 (explicit)
53
+ - **No sending.** `linkedin_connect` / `linkedin_message` operations, the
54
+ per-sender safety meter, and HeyReach webhook ingestion are **Phase 2**.
55
+ - Engagement-signal capture (Gojiberry-style intent from who-engaged-our-posts)
56
+ is a Phase 1.5 add to `discoverProspects` once search mode is trusted.
57
+ - Not our own automation engine; not the official LinkedIn API; not multi-channel.
58
+
59
+ ## Provider: HeyReach
60
+ Behind `LinkedInProvider` so we are not locked in (Unipile = documented
61
+ alternative: cheaper raw search/profile, multi-channel). HeyReach chosen for the
62
+ full Campaign API + webhooks + sender rotation + built-in per-account caps (the
63
+ Phase 2 ban-safety primitives we will want). ToS/ban risk sits on the connected
64
+ LinkedIn account — same posture as Gojiberry today.
65
+
66
+ ## Build sequencing (IMPORTANT)
67
+ The acquire spine (`icp.ts`, `prospectSources.ts`, `acquireMeter.ts`,
68
+ `acquireSeen.ts`) is **uncommitted, actively-edited working-tree code** (not on
69
+ main). Building the acquire-source wiring on top of it is expansion on an
70
+ unstable base. Therefore:
71
+
72
+ 1. **Buildable now (self-contained, no dep on the uncommitted acquire internals):**
73
+ `LinkedInProvider` interface + HeyReach adapter (coded to HeyReach's documented
74
+ API) + `FakeLinkedInProvider` + `icpToLinkedInFilters` + unit tests against the
75
+ fake.
76
+ 2. **After the acquire workstream commits/lands:** wire `enrich acquire --source
77
+ linkedin` end-to-end and add the dry-run integration test.
78
+ 3. **Live validation** needs a HeyReach trial API key (Ryan provides) — the fake
79
+ covers everything up to the real network call.
80
+
81
+ ## Phase 2 (future, recorded for continuity)
82
+ Governed outbound: `linkedin_connect`/`linkedin_message` as `PatchOperation`s
83
+ (objectId = contact, afterValue = body, approvalRequired, `groupId` = sequence
84
+ all-or-nothing, `preconditions` = not-already-connected); `applyOperation`
85
+ dispatches `linkedin_*` to the provider; per-sender-account daily caps via the
86
+ `acquireMeter` pattern (ban-safety encoded as governance); HeyReach webhooks →
87
+ CRM activity + intent signal. This is the differentiated surface.
package/llms.txt CHANGED
@@ -76,7 +76,10 @@ identity key — merge with `dedupe` instead. `dedupe <object> --key
76
76
  deterministic survivor (`--keep richest|oldest`); merges are irreversible and
77
77
  stay low-confidence-capped at approval. `reassign --from <owner> --to
78
78
  <owner>` = ownership handoff plans per object type; `--except-deal-stage`
79
- also excludes records whose account has an open deal in that stage. `fix
79
+ also excludes records whose account has an open deal in that stage;
80
+ `reassign --assign-unowned --to <id>` instead claims every ownerless record
81
+ (`ownerId:empty`) for an owner — the backfill twin of acquire's create-time
82
+ assignment. `fix
80
83
  --rule <id>` = audit one rule → suggest → approve at the confidence bar →
81
84
  apply only with `--yes`. All four produce plans; none writes outside
82
85
  approve → apply.
@@ -95,6 +98,24 @@ where the source value changed (beforeValue = current CRM value → apply-time
95
98
  CAS). Conflict policy MVP is `never`; `system-only`/`always` are phase 2 and
96
99
  refused explicitly. Run store (checkpoint + staleness ledger + `status`) is
97
100
  profile-scoped under `<home>/enrich/runs`. No cron — scheduling is horizontal.
101
+ `acquire` is the one enrich verb that creates net-new records instead of
102
+ filling blanks: it discovers ICP-targeted people, resolves work email, and
103
+ proposes governed `create_record` ops through the same dry-run → approve →
104
+ apply gate — resolve-first dedupe re-checks the key at apply time so it never
105
+ double-creates. A per-profile windowed meter (`acquireMeter.ts`) caps both
106
+ record count and provider spend (per-day + per-month, whichever binds first);
107
+ sources come from staged ingest lists. Acquired leads are never born ownerless:
108
+ an `acquire.assign` policy (`fixed`/`round-robin`/`territory`/`account-owner`,
109
+ or `--assign-owner <id>`) stamps an owner into every `create_record`, gated by
110
+ the snapshot's active owners (unknown/inactive owner → unassigned, never a bad
111
+ owner); with multiple owners and no policy it warns and leaves leads unassigned.
112
+ Discovery sources are presets behind `--source`: `explorium`/`pipe0` (ICP-driven
113
+ people search) and `linkedin` (Phase 1, discovery + dry-run) which reads a
114
+ pre-built HeyReach lead list (`--list <id>` or `acquire.discovery.linkedin.listId`,
115
+ match key = LinkedIn URL, $0/record, `login heyreach`/`HEYREACH_API_KEY`) via the
116
+ injectable `LinkedInProvider` (default HeyReach; `FakeLinkedInProvider` for tests).
117
+ LinkedIn is read-only — Phase 1 has no `linkedin_connect`/`linkedin_message` ops
118
+ and no webhook ingestion (Phase 2). Never auto-writes.
98
119
 
99
120
  ## Key invariants (schedule)
100
121
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
@@ -58,10 +58,11 @@ credentials AND stored plans per client org.
58
58
  | `resolve <contact\|account\|deal>` | Create gate: exit 0 = safe to create, 2 = exists/ambiguous — never blind-create |
59
59
  | `dedupe <object> --key <domain\|email\|name>` | One merge op per duplicate group, deterministic survivor; merges are irreversible |
60
60
  | `bulk-update <object> --where … --set\|--archive\|--create-task` | Filtered dry-run plan; filter re-verified per record at apply time |
61
- | `reassign --from <owner> --to <owner>` | Ownership handoff plans per object type |
61
+ | `reassign --from <owner> --to <owner>` | Ownership handoff plans per object type; `--assign-unowned --to <id>` backfills every ownerless record |
62
62
  | `fix --rule <id>` | audit one rule → suggest → approve at the confidence bar → apply only with `--yes` |
63
63
  | `call parse\|score\|link\|plan` | Transcripts → evidence-quoted insights, rubric scorecards, deal linking, governed next-step writes |
64
64
  | `enrich append\|refresh\|ingest\|status` | Governed enrichment (Apollo pull / Clay ingest), fill-blanks-only plans |
65
+ | `enrich acquire [--source explorium\|pipe0\|linkedin]` | Net-new ICP-targeted lead gen: resolve-first deduped `create_record` plans, capped by a per-profile windowed meter (records + spend); owner-stamped via `acquire.assign`/`--assign-owner` (never born ownerless); `--source linkedin --list <id>` reads a HeyReach lead list (Phase 1 discovery, read-only — never sends); never auto-writes |
65
66
  | `market init\|capture\|classify\|worksheet\|observe\|fronts\|axes\|overlay\|scale\|report\|refresh` | Competitive category map; evidence quotes verified verbatim against stored captures |
66
67
  | `schedule add\|list\|remove\|enable\|disable\|run\|install\|uninstall\|status` | Horizontal cron; read/plan-side allowlist only — scheduling NEVER auto-approves |
67
68
  | `report` | Client-ready audit deliverable (markdown or self-contained HTML) |
@@ -85,6 +86,6 @@ Tools over stdio: `fullstackgtm_audit` (read-only), `fullstackgtm_rules`,
85
86
 
86
87
  ## Going deeper
87
88
 
88
- - [llms.txt](https://github.com/fullstackgtm/core/blob/main/llms.txt) — the full invariant map per layer (calls, market, write verbs, enrich, schedule)
89
+ - [llms.txt](https://github.com/fullstackgtm/core/blob/main/llms.txt) — the full invariant map per layer (calls, market, write verbs, enrich, schedule, engagement/health)
89
90
  - [INSTALL_FOR_AGENTS.md](https://github.com/fullstackgtm/core/blob/main/INSTALL_FOR_AGENTS.md) — deterministic install-and-verify with expected outputs
90
91
  - [docs/api.md](https://github.com/fullstackgtm/core/blob/main/docs/api.md) — semver-covered surfaces: canonical model, rule interface, plan/apply contract, connectors, config, CLI, MCP