fullstackgtm 0.34.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/INSTALL_FOR_AGENTS.md +8 -0
  3. package/README.md +39 -0
  4. package/dist/acquireLinkedIn.d.ts +41 -0
  5. package/dist/acquireLinkedIn.js +57 -0
  6. package/dist/acquireMeter.d.ts +67 -0
  7. package/dist/acquireMeter.js +145 -0
  8. package/dist/acquireSeen.d.ts +5 -0
  9. package/dist/acquireSeen.js +54 -0
  10. package/dist/assign.d.ts +83 -0
  11. package/dist/assign.js +146 -0
  12. package/dist/bin.js +14 -2
  13. package/dist/cli.js +817 -26
  14. package/dist/connectors/hubspot.js +140 -0
  15. package/dist/connectors/linkedin.d.ts +78 -0
  16. package/dist/connectors/linkedin.js +199 -0
  17. package/dist/connectors/prospectSources.d.ts +91 -0
  18. package/dist/connectors/prospectSources.js +227 -0
  19. package/dist/enrich.d.ts +107 -0
  20. package/dist/enrich.js +315 -5
  21. package/dist/format.d.ts +3 -1
  22. package/dist/format.js +14 -2
  23. package/dist/health.d.ts +71 -0
  24. package/dist/health.js +172 -0
  25. package/dist/icp.d.ts +96 -0
  26. package/dist/icp.js +256 -0
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.js +2 -0
  29. package/dist/mappings.js +3 -0
  30. package/dist/reassign.d.ts +11 -2
  31. package/dist/reassign.js +13 -6
  32. package/dist/runReport.d.ts +9 -0
  33. package/dist/runReport.js +66 -0
  34. package/dist/types.d.ts +25 -1
  35. package/docs/api.md +53 -3
  36. package/docs/architecture.md +11 -1
  37. package/docs/dx-punch-list.md +87 -0
  38. package/docs/linkedin-connector-spec.md +87 -0
  39. package/llms.txt +38 -1
  40. package/package.json +1 -1
  41. package/skills/fullstackgtm/SKILL.md +5 -3
  42. package/src/acquireLinkedIn.ts +83 -0
  43. package/src/acquireMeter.ts +186 -0
  44. package/src/acquireSeen.ts +57 -0
  45. package/src/assign.ts +193 -0
  46. package/src/bin.ts +17 -4
  47. package/src/cli.ts +965 -25
  48. package/src/connectors/hubspot.ts +145 -0
  49. package/src/connectors/linkedin.ts +272 -0
  50. package/src/connectors/prospectSources.ts +324 -0
  51. package/src/enrich.ts +411 -5
  52. package/src/format.ts +20 -2
  53. package/src/health.ts +238 -0
  54. package/src/icp.ts +313 -0
  55. package/src/index.ts +18 -0
  56. package/src/mappings.ts +3 -0
  57. package/src/reassign.ts +24 -8
  58. package/src/runReport.ts +76 -0
  59. package/src/types.ts +32 -0
