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/src/health.ts ADDED
@@ -0,0 +1,238 @@
1
+ import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { activeProfile, credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.ts";
5
+ import type { AuditFindingSeverity, CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
6
+
7
+ /**
8
+ * The engagement workspace: a per-client health timeline that accrues from the
9
+ * verb people already run (`audit --save`). State lives in the profile dir
10
+ * (`$FSGTM_HOME[/profiles/<name>]`) alongside credentials and plans, so a
11
+ * consultant working `--profile <client>` builds a continuous record of one
12
+ * org's CRM health over time — not just episodic one-off audits.
13
+ *
14
+ * The score is DETERMINISTIC (no LLM): same plan + same snapshot → same score,
15
+ * so it is stable in CI and comparable run-over-run.
16
+ */
17
+
18
+ // Severity weights: a critical finding costs ~3× a warning, ~10× an info.
19
+ const SEVERITY_WEIGHT: Record<AuditFindingSeverity, number> = { info: 1, warning: 3, critical: 10 };
20
+
21
+ export type HealthEntry = {
22
+ /** ISO timestamp of the audit that produced this entry. */
23
+ at: string;
24
+ /** The saved plan this entry summarizes (correlates to plans/ and snapshots/). */
25
+ planId: string;
26
+ /** Deterministic 0–100 hygiene score (higher is healthier). */
27
+ score: number;
28
+ /** Total finding count across all rules. */
29
+ findings: number;
30
+ /** Severity-weighted finding total (drives the score). */
31
+ weightedFindings: number;
32
+ /** Record counts at audit time — the denominator that normalizes the score. */
33
+ records: { accounts: number; contacts: number; deals: number; total: number };
34
+ /** Finding count per rule id. */
35
+ byRule: Record<string, number>;
36
+ /** Finding count per severity. */
37
+ severityCounts: Record<AuditFindingSeverity, number>;
38
+ };
39
+
40
+ export type HealthRuleDelta = {
41
+ ruleId: string;
42
+ current: number;
43
+ previous: number;
44
+ /** current − previous: positive = more findings (worse), negative = fewer (better). */
45
+ delta: number;
46
+ };
47
+
48
+ export type HealthRollup = {
49
+ profile: string;
50
+ auditCount: number;
51
+ first: string;
52
+ latest: string;
53
+ current: HealthEntry;
54
+ previous: HealthEntry | null;
55
+ /** current.score − previous.score: positive = improving. null on the first audit. */
56
+ scoreDelta: number | null;
57
+ ruleDeltas: HealthRuleDelta[];
58
+ history: Array<{ at: string; score: number; findings: number }>;
59
+ };
60
+
61
+ /**
62
+ * Deterministically score a saved audit. score = 100 / (1 + weighted findings
63
+ * per record): 0 findings → 100; one weighted finding per record → 50; the
64
+ * curve is bounded (0, 100], monotonic, and needs no clamping or magic
65
+ * constants. A messy 50-record CRM scores worse than the same finding count
66
+ * across 5,000 records, which is the point.
67
+ */
68
+ export function computeHealth(plan: PatchPlan, snapshot: CanonicalGtmSnapshot, at: string): HealthEntry {
69
+ const byRule: Record<string, number> = {};
70
+ const severityCounts: Record<AuditFindingSeverity, number> = { info: 0, warning: 0, critical: 0 };
71
+ let weightedFindings = 0;
72
+ for (const finding of plan.findings) {
73
+ byRule[finding.ruleId] = (byRule[finding.ruleId] ?? 0) + 1;
74
+ severityCounts[finding.severity] += 1;
75
+ weightedFindings += SEVERITY_WEIGHT[finding.severity];
76
+ }
77
+
78
+ const records = {
79
+ accounts: snapshot.accounts.length,
80
+ contacts: snapshot.contacts.length,
81
+ deals: snapshot.deals.length,
82
+ total: snapshot.accounts.length + snapshot.contacts.length + snapshot.deals.length,
83
+ };
84
+
85
+ const penalty = weightedFindings / Math.max(records.total, 1);
86
+ const score = Math.round(100 / (1 + penalty));
87
+
88
+ return {
89
+ at,
90
+ planId: plan.id,
91
+ score,
92
+ findings: plan.findings.length,
93
+ weightedFindings,
94
+ records,
95
+ byRule,
96
+ severityCounts,
97
+ };
98
+ }
99
+
100
+ /** Roll a timeline up into the current state, the change since last time, and per-rule deltas. */
101
+ export function summarizeHealth(entries: HealthEntry[], profile: string): HealthRollup | null {
102
+ if (entries.length === 0) return null;
103
+ // Oldest → newest, so `current` is the last entry.
104
+ const sorted = [...entries].sort((a, b) => a.at.localeCompare(b.at));
105
+ const current = sorted[sorted.length - 1];
106
+ const previous = sorted.length > 1 ? sorted[sorted.length - 2] : null;
107
+
108
+ const ruleIds = new Set<string>([
109
+ ...Object.keys(current.byRule),
110
+ ...(previous ? Object.keys(previous.byRule) : []),
111
+ ]);
112
+ const ruleDeltas: HealthRuleDelta[] = [...ruleIds]
113
+ .map((ruleId) => {
114
+ const cur = current.byRule[ruleId] ?? 0;
115
+ const prev = previous?.byRule[ruleId] ?? 0;
116
+ return { ruleId, current: cur, previous: prev, delta: cur - prev };
117
+ })
118
+ .sort((a, b) => b.current - a.current || a.ruleId.localeCompare(b.ruleId));
119
+
120
+ return {
121
+ profile,
122
+ auditCount: sorted.length,
123
+ first: sorted[0].at,
124
+ latest: current.at,
125
+ current,
126
+ previous,
127
+ scoreDelta: previous ? current.score - previous.score : null,
128
+ ruleDeltas,
129
+ history: sorted.map((entry) => ({ at: entry.at, score: entry.score, findings: entry.findings })),
130
+ };
131
+ }
132
+
133
+ // Sign-based and uniform: ▲ = the number went up, ▼ = down. The reader supplies
134
+ // the meaning (score up = good; findings up = bad) — mixing arrow direction with
135
+ // good/bad reads as contradictory ("▲ -3").
136
+ function arrow(delta: number): string {
137
+ if (delta === 0) return "·";
138
+ return delta > 0 ? "▲" : "▼";
139
+ }
140
+
141
+ /** Human-readable rollup: headline score + trend, then per-rule change since last audit. */
142
+ export function healthToMarkdown(rollup: HealthRollup): string {
143
+ const { current, scoreDelta } = rollup;
144
+ const lines = [`# GTM health — profile \`${rollup.profile}\``, ""];
145
+
146
+ const deltaText =
147
+ scoreDelta === null
148
+ ? "(first audit — no prior reading)"
149
+ : `${arrow(scoreDelta)} ${scoreDelta >= 0 ? "+" : ""}${scoreDelta} since the previous audit`;
150
+ lines.push(
151
+ `Score: **${current.score}/100** ${deltaText}`,
152
+ `Audits: ${rollup.auditCount} over ${shortDate(rollup.first)} → ${shortDate(rollup.latest)}`,
153
+ `Findings: ${current.findings} (${current.severityCounts.critical} critical, ${current.severityCounts.warning} warning, ${current.severityCounts.info} info)`,
154
+ `Records: ${current.records.accounts} accounts, ${current.records.contacts} contacts, ${current.records.deals} deals`,
155
+ "",
156
+ );
157
+
158
+ if (rollup.history.length > 1) {
159
+ lines.push("## Trend", "");
160
+ for (const point of rollup.history) {
161
+ const marker = point.at === rollup.latest ? " ← latest" : "";
162
+ lines.push(`- ${shortDate(point.at)} score ${point.score} (${point.findings} findings)${marker}`);
163
+ }
164
+ lines.push("");
165
+ }
166
+
167
+ if (rollup.ruleDeltas.length > 0) {
168
+ lines.push("## By rule (Δ since previous audit)", "", "| Rule | Findings | Δ |", "| --- | --- | --- |");
169
+ for (const rule of rollup.ruleDeltas) {
170
+ const sign = rule.delta > 0 ? `+${rule.delta}` : `${rule.delta}`;
171
+ const cell = rule.delta === 0 ? "·" : `${arrow(rule.delta)} ${sign}`;
172
+ lines.push(`| ${rule.ruleId} | ${rule.current} | ${cell} |`);
173
+ }
174
+ lines.push("");
175
+ }
176
+
177
+ lines.push("> Score is deterministic: 100 / (1 + severity-weighted findings per record). Lower findings or more clean records ⇒ higher score.");
178
+ return `${lines.join("\n")}\n`;
179
+ }
180
+
181
+ function shortDate(iso: string): string {
182
+ return iso.slice(0, 10);
183
+ }
184
+
185
+ // ── Profile-scoped storage ──────────────────────────────────────────────────
186
+
187
+ /** `$FSGTM_HOME[/profiles/<name>]/health.jsonl` — append-only, one entry per audit-save. */
188
+ export function healthFilePath(): string {
189
+ return join(credentialsDir(), "health.jsonl");
190
+ }
191
+
192
+ /** `$FSGTM_HOME[/profiles/<name>]/snapshots/` — canonical snapshots correlated by plan id. */
193
+ export function snapshotsDir(): string {
194
+ return join(credentialsDir(), "snapshots");
195
+ }
196
+
197
+ /** Append a health entry to the active profile's timeline (0600, owner-only). */
198
+ export function appendHealthEntry(entry: HealthEntry): void {
199
+ ensureSecureHomeDir();
200
+ appendFileSync(healthFilePath(), `${JSON.stringify(entry)}\n`, { mode: 0o600 });
201
+ }
202
+
203
+ /** Read the active profile's health timeline; tolerant of partial/corrupt lines. */
204
+ export function readHealthTimeline(): HealthEntry[] {
205
+ let raw: string;
206
+ try {
207
+ raw = readFileSync(healthFilePath(), "utf8");
208
+ } catch {
209
+ return [];
210
+ }
211
+ const entries: HealthEntry[] = [];
212
+ for (const line of raw.split("\n")) {
213
+ const trimmed = line.trim();
214
+ if (!trimmed) continue;
215
+ try {
216
+ entries.push(JSON.parse(trimmed) as HealthEntry);
217
+ } catch {
218
+ // Skip a torn line rather than failing the whole rollup.
219
+ }
220
+ }
221
+ return entries;
222
+ }
223
+
224
+ /** Persist the snapshot behind a saved audit so the timeline can diff/attribute later. */
225
+ export function saveWorkspaceSnapshot(planId: string, snapshot: CanonicalGtmSnapshot): string {
226
+ if (!/^[\w.-]+$/.test(planId)) throw new Error(`Invalid plan id: ${planId}`);
227
+ ensureSecureHomeDir();
228
+ const dir = snapshotsDir();
229
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
230
+ const path = join(dir, `${planId}.json`);
231
+ writeSecureFile(path, `${JSON.stringify(snapshot)}\n`);
232
+ return path;
233
+ }
234
+
235
+ /** The active profile name, for rollup labeling. */
236
+ export function activeWorkspaceProfile(): string {
237
+ return activeProfile();
238
+ }
package/src/icp.ts ADDED
@@ -0,0 +1,313 @@
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
+
16
+ export type Icp = {
17
+ name: string;
18
+ firmographics: {
19
+ /** human industry labels, e.g. ["software","saas"] — used for keyword/industry filters */
20
+ industries?: string[];
21
+ /** Explorium naics_category codes, e.g. ["5112"] (software publishers) */
22
+ naics?: string[];
23
+ /** provider-agnostic employee bands: "1-10","11-50","51-200","201-500","501-1000","1001-5000","5001-10000","10001+" */
24
+ employeeBands?: string[];
25
+ /** ISO country codes, lowercased, e.g. ["us"] */
26
+ geos?: string[];
27
+ };
28
+ persona: {
29
+ /** seniority: "cxo","vp","director","manager","owner","senior" */
30
+ jobLevels?: string[];
31
+ /** "sales","marketing","operations","finance" */
32
+ departments?: string[];
33
+ /** the title phrases that DEFINE the buyer — also the scoring signal */
34
+ titleKeywords?: string[];
35
+ };
36
+ signals?: { intentTopics?: string[] };
37
+ scoring?: {
38
+ /** minimum fit (0..1) for a prospect to become a create_record op. Default 0.5. */
39
+ threshold?: number;
40
+ };
41
+ };
42
+
43
+ export const DEFAULT_FIT_THRESHOLD = 0.5;
44
+
45
+ const COUNTRY_NAMES: Record<string, string> = {
46
+ us: "United States",
47
+ ca: "Canada",
48
+ gb: "United Kingdom",
49
+ uk: "United Kingdom",
50
+ };
51
+
52
+ export function parseIcp(raw: string): Icp {
53
+ let parsed: unknown;
54
+ try {
55
+ parsed = JSON.parse(raw);
56
+ } catch (error) {
57
+ throw new Error(`icp: not valid JSON (${error instanceof Error ? error.message : String(error)})`);
58
+ }
59
+ if (!parsed || typeof parsed !== "object") throw new Error("icp: expected a JSON object");
60
+ const icp = parsed as Icp;
61
+ if (!icp.name || typeof icp.name !== "string") throw new Error('icp: missing "name"');
62
+ if (!icp.persona || (!icp.persona.titleKeywords?.length && !icp.persona.jobLevels?.length)) {
63
+ throw new Error('icp: "persona" needs at least titleKeywords or jobLevels (without them, scoring cannot rank fit)');
64
+ }
65
+ return icp;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Discovery-filter translation
70
+
71
+ /** Explorium /v1/prospects filters from the ICP. */
72
+ export function icpToExploriumFilters(icp: Icp): Record<string, { values?: string[]; value?: boolean }> {
73
+ const f: Record<string, { values?: string[]; value?: boolean }> = { has_email: { value: true } };
74
+ if (icp.persona.jobLevels?.length) f.job_level = { values: icp.persona.jobLevels };
75
+ if (icp.persona.departments?.length) f.job_department = { values: icp.persona.departments };
76
+ if (icp.firmographics.employeeBands?.length) f.company_size = { values: icp.firmographics.employeeBands };
77
+ if (icp.firmographics.geos?.length) f.company_country_code = { values: icp.firmographics.geos };
78
+ if (icp.firmographics.naics?.length) f.naics_category = { values: icp.firmographics.naics };
79
+ return f;
80
+ }
81
+
82
+ /**
83
+ * Crustdata / LinkedIn seniority vocab. ICP job levels are normalized lowercase;
84
+ * Crustdata expects these exact capitalized strings (verified: "CXO",
85
+ * "Vice President", "Director" appear in Crustdata's docs/examples).
86
+ */
87
+ const CRUSTDATA_SENIORITY: Record<string, string> = {
88
+ cxo: "CXO",
89
+ "c-suite": "CXO",
90
+ c_suite: "CXO",
91
+ founder: "Owner",
92
+ owner: "Owner",
93
+ partner: "Partner",
94
+ vp: "Vice President",
95
+ "vice president": "Vice President",
96
+ head: "Director",
97
+ director: "Director",
98
+ manager: "Manager",
99
+ senior: "Senior",
100
+ entry: "Entry",
101
+ };
102
+
103
+ /**
104
+ * ICP industry keyword → LinkedIn industry names Crustdata filters on. Includes
105
+ * the current LinkedIn-v2 names; for software we send the cluster (dev + IT
106
+ * services + internet) so an OR match isn't overly narrow.
107
+ */
108
+ const CRUSTDATA_INDUSTRY: Record<string, string[]> = {
109
+ software: ["Software Development", "Information Technology and Services", "Internet"],
110
+ saas: ["Software Development", "Information Technology and Services"],
111
+ "information technology & services": ["Information Technology and Services"],
112
+ "information technology and services": ["Information Technology and Services"],
113
+ internet: ["Internet"],
114
+ fintech: ["Financial Services"],
115
+ "financial services": ["Financial Services"],
116
+ };
117
+
118
+ /**
119
+ * pipe0 Crustdata people-search config.filters from the ICP. `current_job_titles`
120
+ * matches real LinkedIn title strings (case-sensitive), so keywords are Title
121
+ * Cased. Seniority + industry are mapped to Crustdata's controlled vocab via the
122
+ * tables above. NOTE: the exact pipe0→Crustdata value set could not be re-run
123
+ * live (pipe0 credits were exhausted) — validate when credits refill; fit
124
+ * scoring is the safety net for persona precision regardless.
125
+ */
126
+ export function icpToCrustdataFilters(icp: Icp): Record<string, unknown> {
127
+ const f: Record<string, unknown> = {};
128
+ if (icp.persona.titleKeywords?.length) f.current_job_titles = icp.persona.titleKeywords.map(titleCase);
129
+ if (icp.firmographics.geos?.length) {
130
+ f.locations = icp.firmographics.geos.map((g) => COUNTRY_NAMES[g.toLowerCase()] ?? g);
131
+ }
132
+ if (icp.persona.jobLevels?.length) {
133
+ const include = [...new Set(icp.persona.jobLevels.map((l) => CRUSTDATA_SENIORITY[l.toLowerCase()]).filter(Boolean))];
134
+ if (include.length) f.current_seniority_levels = { include, exclude: [] };
135
+ }
136
+ if (icp.firmographics.industries?.length) {
137
+ const inds = [
138
+ ...new Set(icp.firmographics.industries.flatMap((i) => CRUSTDATA_INDUSTRY[i.toLowerCase()] ?? [titleCase(i)])),
139
+ ];
140
+ if (inds.length) f.current_employers_linkedin_industries = inds;
141
+ }
142
+ return f;
143
+ }
144
+
145
+ const ACRONYMS = new Set(["cro", "coo", "ceo", "cfo", "vp", "crm", "gtm", "saas"]);
146
+ function titleCase(value: string): string {
147
+ return value
148
+ .split(/\s+/)
149
+ .map((w) => (ACRONYMS.has(w.toLowerCase()) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1)))
150
+ .join(" ");
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Fit scoring (persona precision; firmographics are enforced by the filter)
155
+
156
+ export type IcpFit = { score: number; reasons: string[] };
157
+
158
+ /**
159
+ * Score a prospect's PERSONA fit 0..1. Title-keyword match is the strongest
160
+ * signal (it's what defines the buyer); job level and department add to it.
161
+ * Firmographics aren't re-scored here — discovery already filtered on them.
162
+ */
163
+ export function scoreProspectAgainstIcp(
164
+ prospect: { jobTitle?: string; jobLevel?: string; jobDepartment?: string },
165
+ icp: Icp,
166
+ ): IcpFit {
167
+ const reasons: string[] = [];
168
+ const title = (prospect.jobTitle ?? "").toLowerCase();
169
+ const keywords = (icp.persona.titleKeywords ?? []).map((k) => k.toLowerCase());
170
+ const levels = (icp.persona.jobLevels ?? []).map((l) => l.toLowerCase());
171
+ const depts = (icp.persona.departments ?? []).map((d) => d.toLowerCase());
172
+
173
+ let score = 0;
174
+ let weightSum = 0;
175
+
176
+ if (keywords.length) {
177
+ weightSum += 0.6;
178
+ const hit = keywords.find((k) => title.includes(k));
179
+ if (hit) {
180
+ score += 0.6;
181
+ reasons.push(`title matches ICP keyword "${hit}"`);
182
+ }
183
+ }
184
+ if (levels.length) {
185
+ weightSum += 0.25;
186
+ const level = (prospect.jobLevel ?? "").toLowerCase();
187
+ if (level && levels.some((l) => level.includes(l))) {
188
+ score += 0.25;
189
+ reasons.push(`seniority "${prospect.jobLevel}" in ICP levels`);
190
+ }
191
+ }
192
+ if (depts.length) {
193
+ weightSum += 0.15;
194
+ const dept = (prospect.jobDepartment ?? "").toLowerCase();
195
+ if (dept && depts.some((d) => dept.includes(d))) {
196
+ score += 0.15;
197
+ reasons.push(`department "${prospect.jobDepartment}" in ICP`);
198
+ }
199
+ }
200
+ const normalized = weightSum > 0 ? score / weightSum : 0;
201
+ if (reasons.length === 0) reasons.push("no persona signal matched the ICP");
202
+ return { score: Number(normalized.toFixed(3)), reasons };
203
+ }
204
+
205
+ export function fitThreshold(icp: Icp): number {
206
+ return icp.scoring?.threshold ?? DEFAULT_FIT_THRESHOLD;
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Interview spec — an agent drives this with AskUserQuestion, then writes an Icp
211
+
212
+ export type IcpInterviewQuestion = {
213
+ id: keyof FlatIcpAnswers;
214
+ header: string;
215
+ question: string;
216
+ multiSelect: boolean;
217
+ options: { label: string; value: string | string[]; description: string }[];
218
+ };
219
+
220
+ /** Flattened answer keys the interview collects (assembled into an Icp). */
221
+ export type FlatIcpAnswers = {
222
+ industries: string[];
223
+ employeeBands: string[];
224
+ jobLevels: string[];
225
+ departments: string[];
226
+ titleKeywords: string[];
227
+ geos: string[];
228
+ };
229
+
230
+ export const INTERVIEW_SPEC: IcpInterviewQuestion[] = [
231
+ {
232
+ id: "industries",
233
+ header: "Target market",
234
+ question: "What company profile are you targeting? (Drives firmographic discovery filters.)",
235
+ multiSelect: true,
236
+ options: [
237
+ { label: "B2B SaaS / software", value: ["software", "saas"], description: "Software & internet — classic messy-CRM, RevOps-equipped buyer." },
238
+ { label: "Broader tech-enabled B2B", value: ["software", "information technology & services", "financial services"], description: "Any B2B running a real sales motion on a CRM." },
239
+ { label: "Any B2B with a CRM", value: [], description: "Vertical-agnostic — qualify on persona, not industry." },
240
+ ],
241
+ },
242
+ {
243
+ id: "employeeBands",
244
+ header: "Company size",
245
+ question: "Company size (employee bands) that fit?",
246
+ multiSelect: true,
247
+ options: [
248
+ { label: "Seed–Series A (1–50)", value: ["1-10", "11-50"], description: "Early; often no dedicated RevOps yet." },
249
+ { label: "Series B–C (51–500)", value: ["51-200", "201-500"], description: "Sweet spot: messy CRM, RevOps exists, budget exists." },
250
+ { label: "Growth (501–2000)", value: ["501-1000", "1001-5000"], description: "Established RevOps, many CRM writers." },
251
+ { label: "Enterprise (2000+)", value: ["1001-5000", "5001-10000", "10001+"], description: "Up-market; longer cycles." },
252
+ ],
253
+ },
254
+ {
255
+ id: "titleKeywords",
256
+ header: "Buyer persona",
257
+ question: "Which buyer persona(s) should discovery target and scoring reward?",
258
+ multiSelect: true,
259
+ options: [
260
+ { label: "RevOps leadership", value: ["revenue operations", "revops", "head of revenue operations"], description: "VP/Head/Dir Revenue Operations." },
261
+ { label: "Sales/Marketing Ops", value: ["sales operations", "gtm operations", "marketing operations", "revenue ops"], description: "Hands-on the CRM daily." },
262
+ { label: "Exec buyers", value: ["chief revenue officer", "cro", "chief operating officer", "vp of sales"], description: "Economic sign-off." },
263
+ { label: "CRM/SF admins", value: ["salesforce admin", "hubspot admin", "crm manager", "revops analyst"], description: "Feel the pain, champion the fix." },
264
+ ],
265
+ },
266
+ {
267
+ id: "geos",
268
+ header: "Geography",
269
+ question: "Geography?",
270
+ multiSelect: true,
271
+ options: [
272
+ { label: "United States", value: ["us"], description: "US-only (best data coverage)." },
273
+ { label: "US + Canada", value: ["us", "ca"], description: "North America." },
274
+ { label: "US + UK/EU", value: ["us", "gb"], description: "English-speaking + Western Europe (mind GDPR)." },
275
+ { label: "Global", value: [], description: "No geo filter." },
276
+ ],
277
+ },
278
+ ];
279
+
280
+ /** Assemble an Icp from flattened interview answers. */
281
+ export function icpFromAnswers(name: string, answers: FlatIcpAnswers): Icp {
282
+ const levelsFromTitles = inferLevels(answers.titleKeywords);
283
+ return {
284
+ name,
285
+ firmographics: {
286
+ industries: dedupe(answers.industries),
287
+ naics: answers.industries.some((i) => /software|saas/i.test(i)) ? ["5112"] : undefined,
288
+ employeeBands: dedupe(answers.employeeBands),
289
+ geos: dedupe(answers.geos),
290
+ },
291
+ persona: {
292
+ jobLevels: answers.jobLevels?.length ? dedupe(answers.jobLevels) : levelsFromTitles,
293
+ departments: answers.departments?.length ? dedupe(answers.departments) : ["sales", "marketing", "operations"],
294
+ titleKeywords: dedupe(answers.titleKeywords),
295
+ },
296
+ scoring: { threshold: DEFAULT_FIT_THRESHOLD },
297
+ };
298
+ }
299
+
300
+ function inferLevels(titles: string[]): string[] {
301
+ const levels = new Set<string>();
302
+ for (const t of titles.map((x) => x.toLowerCase())) {
303
+ if (/chief|^c[a-z]o$|cro|coo/.test(t)) levels.add("cxo");
304
+ if (/vp|vice president|head of/.test(t)) levels.add("vp");
305
+ if (/director/.test(t)) levels.add("director");
306
+ if (/manager|admin|analyst|operations/.test(t)) levels.add("manager");
307
+ }
308
+ return levels.size ? [...levels] : ["cxo", "vp", "director", "manager"];
309
+ }
310
+
311
+ function dedupe(values: string[] | undefined): string[] {
312
+ return [...new Set((values ?? []).filter(Boolean))];
313
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,14 @@
1
1
  export { auditSnapshot, defaultPolicy } from "./audit.ts";
2
+ export {
3
+ matchesTerritory,
4
+ parseAssignmentPolicy,
5
+ resolveAssignment,
6
+ type AssignmentContext,
7
+ type AssignmentPolicy,
8
+ type AssignmentResult,
9
+ type AssignmentStrategy,
10
+ type TerritoryRule,
11
+ } from "./assign.ts";
2
12
  export { buildBulkUpdatePlan, isFilterableField, parseWhere, type BulkUpdateOptions } from "./bulkUpdate.ts";
3
13
  export { buildDedupePlan, dedupeKey, type DedupeOptions } from "./dedupe.ts";
4
14
  export { buildReassignPlans, type ReassignObjectType, type ReassignOptions } from "./reassign.ts";
@@ -131,6 +141,14 @@ export {
131
141
  type AuditLogVerification,
132
142
  } from "./auditLog.ts";
133
143
  export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
144
+ export {
145
+ computeHealth,
146
+ summarizeHealth,
147
+ healthToMarkdown,
148
+ type HealthEntry,
149
+ type HealthRollup,
150
+ type HealthRuleDelta,
151
+ } from "./health.ts";
134
152
  export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
135
153
  export {
136
154
  HUBSPOT_DEFAULT_FIELD_MAPPINGS,
package/src/mappings.ts CHANGED
@@ -22,6 +22,9 @@ export const HUBSPOT_DEFAULT_FIELD_MAPPINGS: Record<
22
22
  email: "email",
23
23
  phone: "phone",
24
24
  title: "jobtitle",
25
+ // HubSpot-standard "LinkedIn URL"; safe to request everywhere (HubSpot
26
+ // ignores unknown properties rather than erroring). Powers strong dedup.
27
+ linkedin: "hs_linkedin_url",
25
28
  ownerId: "hubspot_owner_id",
26
29
  },
27
30
  deals: {
package/src/reassign.ts CHANGED
@@ -28,10 +28,19 @@ import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
28
28
  export type ReassignObjectType = "account" | "contact" | "deal";
29
29
 
30
30
  export type ReassignOptions = {
31
- /** the owner whose records are being handed off */
32
- fromOwnerId: string;
31
+ /**
32
+ * the owner whose records are being handed off. Ignored (and not required)
33
+ * when `assignUnowned` is set — that mode targets ownerless records instead.
34
+ */
35
+ fromOwnerId?: string;
33
36
  /** the receiving owner — must be a known user in the snapshot */
34
37
  toOwnerId: string;
38
+ /**
39
+ * Backfill mode: instead of moving records from one owner to another, claim
40
+ * every OWNERLESS record (ownerId:empty) for `toOwnerId`. Shares the create-
41
+ * time assignment intent with `enrich acquire`, applied to existing debt.
42
+ */
43
+ assignUnowned?: boolean;
35
44
  /** which object types to compile plans for (default all three) */
36
45
  objects?: ReassignObjectType[];
37
46
  /** extra --where scoping, AND-ed into every plan (account fields lifted) */
@@ -65,11 +74,16 @@ export function buildReassignPlans(
65
74
  snapshot: CanonicalGtmSnapshot,
66
75
  options: ReassignOptions,
67
76
  ): PatchPlan[] {
68
- if (!options.fromOwnerId || !options.toOwnerId) {
69
- throw new Error("reassign requires both --from <ownerId> and --to <ownerId>.");
77
+ if (!options.toOwnerId) {
78
+ throw new Error("reassign requires --to <ownerId>.");
70
79
  }
71
- if (options.fromOwnerId === options.toOwnerId) {
72
- throw new Error("reassign --from and --to are the same owner — nothing to hand off.");
80
+ if (!options.assignUnowned) {
81
+ if (!options.fromOwnerId) {
82
+ throw new Error("reassign requires --from <ownerId> (or --assign-unowned to claim ownerless records).");
83
+ }
84
+ if (options.fromOwnerId === options.toOwnerId) {
85
+ throw new Error("reassign --from and --to are the same owner — nothing to hand off.");
86
+ }
73
87
  }
74
88
  // The receiving owner must exist: a typo'd --to would otherwise write an
75
89
  // invalid owner onto every matched record.
@@ -88,7 +102,7 @@ export function buildReassignPlans(
88
102
  }
89
103
 
90
104
  return objects.map((objectType) => {
91
- const where = [`ownerId=${options.fromOwnerId}`];
105
+ const where = [options.assignUnowned ? "ownerId:empty" : `ownerId=${options.fromOwnerId}`];
92
106
  if (objectType === "deal" && !options.includeClosedDeals) {
93
107
  where.push("isClosed=false"); // closed deals keep their historical owner
94
108
  }
@@ -110,7 +124,9 @@ export function buildReassignPlans(
110
124
  set: { ownerId: options.toOwnerId },
111
125
  reason:
112
126
  options.reason ??
113
- `reassign: hand off ${objectType}s from owner ${options.fromOwnerId} to ${options.toOwnerId}`,
127
+ (options.assignUnowned
128
+ ? `reassign: claim ownerless ${objectType}s for owner ${options.toOwnerId}`
129
+ : `reassign: hand off ${objectType}s from owner ${options.fromOwnerId} to ${options.toOwnerId}`),
114
130
  maxOperations: options.maxOperations,
115
131
  });
116
132
  });