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/CHANGELOG.md +50 -0
- package/INSTALL_FOR_AGENTS.md +8 -0
- package/README.md +14 -0
- package/dist/acquireLinkedIn.d.ts +41 -0
- package/dist/acquireLinkedIn.js +57 -0
- package/dist/assign.d.ts +83 -0
- package/dist/assign.js +146 -0
- package/dist/bin.js +14 -2
- package/dist/cli.js +91 -11
- package/dist/connectors/hubspot.js +5 -0
- package/dist/connectors/linkedin.d.ts +78 -0
- package/dist/connectors/linkedin.js +199 -0
- package/dist/enrich.d.ts +21 -1
- package/dist/enrich.js +127 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/reassign.d.ts +11 -2
- package/dist/reassign.js +13 -6
- package/dist/runReport.d.ts +9 -0
- package/dist/runReport.js +66 -0
- package/dist/types.d.ts +8 -0
- package/docs/api.md +24 -1
- package/docs/architecture.md +3 -0
- package/docs/linkedin-connector-spec.md +87 -0
- package/llms.txt +22 -1
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +3 -2
- package/src/acquireLinkedIn.ts +83 -0
- package/src/assign.ts +193 -0
- package/src/bin.ts +17 -4
- package/src/cli.ts +102 -12
- package/src/connectors/hubspot.ts +5 -0
- package/src/connectors/linkedin.ts +272 -0
- package/src/enrich.ts +137 -3
- package/src/index.ts +10 -0
- package/src/reassign.ts +24 -8
- package/src/runReport.ts +76 -0
- package/src/types.ts +8 -0
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";
|
package/dist/reassign.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
5
|
-
|
|
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.
|
|
44
|
-
throw new Error("reassign requires
|
|
43
|
+
if (!options.toOwnerId) {
|
|
44
|
+
throw new Error("reassign requires --to <ownerId>.");
|
|
45
45
|
}
|
|
46
|
-
if (options.
|
|
47
|
-
|
|
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
|
-
|
|
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.
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
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.
|
|
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
|