package/dist/icp.d.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * ICP — the Ideal Customer Profile that makes `enrich acquire` targeted instead
3
+ * of random. One structured artifact drives two things:
4
+ * 1. discovery filters — translated per provider (Explorium, pipe0/Crustdata)
5
+ * so the pull only returns ICP-shaped companies + people, and
6
+ * 2. fit scoring — every discovered prospect is scored against the persona so
7
+ * only above-threshold leads become create_record ops.
8
+ *
9
+ * Develop one via interview: an agent (Claude Code / Codex) reads INTERVIEW_SPEC,
10
+ * asks the questions with its AskUserQuestion tool, and writes the answers into
11
+ * an Icp (CLI: `icp interview` emits the spec, `icp show` renders the result).
12
+ *
13
+ * Zero runtime deps; pure functions (translation + scoring take plain data).
14
+ */
15
+ export type Icp = {
16
+ name: string;
17
+ firmographics: {
18
+ /** human industry labels, e.g. ["software","saas"] — used for keyword/industry filters */
19
+ industries?: string[];
20
+ /** Explorium naics_category codes, e.g. ["5112"] (software publishers) */
21
+ naics?: string[];
22
+ /** provider-agnostic employee bands: "1-10","11-50","51-200","201-500","501-1000","1001-5000","5001-10000","10001+" */
23
+ employeeBands?: string[];
24
+ /** ISO country codes, lowercased, e.g. ["us"] */
25
+ geos?: string[];
26
+ };
27
+ persona: {
28
+ /** seniority: "cxo","vp","director","manager","owner","senior" */
29
+ jobLevels?: string[];
30
+ /** "sales","marketing","operations","finance" */
31
+ departments?: string[];
32
+ /** the title phrases that DEFINE the buyer — also the scoring signal */
33
+ titleKeywords?: string[];
34
+ };
35
+ signals?: {
36
+ intentTopics?: string[];
37
+ };
38
+ scoring?: {
39
+ /** minimum fit (0..1) for a prospect to become a create_record op. Default 0.5. */
40
+ threshold?: number;
41
+ };
42
+ };
43
+ export declare const DEFAULT_FIT_THRESHOLD = 0.5;
44
+ export declare function parseIcp(raw: string): Icp;
45
+ /** Explorium /v1/prospects filters from the ICP. */
46
+ export declare function icpToExploriumFilters(icp: Icp): Record<string, {
47
+ values?: string[];
48
+ value?: boolean;
49
+ }>;
50
+ /**
51
+ * pipe0 Crustdata people-search config.filters from the ICP. `current_job_titles`
52
+ * matches real LinkedIn title strings (case-sensitive), so keywords are Title
53
+ * Cased. Seniority + industry are mapped to Crustdata's controlled vocab via the
54
+ * tables above. NOTE: the exact pipe0→Crustdata value set could not be re-run
55
+ * live (pipe0 credits were exhausted) — validate when credits refill; fit
56
+ * scoring is the safety net for persona precision regardless.
57
+ */
58
+ export declare function icpToCrustdataFilters(icp: Icp): Record<string, unknown>;
59
+ export type IcpFit = {
60
+ score: number;
61
+ reasons: string[];
62
+ };
63
+ /**
64
+ * Score a prospect's PERSONA fit 0..1. Title-keyword match is the strongest
65
+ * signal (it's what defines the buyer); job level and department add to it.
66
+ * Firmographics aren't re-scored here — discovery already filtered on them.
67
+ */
68
+ export declare function scoreProspectAgainstIcp(prospect: {
69
+ jobTitle?: string;
70
+ jobLevel?: string;
71
+ jobDepartment?: string;
72
+ }, icp: Icp): IcpFit;
73
+ export declare function fitThreshold(icp: Icp): number;
74
+ export type IcpInterviewQuestion = {
75
+ id: keyof FlatIcpAnswers;
76
+ header: string;
77
+ question: string;
78
+ multiSelect: boolean;
79
+ options: {
80
+ label: string;
81
+ value: string | string[];
82
+ description: string;
83
+ }[];
84
+ };
85
+ /** Flattened answer keys the interview collects (assembled into an Icp). */
86
+ export type FlatIcpAnswers = {
87
+ industries: string[];
88
+ employeeBands: string[];
89
+ jobLevels: string[];
90
+ departments: string[];
91
+ titleKeywords: string[];
92
+ geos: string[];
93
+ };
94
+ export declare const INTERVIEW_SPEC: IcpInterviewQuestion[];
95
+ /** Assemble an Icp from flattened interview answers. */
96
+ export declare function icpFromAnswers(name: string, answers: FlatIcpAnswers): Icp;
package/dist/icp.js ADDED
@@ -0,0 +1,256 @@
1
+ /**
2
+ * ICP — the Ideal Customer Profile that makes `enrich acquire` targeted instead
3
+ * of random. One structured artifact drives two things:
4
+ * 1. discovery filters — translated per provider (Explorium, pipe0/Crustdata)
5
+ * so the pull only returns ICP-shaped companies + people, and
6
+ * 2. fit scoring — every discovered prospect is scored against the persona so
7
+ * only above-threshold leads become create_record ops.
8
+ *
9
+ * Develop one via interview: an agent (Claude Code / Codex) reads INTERVIEW_SPEC,
10
+ * asks the questions with its AskUserQuestion tool, and writes the answers into
11
+ * an Icp (CLI: `icp interview` emits the spec, `icp show` renders the result).
12
+ *
13
+ * Zero runtime deps; pure functions (translation + scoring take plain data).
14
+ */
15
+ export const DEFAULT_FIT_THRESHOLD = 0.5;
16
+ const COUNTRY_NAMES = {
17
+ us: "United States",
18
+ ca: "Canada",
19
+ gb: "United Kingdom",
20
+ uk: "United Kingdom",
21
+ };
22
+ export function parseIcp(raw) {
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(raw);
26
+ }
27
+ catch (error) {
28
+ throw new Error(`icp: not valid JSON (${error instanceof Error ? error.message : String(error)})`);
29
+ }
30
+ if (!parsed || typeof parsed !== "object")
31
+ throw new Error("icp: expected a JSON object");
32
+ const icp = parsed;
33
+ if (!icp.name || typeof icp.name !== "string")
34
+ throw new Error('icp: missing "name"');
35
+ if (!icp.persona || (!icp.persona.titleKeywords?.length && !icp.persona.jobLevels?.length)) {
36
+ throw new Error('icp: "persona" needs at least titleKeywords or jobLevels (without them, scoring cannot rank fit)');
37
+ }
38
+ return icp;
39
+ }
40
+ // ---------------------------------------------------------------------------
41
+ // Discovery-filter translation
42
+ /** Explorium /v1/prospects filters from the ICP. */
43
+ export function icpToExploriumFilters(icp) {
44
+ const f = { has_email: { value: true } };
45
+ if (icp.persona.jobLevels?.length)
46
+ f.job_level = { values: icp.persona.jobLevels };
47
+ if (icp.persona.departments?.length)
48
+ f.job_department = { values: icp.persona.departments };
49
+ if (icp.firmographics.employeeBands?.length)
50
+ f.company_size = { values: icp.firmographics.employeeBands };
51
+ if (icp.firmographics.geos?.length)
52
+ f.company_country_code = { values: icp.firmographics.geos };
53
+ if (icp.firmographics.naics?.length)
54
+ f.naics_category = { values: icp.firmographics.naics };
55
+ return f;
56
+ }
57
+ /**
58
+ * Crustdata / LinkedIn seniority vocab. ICP job levels are normalized lowercase;
59
+ * Crustdata expects these exact capitalized strings (verified: "CXO",
60
+ * "Vice President", "Director" appear in Crustdata's docs/examples).
61
+ */
62
+ const CRUSTDATA_SENIORITY = {
63
+ cxo: "CXO",
64
+ "c-suite": "CXO",
65
+ c_suite: "CXO",
66
+ founder: "Owner",
67
+ owner: "Owner",
68
+ partner: "Partner",
69
+ vp: "Vice President",
70
+ "vice president": "Vice President",
71
+ head: "Director",
72
+ director: "Director",
73
+ manager: "Manager",
74
+ senior: "Senior",
75
+ entry: "Entry",
76
+ };
77
+ /**
78
+ * ICP industry keyword → LinkedIn industry names Crustdata filters on. Includes
79
+ * the current LinkedIn-v2 names; for software we send the cluster (dev + IT
80
+ * services + internet) so an OR match isn't overly narrow.
81
+ */
82
+ const CRUSTDATA_INDUSTRY = {
83
+ software: ["Software Development", "Information Technology and Services", "Internet"],
84
+ saas: ["Software Development", "Information Technology and Services"],
85
+ "information technology & services": ["Information Technology and Services"],
86
+ "information technology and services": ["Information Technology and Services"],
87
+ internet: ["Internet"],
88
+ fintech: ["Financial Services"],
89
+ "financial services": ["Financial Services"],
90
+ };
91
+ /**
92
+ * pipe0 Crustdata people-search config.filters from the ICP. `current_job_titles`
93
+ * matches real LinkedIn title strings (case-sensitive), so keywords are Title
94
+ * Cased. Seniority + industry are mapped to Crustdata's controlled vocab via the
95
+ * tables above. NOTE: the exact pipe0→Crustdata value set could not be re-run
96
+ * live (pipe0 credits were exhausted) — validate when credits refill; fit
97
+ * scoring is the safety net for persona precision regardless.
98
+ */
99
+ export function icpToCrustdataFilters(icp) {
100
+ const f = {};
101
+ if (icp.persona.titleKeywords?.length)
102
+ f.current_job_titles = icp.persona.titleKeywords.map(titleCase);
103
+ if (icp.firmographics.geos?.length) {
104
+ f.locations = icp.firmographics.geos.map((g) => COUNTRY_NAMES[g.toLowerCase()] ?? g);
105
+ }
106
+ if (icp.persona.jobLevels?.length) {
107
+ const include = [...new Set(icp.persona.jobLevels.map((l) => CRUSTDATA_SENIORITY[l.toLowerCase()]).filter(Boolean))];
108
+ if (include.length)
109
+ f.current_seniority_levels = { include, exclude: [] };
110
+ }
111
+ if (icp.firmographics.industries?.length) {
112
+ const inds = [
113
+ ...new Set(icp.firmographics.industries.flatMap((i) => CRUSTDATA_INDUSTRY[i.toLowerCase()] ?? [titleCase(i)])),
114
+ ];
115
+ if (inds.length)
116
+ f.current_employers_linkedin_industries = inds;
117
+ }
118
+ return f;
119
+ }
120
+ const ACRONYMS = new Set(["cro", "coo", "ceo", "cfo", "vp", "crm", "gtm", "saas"]);
121
+ function titleCase(value) {
122
+ return value
123
+ .split(/\s+/)
124
+ .map((w) => (ACRONYMS.has(w.toLowerCase()) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1)))
125
+ .join(" ");
126
+ }
127
+ /**
128
+ * Score a prospect's PERSONA fit 0..1. Title-keyword match is the strongest
129
+ * signal (it's what defines the buyer); job level and department add to it.
130
+ * Firmographics aren't re-scored here — discovery already filtered on them.
131
+ */
132
+ export function scoreProspectAgainstIcp(prospect, icp) {
133
+ const reasons = [];
134
+ const title = (prospect.jobTitle ?? "").toLowerCase();
135
+ const keywords = (icp.persona.titleKeywords ?? []).map((k) => k.toLowerCase());
136
+ const levels = (icp.persona.jobLevels ?? []).map((l) => l.toLowerCase());
137
+ const depts = (icp.persona.departments ?? []).map((d) => d.toLowerCase());
138
+ let score = 0;
139
+ let weightSum = 0;
140
+ if (keywords.length) {
141
+ weightSum += 0.6;
142
+ const hit = keywords.find((k) => title.includes(k));
143
+ if (hit) {
144
+ score += 0.6;
145
+ reasons.push(`title matches ICP keyword "${hit}"`);
146
+ }
147
+ }
148
+ if (levels.length) {
149
+ weightSum += 0.25;
150
+ const level = (prospect.jobLevel ?? "").toLowerCase();
151
+ if (level && levels.some((l) => level.includes(l))) {
152
+ score += 0.25;
153
+ reasons.push(`seniority "${prospect.jobLevel}" in ICP levels`);
154
+ }
155
+ }
156
+ if (depts.length) {
157
+ weightSum += 0.15;
158
+ const dept = (prospect.jobDepartment ?? "").toLowerCase();
159
+ if (dept && depts.some((d) => dept.includes(d))) {
160
+ score += 0.15;
161
+ reasons.push(`department "${prospect.jobDepartment}" in ICP`);
162
+ }
163
+ }
164
+ const normalized = weightSum > 0 ? score / weightSum : 0;
165
+ if (reasons.length === 0)
166
+ reasons.push("no persona signal matched the ICP");
167
+ return { score: Number(normalized.toFixed(3)), reasons };
168
+ }
169
+ export function fitThreshold(icp) {
170
+ return icp.scoring?.threshold ?? DEFAULT_FIT_THRESHOLD;
171
+ }
172
+ export const INTERVIEW_SPEC = [
173
+ {
174
+ id: "industries",
175
+ header: "Target market",
176
+ question: "What company profile are you targeting? (Drives firmographic discovery filters.)",
177
+ multiSelect: true,
178
+ options: [
179
+ { label: "B2B SaaS / software", value: ["software", "saas"], description: "Software & internet — classic messy-CRM, RevOps-equipped buyer." },
180
+ { label: "Broader tech-enabled B2B", value: ["software", "information technology & services", "financial services"], description: "Any B2B running a real sales motion on a CRM." },
181
+ { label: "Any B2B with a CRM", value: [], description: "Vertical-agnostic — qualify on persona, not industry." },
182
+ ],
183
+ },
184
+ {
185
+ id: "employeeBands",
186
+ header: "Company size",
187
+ question: "Company size (employee bands) that fit?",
188
+ multiSelect: true,
189
+ options: [
190
+ { label: "Seed–Series A (1–50)", value: ["1-10", "11-50"], description: "Early; often no dedicated RevOps yet." },
191
+ { label: "Series B–C (51–500)", value: ["51-200", "201-500"], description: "Sweet spot: messy CRM, RevOps exists, budget exists." },
192
+ { label: "Growth (501–2000)", value: ["501-1000", "1001-5000"], description: "Established RevOps, many CRM writers." },
193
+ { label: "Enterprise (2000+)", value: ["1001-5000", "5001-10000", "10001+"], description: "Up-market; longer cycles." },
194
+ ],
195
+ },
196
+ {
197
+ id: "titleKeywords",
198
+ header: "Buyer persona",
199
+ question: "Which buyer persona(s) should discovery target and scoring reward?",
200
+ multiSelect: true,
201
+ options: [
202
+ { label: "RevOps leadership", value: ["revenue operations", "revops", "head of revenue operations"], description: "VP/Head/Dir Revenue Operations." },
203
+ { label: "Sales/Marketing Ops", value: ["sales operations", "gtm operations", "marketing operations", "revenue ops"], description: "Hands-on the CRM daily." },
204
+ { label: "Exec buyers", value: ["chief revenue officer", "cro", "chief operating officer", "vp of sales"], description: "Economic sign-off." },
205
+ { label: "CRM/SF admins", value: ["salesforce admin", "hubspot admin", "crm manager", "revops analyst"], description: "Feel the pain, champion the fix." },
206
+ ],
207
+ },
208
+ {
209
+ id: "geos",
210
+ header: "Geography",
211
+ question: "Geography?",
212
+ multiSelect: true,
213
+ options: [
214
+ { label: "United States", value: ["us"], description: "US-only (best data coverage)." },
215
+ { label: "US + Canada", value: ["us", "ca"], description: "North America." },
216
+ { label: "US + UK/EU", value: ["us", "gb"], description: "English-speaking + Western Europe (mind GDPR)." },
217
+ { label: "Global", value: [], description: "No geo filter." },
218
+ ],
219
+ },
220
+ ];
221
+ /** Assemble an Icp from flattened interview answers. */
222
+ export function icpFromAnswers(name, answers) {
223
+ const levelsFromTitles = inferLevels(answers.titleKeywords);
224
+ return {
225
+ name,
226
+ firmographics: {
227
+ industries: dedupe(answers.industries),
228
+ naics: answers.industries.some((i) => /software|saas/i.test(i)) ? ["5112"] : undefined,
229
+ employeeBands: dedupe(answers.employeeBands),
230
+ geos: dedupe(answers.geos),
231
+ },
232
+ persona: {
233
+ jobLevels: answers.jobLevels?.length ? dedupe(answers.jobLevels) : levelsFromTitles,
234
+ departments: answers.departments?.length ? dedupe(answers.departments) : ["sales", "marketing", "operations"],
235
+ titleKeywords: dedupe(answers.titleKeywords),
236
+ },
237
+ scoring: { threshold: DEFAULT_FIT_THRESHOLD },
238
+ };
239
+ }
240
+ function inferLevels(titles) {
241
+ const levels = new Set();
242
+ for (const t of titles.map((x) => x.toLowerCase())) {
243
+ if (/chief|^c[a-z]o$|cro|coo/.test(t))
244
+ levels.add("cxo");
245
+ if (/vp|vice president|head of/.test(t))
246
+ levels.add("vp");
247
+ if (/director/.test(t))
248
+ levels.add("director");
249
+ if (/manager|admin|analyst|operations/.test(t))
250
+ levels.add("manager");
251
+ }
252
+ return levels.size ? [...levels] : ["cxo", "vp", "director", "manager"];
253
+ }
254
+ function dedupe(values) {
255
+ return [...new Set((values ?? []).filter(Boolean))];
256
+ }
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";
@@ -19,6 +20,7 @@ export { createFilePlanStore, type PlanStore, type StoredPlan } from "./planStor
19
20
  export { computeApprovalDigests, loadOrCreateSigningKey, loadSigningKey, signApproval, verifyApprovalDigests, type ApprovalVerification, } from "./integrity.ts";
20
21
  export { buildAuditLog, verifyAuditLog, type AuditLogEntry, type AuditLogExport, type AuditLogVerification, } from "./auditLog.ts";
21
22
  export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
23
+ export { computeHealth, summarizeHealth, healthToMarkdown, type HealthEntry, type HealthRollup, type HealthRuleDelta, } from "./health.ts";
22
24
  export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
23
25
  export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, type CrmObjectType, type FieldMappings, } from "./mappings.ts";
24
26
  export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, provenanceSummary, requiresHumanInput, staleDealRule, } from "./rules.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";
@@ -19,6 +20,7 @@ export { createFilePlanStore } from "./planStore.js";
19
20
  export { computeApprovalDigests, loadOrCreateSigningKey, loadSigningKey, signApproval, verifyApprovalDigests, } from "./integrity.js";
20
21
  export { buildAuditLog, verifyAuditLog, } from "./auditLog.js";
21
22
  export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
23
+ export { computeHealth, summarizeHealth, healthToMarkdown, } from "./health.js";
22
24
  export { auditReportToHtml, auditReportToMarkdown } from "./report.js";
23
25
  export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, } from "./mappings.js";
24
26
  export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, provenanceSummary, requiresHumanInput, staleDealRule, } from "./rules.js";
package/dist/mappings.js CHANGED
@@ -13,6 +13,9 @@ export const HUBSPOT_DEFAULT_FIELD_MAPPINGS = {
13
13
  email: "email",
14
14
  phone: "phone",
15
15
  title: "jobtitle",
16
+ // HubSpot-standard "LinkedIn URL"; safe to request everywhere (HubSpot
17
+ // ignores unknown properties rather than erroring). Powers strong dedup.
18
+ linkedin: "hs_linkedin_url",
16
19
  ownerId: "hubspot_owner_id",
17
20
  },
18
21
  deals: {
@@ -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
@@ -10,7 +10,29 @@ export type RiskLevel = "low" | "medium" | "high";
10
10
  export type ApprovalStatus = "draft" | "needs_approval" | "approved" | "rejected" | "applied";
11
11
  export type GtmObjectType = "account" | "contact" | "deal" | "user" | "activity";
12
12
  export type GtmEvidenceSourceSystem = "salesforce" | "hubspot" | "gong" | "chorus" | "fathom" | "manual" | "csv" | "mock" | "web" | "unknown";
13
- export type PatchOperationType = "set_field" | "clear_field" | "link_record" | "archive_record" | "create_task" | "merge_records";
13
+ export type PatchOperationType = "set_field" | "clear_field" | "link_record" | "archive_record" | "create_task" | "create_record" | "merge_records";
14
+ /**
15
+ * The afterValue of a `create_record` operation. The connector re-resolves on
16
+ * `matchKey`/`matchValue` at apply time and creates only on a confirmed miss.
17
+ * `estCostUsd` is the acquire meter's per-record charge, recorded against the
18
+ * budget on a successful create.
19
+ */
20
+ export type CreateRecordPayload = {
21
+ properties: Record<string, string>;
22
+ matchKey: string;
23
+ matchValue: string;
24
+ source: string;
25
+ estCostUsd?: number;
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;
35
+ };
14
36
  export type AuditFindingSeverity = "info" | "warning" | "critical";
15
37
  /**
16
38
  * One claim that a canonical record exists in an external system. A record
@@ -125,6 +147,8 @@ export type CanonicalContact = {
125
147
  email?: string;
126
148
  phone?: string;
127
149
  title?: string;
150
+ /** LinkedIn profile URL — the strongest cross-system identity key for dedup. */
151
+ linkedin?: string;
128
152
  ownerId?: string;
129
153
  lastActivityAt?: string;
130
154
  lastSyncAt?: string;
package/docs/api.md CHANGED
@@ -40,7 +40,7 @@ release.
40
40
  - `GtmConnector` — `{ provider, fetchSnapshot(), applyOperation?, readField?, fetchChanges? }`.
41
41
  - Connectors never silently drop unresolvable records; audits surface them.
42
42
  - `fetchChanges(sinceIso)` returns a partial snapshot; change feeds may omit associations.
43
- - `createHubspotConnector(options)` — read/write/readField/fetchChanges. `applyOperation` implements every `PatchOperationType`: `set_field`, `clear_field`, `link_record`, `create_task`, `archive_record`, `merge_records` (HubSpot v3 merge — pairwise, irreversible; survivor must belong to the duplicate group). The Salesforce connector skips `merge_records` honestly (SOAP/Apex-only on that platform).
43
+ - `createHubspotConnector(options)` — read/write/readField/fetchChanges. `applyOperation` implements every `PatchOperationType`: `set_field`, `clear_field`, `link_record`, `create_task`, `create_record` (resolve-first net-new contact/company create — re-checks the dedupe key at apply, never double-creates), `archive_record`, `merge_records` (HubSpot v3 merge — pairwise, irreversible; survivor must belong to the duplicate group). The Salesforce connector skips `merge_records` and `create_record` honestly (platform-specific).
44
44
  - `createSalesforceConnector(options)` — read/write/readField/fetchChanges; probabilities normalized to 0..1; `applyOperation` implements every operation type.
45
45
  - `createStripeConnector(options)` — read-only billing by design (`applyOperation` returns `skipped`); email domains are the cross-system merge keys. Implements `fetchChanges` (incremental via `created[gte]`).
46
46
 
@@ -62,7 +62,8 @@ Commands: `login` / `logout`, `snapshot`, `audit`, `report`, `diff`, `merge`, `p
62
62
  `bulk-update`, `dedupe`, `reassign`, `fix`,
63
63
  `market` (`init` / `capture` / `classify` / `worksheet` / `observe` / `fronts` /
64
64
  `axes` / `overlay` / `scale` / `report` / `refresh`),
65
- `enrich` (`append` / `refresh` / `ingest` / `status`),
65
+ `enrich` (`append` / `refresh` / `ingest` / `acquire` / `status`),
66
+ `icp` (`interview` / `set` / `show`),
66
67
  `schedule` (`add` / `list` / `remove` / `enable` / `disable` / `run` /
67
68
  `install` / `uninstall` / `status`), `rules`, `profiles`, `doctor`.
68
69
  Exit codes: `0` success · `1` error · `2` findings/regressions at the requested gate
@@ -101,7 +102,10 @@ emits a standard dry-run `PatchPlan` for the normal approve → apply chain:
101
102
  duplicate groups by normalized identity key, one `merge_records` per group,
102
103
  deterministic survivor selection (`richest` / `oldest`).
103
104
  - `buildReassignPlans(snapshot, options: ReassignOptions)` — one plan per
104
- `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.
105
109
 
106
110
  `fix` is CLI-only composition of existing surfaces (audit → suggest →
107
111
  approve → apply for one rule).
@@ -120,6 +124,52 @@ dependency-free CSV intake; the Apollo client (`createApolloClient`,
120
124
  `pullApolloRecords`, 429-aware with `Retry-After`) is the first `api`-kind
121
125
  source.
122
126
 
127
+ ## Acquire (net-new lead generation)
128
+
129
+ `buildAcquirePlan` turns sourced-but-unmatched prospects into `create_record`
130
+ operations (matched / ambiguous are skipped — resolve-first never creates over
131
+ a possible dup), capped by the meter's headroom. `builtinAcquirePreset` is the
132
+ zero-config `EnrichConfig.acquire` (budget, provider, create-mapping) so
133
+ `enrich acquire` runs with just an `icp.json`.
134
+
135
+ - **ICP** (`icp.ts`): `Icp` type, `parseIcp`, `icpToExploriumFilters` /
136
+ `icpToCrustdataFilters` (per-provider discovery filters), `scoreProspectAgainstIcp`
137
+ (persona fit 0..1), `fitThreshold`, `INTERVIEW_SPEC` + `icpFromAnswers` (the
138
+ agent-driven interview).
139
+ - **Prospect sources** (`connectors/prospectSources.ts`, `Prospect`):
140
+ `fetchExploriumProspects` (discovery), `pipe0ResolveWorkEmails` (work-email
141
+ waterfall, chunked, surfaces upstream errors), `fetchPipe0CrustdataProspects`
142
+ (people search). `prospectIdentityKeys` / `crmContactKeys` /
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.
152
+ - **Meter** (`acquireMeter.ts`): `AcquireBudget`, `loadMeter` / `remaining` /
153
+ `recordConsumption` — per-profile windowed record+spend budget, charged only
154
+ for landed creates.
155
+ - **Seen cache** (`acquireSeen.ts`): `loadSeen` / `recordSeen` — cross-run
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`.
169
+
170
+ `CanonicalContact.linkedin` (mapped from HubSpot `hs_linkedin_url`) is the
171
+ strong dedup key across all of the above.
172
+
123
173
  ## Schedule
124
174
 
125
175
  The horizontal scheduler: a declarative schedule-entry store, a