fullstackgtm 0.42.0 → 0.44.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 (68) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/README.md +18 -7
  3. package/dist/calls.d.ts +13 -0
  4. package/dist/calls.js +14 -3
  5. package/dist/cli.js +718 -60
  6. package/dist/connectors/hubspot.js +58 -24
  7. package/dist/connectors/outboxChannel.d.ts +64 -0
  8. package/dist/connectors/outboxChannel.js +170 -0
  9. package/dist/connectors/prospectSources.d.ts +22 -0
  10. package/dist/connectors/prospectSources.js +43 -0
  11. package/dist/connectors/salesforce.js +40 -15
  12. package/dist/connectors/signalSources.d.ts +105 -0
  13. package/dist/connectors/signalSources.js +316 -0
  14. package/dist/connectors/theirstack.d.ts +84 -0
  15. package/dist/connectors/theirstack.js +125 -0
  16. package/dist/draft.js +27 -5
  17. package/dist/enrich.d.ts +6 -0
  18. package/dist/enrich.js +47 -1
  19. package/dist/health.d.ts +12 -0
  20. package/dist/health.js +29 -2
  21. package/dist/icp.d.ts +47 -3
  22. package/dist/icp.js +105 -11
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.js +2 -0
  25. package/dist/init.d.ts +47 -0
  26. package/dist/init.js +143 -0
  27. package/dist/judge.d.ts +36 -3
  28. package/dist/judge.js +46 -10
  29. package/dist/judgeEval.d.ts +7 -0
  30. package/dist/judgeEval.js +8 -1
  31. package/dist/runReport.d.ts +26 -0
  32. package/dist/runReport.js +15 -0
  33. package/dist/schedule.js +18 -4
  34. package/dist/signals.d.ts +58 -0
  35. package/dist/signals.js +65 -0
  36. package/dist/tam.d.ts +225 -0
  37. package/dist/tam.js +470 -0
  38. package/dist/types.d.ts +12 -0
  39. package/docs/api.md +26 -4
  40. package/docs/outbox-format.md +92 -0
  41. package/docs/recipes.md +195 -0
  42. package/docs/roadmap-to-1.0.md +31 -1
  43. package/docs/signal-spool-format.md +162 -0
  44. package/docs/tam.md +195 -0
  45. package/llms.txt +89 -1
  46. package/package.json +1 -1
  47. package/skills/fullstackgtm/SKILL.md +17 -1
  48. package/src/calls.ts +24 -3
  49. package/src/cli.ts +809 -55
  50. package/src/connectors/hubspot.ts +55 -23
  51. package/src/connectors/outboxChannel.ts +202 -0
  52. package/src/connectors/prospectSources.ts +57 -0
  53. package/src/connectors/salesforce.ts +39 -14
  54. package/src/connectors/signalSources.ts +363 -0
  55. package/src/connectors/theirstack.ts +170 -0
  56. package/src/draft.ts +26 -5
  57. package/src/enrich.ts +51 -1
  58. package/src/health.ts +47 -2
  59. package/src/icp.ts +113 -11
  60. package/src/index.ts +42 -0
  61. package/src/init.ts +166 -0
  62. package/src/judge.ts +85 -11
  63. package/src/judgeEval.ts +8 -1
  64. package/src/runReport.ts +39 -0
  65. package/src/schedule.ts +20 -4
  66. package/src/signals.ts +95 -0
  67. package/src/tam.ts +654 -0
  68. package/src/types.ts +12 -0
package/dist/enrich.js CHANGED
@@ -247,6 +247,8 @@ export function builtinAcquirePreset(source) {
247
247
  company: "companyName",
248
248
  email: "email",
249
249
  },
250
+ associateCompanyFrom: "companyName",
251
+ associateCompanyDomainFrom: "companyDomain",
250
252
  },
251
253
  },
252
254
  },
@@ -274,6 +276,8 @@ export function builtinAcquirePreset(source) {
274
276
  company: "companyName",
275
277
  hs_linkedin_url: "linkedin",
276
278
  },
279
+ associateCompanyFrom: "companyName",
280
+ associateCompanyDomainFrom: "companyDomain",
277
281
  },
278
282
  },
279
283
  },
@@ -490,6 +494,28 @@ function crmFieldValue(snapshot, objectType, objectId, field) {
490
494
  function isEmptyValue(value) {
491
495
  return value === undefined || value === null || (typeof value === "string" && value.trim() === "");
492
496
  }
497
+ /** Bare registrable host from a domain or URL ("https://www.x.com/a" → "x.com"). */
498
+ function normalizeCompanyDomain(value) {
499
+ if (typeof value !== "string" || !value.trim())
500
+ return undefined;
501
+ const host = value
502
+ .trim()
503
+ .toLowerCase()
504
+ .replace(/^https?:\/\//, "")
505
+ .replace(/^www\./, "")
506
+ .replace(/\/.*$/, "")
507
+ .replace(/\s+/g, "");
508
+ return host && host.includes(".") ? host : undefined;
509
+ }
510
+ /** The domain of a work email ("vp@acme.com" → "acme.com"), else undefined. */
511
+ function domainFromEmail(value) {
512
+ if (typeof value !== "string")
513
+ return undefined;
514
+ const at = value.lastIndexOf("@");
515
+ if (at < 0)
516
+ return undefined;
517
+ return normalizeCompanyDomain(value.slice(at + 1));
518
+ }
493
519
  /** Values compare as trimmed strings; numbers compare numerically. */
494
520
  function sameValue(a, b) {
495
521
  if (isEmptyValue(a) && isEmptyValue(b))
@@ -831,6 +857,21 @@ export function buildAcquirePlan(options) {
831
857
  if (!isEmptyValue(companyValue))
832
858
  associateCompanyName = String(companyValue);
833
859
  }
860
+ // The company domain makes the lead's account signal-watchable. Prefer the
861
+ // configured source path; fall back to the work email's domain (free, and
862
+ // for B2B leads the email domain IS the company domain).
863
+ let associateCompanyDomain;
864
+ if (associateCompanyName) {
865
+ const fromPath = createMap.associateCompanyDomainFrom
866
+ ? sourceValueAt(record.payload, createMap.associateCompanyDomainFrom)
867
+ : undefined;
868
+ const candidate = !isEmptyValue(fromPath)
869
+ ? String(fromPath)
870
+ : domainFromEmail(sourceValueAt(record.payload, "email"));
871
+ const normalized = normalizeCompanyDomain(candidate);
872
+ if (normalized)
873
+ associateCompanyDomain = normalized;
874
+ }
834
875
  const recordEvidence = evidenceFor(source, sourceConfig.kind, sourceConfig.format, record, undefined, nowIso);
835
876
  evidence.push(recordEvidence);
836
877
  // Resolve ownership BEFORE the meter-charged create lands, so the lead is
@@ -853,8 +894,13 @@ export function buildAcquirePlan(options) {
853
894
  source,
854
895
  estCostUsd: costPerRecord,
855
896
  associateCompanyName,
897
+ ...(associateCompanyDomain ? { associateCompanyDomain } : {}),
856
898
  ...(ownerId ? { ownerId, assignedBy } : {}),
857
899
  };
900
+ // Surface the account link in the dry-run so it is not a silent side-effect.
901
+ const accountNote = associateCompanyName
902
+ ? ` Resolves/creates its account "${associateCompanyName}"${associateCompanyDomain ? ` (${associateCompanyDomain})` : ""} and links the lead.`
903
+ : "";
858
904
  operations.push({
859
905
  id: `op_acq_${fnv1a(`${source}:${record.objectType}:${matchValue}`)}`,
860
906
  objectType: canonicalObjectType(record.objectType),
@@ -863,7 +909,7 @@ export function buildAcquirePlan(options) {
863
909
  beforeValue: null,
864
910
  afterValue: payload,
865
911
  reason: `${source} sourced net-new ${record.objectType} "${describeSourceRecord(record)}" ` +
866
- `(${createMap.matchKey}=${matchValue}); no CRM match — create as a lead.`,
912
+ `(${createMap.matchKey}=${matchValue}); no CRM match — create as a lead.${accountNote}`,
867
913
  sourceRuleOrPolicy: `acquire:${source}`,
868
914
  riskLevel: "medium",
869
915
  approvalRequired: true,
package/dist/health.d.ts CHANGED
@@ -21,6 +21,18 @@ export type HealthEntry = {
21
21
  byRule: Record<string, number>;
22
22
  /** Finding count per severity. */
23
23
  severityCounts: Record<AuditFindingSeverity, number>;
24
+ /**
25
+ * Per-object-type breakdown — so "is my contact data clean but my pipeline
26
+ * messy?" is answerable without re-auditing. Each type carries its own
27
+ * record-normalized score (same curve as the overall score, scoped to that
28
+ * type's records + findings).
29
+ */
30
+ byObjectType: Record<"account" | "contact" | "deal", {
31
+ records: number;
32
+ findings: number;
33
+ weightedFindings: number;
34
+ score: number;
35
+ }>;
24
36
  };
25
37
  export type HealthRuleDelta = {
26
38
  ruleId: string;
package/dist/health.js CHANGED
@@ -23,11 +23,21 @@ const SEVERITY_WEIGHT = { info: 1, warning: 3, critical: 10 };
23
23
  export function computeHealth(plan, snapshot, at) {
24
24
  const byRule = {};
25
25
  const severityCounts = { info: 0, warning: 0, critical: 0 };
26
+ const typeTally = {
27
+ account: { findings: 0, weightedFindings: 0 },
28
+ contact: { findings: 0, weightedFindings: 0 },
29
+ deal: { findings: 0, weightedFindings: 0 },
30
+ };
26
31
  let weightedFindings = 0;
27
32
  for (const finding of plan.findings) {
28
33
  byRule[finding.ruleId] = (byRule[finding.ruleId] ?? 0) + 1;
29
34
  severityCounts[finding.severity] += 1;
30
35
  weightedFindings += SEVERITY_WEIGHT[finding.severity];
36
+ const t = finding.objectType;
37
+ if (t === "account" || t === "contact" || t === "deal") {
38
+ typeTally[t].findings += 1;
39
+ typeTally[t].weightedFindings += SEVERITY_WEIGHT[finding.severity];
40
+ }
31
41
  }
32
42
  const records = {
33
43
  accounts: snapshot.accounts.length,
@@ -35,8 +45,18 @@ export function computeHealth(plan, snapshot, at) {
35
45
  deals: snapshot.deals.length,
36
46
  total: snapshot.accounts.length + snapshot.contacts.length + snapshot.deals.length,
37
47
  };
38
- const penalty = weightedFindings / Math.max(records.total, 1);
39
- const score = Math.round(100 / (1 + penalty));
48
+ const scoreFor = (weighted, recordCount) => Math.round(100 / (1 + weighted / Math.max(recordCount, 1)));
49
+ const recordsByType = { account: records.accounts, contact: records.contacts, deal: records.deals };
50
+ const byObjectType = ["account", "contact", "deal"].reduce((acc, t) => {
51
+ acc[t] = {
52
+ records: recordsByType[t],
53
+ findings: typeTally[t].findings,
54
+ weightedFindings: typeTally[t].weightedFindings,
55
+ score: scoreFor(typeTally[t].weightedFindings, recordsByType[t]),
56
+ };
57
+ return acc;
58
+ }, {});
59
+ const score = scoreFor(weightedFindings, records.total);
40
60
  return {
41
61
  at,
42
62
  planId: plan.id,
@@ -46,6 +66,7 @@ export function computeHealth(plan, snapshot, at) {
46
66
  records,
47
67
  byRule,
48
68
  severityCounts,
69
+ byObjectType,
49
70
  };
50
71
  }
51
72
  /** Roll a timeline up into the current state, the change since last time, and per-rule deltas. */
@@ -95,6 +116,12 @@ export function healthToMarkdown(rollup) {
95
116
  ? "(first audit — no prior reading)"
96
117
  : `${arrow(scoreDelta)} ${scoreDelta >= 0 ? "+" : ""}${scoreDelta} since the previous audit`;
97
118
  lines.push(`Score: **${current.score}/100** ${deltaText}`, `Audits: ${rollup.auditCount} over ${shortDate(rollup.first)} → ${shortDate(rollup.latest)}`, `Findings: ${current.findings} (${current.severityCounts.critical} critical, ${current.severityCounts.warning} warning, ${current.severityCounts.info} info)`, `Records: ${current.records.accounts} accounts, ${current.records.contacts} contacts, ${current.records.deals} deals`, "");
119
+ lines.push("## By object type", "", "| Type | Score | Findings | Records |", "| --- | --- | --- | --- |");
120
+ for (const t of ["account", "contact", "deal"]) {
121
+ const b = current.byObjectType[t];
122
+ lines.push(`| ${t} | ${b.score}/100 | ${b.findings} | ${b.records} |`);
123
+ }
124
+ lines.push("");
98
125
  if (rollup.history.length > 1) {
99
126
  lines.push("## Trend", "");
100
127
  for (const point of rollup.history) {
package/dist/icp.d.ts CHANGED
@@ -23,6 +23,10 @@ export type Icp = {
23
23
  employeeBands?: string[];
24
24
  /** ISO country codes, lowercased, e.g. ["us"] */
25
25
  geos?: string[];
26
+ /** technographic targeting: technology slugs the account must use, e.g.
27
+ * ["salesforce","hubspot","pipedrive"] — the real CRM/MAP buying signal,
28
+ * consumed by TheirStack company search. OR-matched. */
29
+ technologies?: string[];
26
30
  };
27
31
  persona: {
28
32
  /** seniority: "cxo","vp","director","manager","owner","senior" */
@@ -47,13 +51,53 @@ export declare function icpToExploriumFilters(icp: Icp): Record<string, {
47
51
  values?: string[];
48
52
  value?: boolean;
49
53
  }>;
54
+ /**
55
+ * Explorium /v1/businesses filters from the ICP — the COMPANY (account) side, for
56
+ * sizing the account universe (TAM). Firmographics only, no persona: the count is
57
+ * of matching companies. Field names differ from /v1/prospects (verified live):
58
+ * `country_code` (not company_country_code), `company_size` (same employee bands),
59
+ * `naics_category`. `/v1/businesses` total_results is a real count, capped at
60
+ * 60,000 (see EXPLORIUM_BUSINESS_COUNT_CAP in the connector).
61
+ */
62
+ export declare function icpToExploriumBusinessFilters(icp: Icp): Record<string, {
63
+ values?: string[];
64
+ }>;
65
+ /**
66
+ * Collapse provider-agnostic employee bands ("51-200","10001+") into a single
67
+ * {min,max} envelope for APIs that take integer bounds (TheirStack). An open
68
+ * top band ("10001+") leaves max undefined.
69
+ */
70
+ export declare function employeeBandsToRange(bands: string[] | undefined): {
71
+ min?: number;
72
+ max?: number;
73
+ };
74
+ /**
75
+ * TheirStack company-search filter from the ICP: the technographic targeting that
76
+ * Explorium can't do. `company_technology_slug_or` is the CRM/MAP buying signal
77
+ * (firmographics.technologies); employee bands become min/max bounds; geos become
78
+ * ISO2 codes (uppercased).
79
+ */
80
+ export declare function icpToTheirStackFilters(icp: Icp): {
81
+ company_technology_slug_or?: string[];
82
+ min_employee_count?: number;
83
+ max_employee_count?: number;
84
+ company_country_code_or?: string[];
85
+ };
50
86
  /**
51
87
  * pipe0 Crustdata people-search config.filters from the ICP. `current_job_titles`
52
88
  * matches real LinkedIn title strings (case-sensitive), so keywords are Title
53
89
  * 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.
90
+ * tables above.
91
+ *
92
+ * LIVE FINDINGS (2026-06-26): `current_job_titles` is confirmed working (a
93
+ * titles-only RevOps search returns results); `current_title` is rejected (422).
94
+ * The full ICP filter returned 0 — the prime suspect is the industry vocab
95
+ * (LinkedIn v1/v2 taxonomy mismatch), now hedged by sending BOTH generations in
96
+ * CRUSTDATA_INDUSTRY above. NOT yet re-confirmed end-to-end (pipe0 credits were
97
+ * exhausted mid-investigation) — re-run `enrich acquire --source pipe0` once
98
+ * credits refill; if it still returns 0, the next suspects are the
99
+ * `current_seniority_levels` shape/values and `locations`. Note fit-scoring backs
100
+ * up PERSONA precision but NOT industry, so the industry filter is load-bearing.
57
101
  */
58
102
  export declare function icpToCrustdataFilters(icp: Icp): Record<string, unknown>;
59
103
  export type IcpFit = {
package/dist/icp.js CHANGED
@@ -54,6 +54,77 @@ export function icpToExploriumFilters(icp) {
54
54
  f.naics_category = { values: icp.firmographics.naics };
55
55
  return f;
56
56
  }
57
+ /**
58
+ * Explorium /v1/businesses filters from the ICP — the COMPANY (account) side, for
59
+ * sizing the account universe (TAM). Firmographics only, no persona: the count is
60
+ * of matching companies. Field names differ from /v1/prospects (verified live):
61
+ * `country_code` (not company_country_code), `company_size` (same employee bands),
62
+ * `naics_category`. `/v1/businesses` total_results is a real count, capped at
63
+ * 60,000 (see EXPLORIUM_BUSINESS_COUNT_CAP in the connector).
64
+ */
65
+ export function icpToExploriumBusinessFilters(icp) {
66
+ const f = {};
67
+ if (icp.firmographics.geos?.length)
68
+ f.country_code = { values: icp.firmographics.geos };
69
+ if (icp.firmographics.employeeBands?.length)
70
+ f.company_size = { values: icp.firmographics.employeeBands };
71
+ if (icp.firmographics.naics?.length)
72
+ f.naics_category = { values: icp.firmographics.naics };
73
+ return f;
74
+ }
75
+ /**
76
+ * Collapse provider-agnostic employee bands ("51-200","10001+") into a single
77
+ * {min,max} envelope for APIs that take integer bounds (TheirStack). An open
78
+ * top band ("10001+") leaves max undefined.
79
+ */
80
+ export function employeeBandsToRange(bands) {
81
+ if (!bands?.length)
82
+ return {};
83
+ let min;
84
+ let max;
85
+ let openTop = false;
86
+ for (const band of bands) {
87
+ const plus = /^(\d+)\+$/.exec(band.trim());
88
+ if (plus) {
89
+ const lo = Number(plus[1]);
90
+ if (min === undefined || lo < min)
91
+ min = lo;
92
+ openTop = true;
93
+ continue;
94
+ }
95
+ const range = /^(\d+)\s*-\s*(\d+)$/.exec(band.trim());
96
+ if (range) {
97
+ const lo = Number(range[1]);
98
+ const hi = Number(range[2]);
99
+ if (min === undefined || lo < min)
100
+ min = lo;
101
+ if (max === undefined || hi > max)
102
+ max = hi;
103
+ }
104
+ }
105
+ return { min, max: openTop ? undefined : max };
106
+ }
107
+ /**
108
+ * TheirStack company-search filter from the ICP: the technographic targeting that
109
+ * Explorium can't do. `company_technology_slug_or` is the CRM/MAP buying signal
110
+ * (firmographics.technologies); employee bands become min/max bounds; geos become
111
+ * ISO2 codes (uppercased).
112
+ */
113
+ export function icpToTheirStackFilters(icp) {
114
+ const f = {};
115
+ if (icp.firmographics.technologies?.length) {
116
+ f.company_technology_slug_or = icp.firmographics.technologies.map((t) => t.trim().toLowerCase());
117
+ }
118
+ if (icp.firmographics.geos?.length) {
119
+ f.company_country_code_or = icp.firmographics.geos.map((g) => g.trim().toUpperCase());
120
+ }
121
+ const range = employeeBandsToRange(icp.firmographics.employeeBands);
122
+ if (range.min !== undefined)
123
+ f.min_employee_count = range.min;
124
+ if (range.max !== undefined)
125
+ f.max_employee_count = range.max;
126
+ return f;
127
+ }
57
128
  /**
58
129
  * Crustdata / LinkedIn seniority vocab. ICP job levels are normalized lowercase;
59
130
  * Crustdata expects these exact capitalized strings (verified: "CXO",
@@ -75,16 +146,31 @@ const CRUSTDATA_SENIORITY = {
75
146
  entry: "Entry",
76
147
  };
77
148
  /**
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.
149
+ * ICP industry keyword → LinkedIn industry names Crustdata filters on.
150
+ *
151
+ * LinkedIn renamed its industry taxonomy (v1 v2, ~2022), and data vendors
152
+ * normalize to one generation or the other. Sending ONLY the v2 names risks a
153
+ * zero-match when Crustdata stores v1 (and vice-versa) — observed live: a RevOps
154
+ * ICP whose only firmographic constraint was the v2 names returned 0 even though
155
+ * the title-only search returned plenty. So each cluster sends BOTH generations
156
+ * (OR-matched within the field): v2 ("Software Development", "IT Services and IT
157
+ * Consulting", "Technology, Information and Internet") AND v1 ("Computer
158
+ * Software", "Information Technology & Services", "Internet").
81
159
  */
82
160
  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"],
161
+ software: [
162
+ "Software Development",
163
+ "Computer Software",
164
+ "IT Services and IT Consulting",
165
+ "Information Technology & Services",
166
+ "Information Technology and Services",
167
+ "Technology, Information and Internet",
168
+ "Internet",
169
+ ],
170
+ saas: ["Software Development", "Computer Software", "IT Services and IT Consulting", "Information Technology & Services"],
171
+ "information technology & services": ["Information Technology & Services", "IT Services and IT Consulting"],
172
+ "information technology and services": ["Information Technology & Services", "IT Services and IT Consulting"],
173
+ internet: ["Internet", "Technology, Information and Internet"],
88
174
  fintech: ["Financial Services"],
89
175
  "financial services": ["Financial Services"],
90
176
  };
@@ -92,9 +178,17 @@ const CRUSTDATA_INDUSTRY = {
92
178
  * pipe0 Crustdata people-search config.filters from the ICP. `current_job_titles`
93
179
  * matches real LinkedIn title strings (case-sensitive), so keywords are Title
94
180
  * 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.
181
+ * tables above.
182
+ *
183
+ * LIVE FINDINGS (2026-06-26): `current_job_titles` is confirmed working (a
184
+ * titles-only RevOps search returns results); `current_title` is rejected (422).
185
+ * The full ICP filter returned 0 — the prime suspect is the industry vocab
186
+ * (LinkedIn v1/v2 taxonomy mismatch), now hedged by sending BOTH generations in
187
+ * CRUSTDATA_INDUSTRY above. NOT yet re-confirmed end-to-end (pipe0 credits were
188
+ * exhausted mid-investigation) — re-run `enrich acquire --source pipe0` once
189
+ * credits refill; if it still returns 0, the next suspects are the
190
+ * `current_seniority_levels` shape/values and `locations`. Note fit-scoring backs
191
+ * up PERSONA precision but NOT industry, so the industry filter is load-bearing.
98
192
  */
99
193
  export function icpToCrustdataFilters(icp) {
100
194
  const f = {};
package/dist/index.d.ts CHANGED
@@ -13,6 +13,8 @@ export { createStripeConnector, type StripeConnectorOptions } from "./connectors
13
13
  export { activeProfile, credentialsDir, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotAccessToken, resolveHubspotConnection, setActiveProfile, storeCredential, type HubspotConnection, type StoredCredential, } from "./credentials.ts";
14
14
  export { generateDemoSnapshot, type DemoSnapshotOptions } from "./demo.ts";
15
15
  export { buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, enrichRunId, inferIngestObjectType, ingestKeyValue, latestStamps, loadEnrichConfig, matchSourceRecord, parseCsv, parseEnrichConfig, resolveCrmField, selectStaleWork, sourceValueAt, stagedSourceRecords, staleDaysFor, type BuildEnrichPlanOptions, type EnrichAmbiguity, type EnrichConfig, type EnrichCounts, type EnrichFieldConfig, type EnrichMatchConfig, type EnrichMode, type EnrichObjectType, type EnrichPlanResult, type EnrichRun, type EnrichRunStore, type EnrichSourceConfig, type EnrichSourceRecord, type EnrichStamp, type EnrichWorkItem, type MatchOutcome, } from "./enrich.ts";
16
+ export { scaffoldWorkspace, starterEnrichConfig, starterIcp, starterPlaybook, type InitProvider, type InitSource, type ScaffoldFile, type ScaffoldOptions, } from "./init.ts";
17
+ export { appendCoverage, classifyAccount, classifyCoverage, computeCoverage, coverageCountsFromSnapshot, coveredAccounts, coverageToText, crmCheckableCriteria, deriveAcvFromClosedWon, deriveBuyersPerAccount, estimateTam, loadTamModel, projectEta, readCoverageTimeline, saveTamModel, tamDir, tamReportToMarkdown, type AccountTamClass, type AcvBasis, type DerivedAcv, type DerivedBuyers, type EstimateTamInput, type TamClassified, type TamCoverage, type TamCoverageCounts, type TamCrossCheck, type TamEta, type TamModel, type TamTargeting, type TamUniverse, } from "./tam.ts";
16
18
  export { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, type ApolloClient, type ApolloClientOptions, type ApolloPullKey, type ApolloPullResult, } from "./enrichApollo.ts";
17
19
  export { diffFindings, diffSnapshots, diffToMarkdown, type CollectionDiff, type FieldChange, type FindingsDrift, type RecordChange, type SnapshotDiff, } from "./diff.ts";
18
20
  export { mergeSnapshots, type MergeConflict, type MergeMatch, type MergeReport, type MergeSuggestion, } from "./merge.ts";
package/dist/index.js CHANGED
@@ -13,6 +13,8 @@ export { createStripeConnector } from "./connectors/stripe.js";
13
13
  export { activeProfile, credentialsDir, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotAccessToken, resolveHubspotConnection, setActiveProfile, storeCredential, } from "./credentials.js";
14
14
  export { generateDemoSnapshot } from "./demo.js";
15
15
  export { buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, enrichRunId, inferIngestObjectType, ingestKeyValue, latestStamps, loadEnrichConfig, matchSourceRecord, parseCsv, parseEnrichConfig, resolveCrmField, selectStaleWork, sourceValueAt, stagedSourceRecords, staleDaysFor, } from "./enrich.js";
16
+ export { scaffoldWorkspace, starterEnrichConfig, starterIcp, starterPlaybook, } from "./init.js";
17
+ export { appendCoverage, classifyAccount, classifyCoverage, computeCoverage, coverageCountsFromSnapshot, coveredAccounts, coverageToText, crmCheckableCriteria, deriveAcvFromClosedWon, deriveBuyersPerAccount, estimateTam, loadTamModel, projectEta, readCoverageTimeline, saveTamModel, tamDir, tamReportToMarkdown, } from "./tam.js";
16
18
  export { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, } from "./enrichApollo.js";
17
19
  export { diffFindings, diffSnapshots, diffToMarkdown, } from "./diff.js";
18
20
  export { mergeSnapshots, } from "./merge.js";
package/dist/init.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `fullstackgtm init` — scaffold a GTM workspace from cold scratch.
3
+ *
4
+ * The product thesis (see docs/recipes.md): the CLI ships governed PRIMITIVES;
5
+ * the user's coding agent is the orchestrator. There is deliberately no
6
+ * `outbound` mega-verb. `init` is the one piece of scaffolding that earns its
7
+ * keep — it writes the three files a workspace needs so the very first
8
+ * `enrich acquire` / `signals` / `judge` / `draft` commands work, plus a
9
+ * PLAYBOOK that points at the recipes wired with THIS workspace's source and
10
+ * provider.
11
+ *
12
+ * It is a pure file-writer: no network, no credentials, never overwrites
13
+ * without `--force`. The starter ICP is a valid (editable) example so
14
+ * `icp show` / `enrich acquire` run immediately; re-run `icp interview` to
15
+ * replace it with a real one.
16
+ */
17
+ import { type EnrichConfig } from "./enrich.ts";
18
+ import { type Icp } from "./icp.ts";
19
+ export type InitProvider = "hubspot" | "salesforce";
20
+ export type InitSource = "pipe0" | "explorium" | "linkedin";
21
+ export type ScaffoldOptions = {
22
+ /** discovery source the acquire preset + playbook are wired for. Default "pipe0". */
23
+ source?: InitSource;
24
+ /** CRM the playbook writes against. Default "hubspot". */
25
+ provider?: InitProvider;
26
+ /** profile the playbook scopes commands to (omitted = default profile). */
27
+ profile?: string;
28
+ };
29
+ export type ScaffoldFile = {
30
+ path: string;
31
+ content: string;
32
+ };
33
+ /** A generic, valid starter ICP. Edit it, or replace via `icp interview`. */
34
+ export declare function starterIcp(): Icp;
35
+ /** The acquire preset for the chosen source, with an explicit (placeholder)
36
+ * assign policy so the seam is visible — leads are never silently ownerless. */
37
+ export declare function starterEnrichConfig(source: InitSource): EnrichConfig;
38
+ /** The workspace PLAYBOOK: the cold-start + outbound-loop recipes wired with the
39
+ * chosen source/provider/profile, pointing at docs/recipes.md for the rest. */
40
+ export declare function starterPlaybook(opts: Required<Pick<ScaffoldOptions, "source" | "provider">> & {
41
+ profile?: string;
42
+ }): string;
43
+ /**
44
+ * Build the set of files `init` would write (pure — no IO). The caller decides
45
+ * which to actually write (skipping existing files unless --force).
46
+ */
47
+ export declare function scaffoldWorkspace(opts?: ScaffoldOptions): ScaffoldFile[];
package/dist/init.js ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * `fullstackgtm init` — scaffold a GTM workspace from cold scratch.
3
+ *
4
+ * The product thesis (see docs/recipes.md): the CLI ships governed PRIMITIVES;
5
+ * the user's coding agent is the orchestrator. There is deliberately no
6
+ * `outbound` mega-verb. `init` is the one piece of scaffolding that earns its
7
+ * keep — it writes the three files a workspace needs so the very first
8
+ * `enrich acquire` / `signals` / `judge` / `draft` commands work, plus a
9
+ * PLAYBOOK that points at the recipes wired with THIS workspace's source and
10
+ * provider.
11
+ *
12
+ * It is a pure file-writer: no network, no credentials, never overwrites
13
+ * without `--force`. The starter ICP is a valid (editable) example so
14
+ * `icp show` / `enrich acquire` run immediately; re-run `icp interview` to
15
+ * replace it with a real one.
16
+ */
17
+ import { builtinAcquirePreset, ENRICH_CONFIG_FILE_NAME } from "./enrich.js";
18
+ import { DEFAULT_FIT_THRESHOLD } from "./icp.js";
19
+ /** A placeholder owner id that can never match a real one, so the assign block
20
+ * is VISIBLE (leads route through it) yet safe: an unknown owner resolves to
21
+ * unassigned with a warning, never a wrong owner. Replace before acquiring. */
22
+ const PLACEHOLDER_OWNER = "REPLACE_WITH_OWNER_ID";
23
+ /** A generic, valid starter ICP. Edit it, or replace via `icp interview`. */
24
+ export function starterIcp() {
25
+ return {
26
+ name: "Example ICP — edit me (or rebuild with `fullstackgtm icp interview`)",
27
+ firmographics: {
28
+ industries: ["software", "saas"],
29
+ employeeBands: ["51-200", "201-500", "501-1000"],
30
+ geos: ["us"],
31
+ // Technographic targeting (the real RevOps signal): companies that USE a
32
+ // CRM/MAP. Drives `tam estimate|accounts --source theirstack`.
33
+ technologies: ["salesforce", "hubspot", "pipedrive"],
34
+ },
35
+ persona: {
36
+ jobLevels: ["vp", "director", "manager"],
37
+ departments: ["sales", "operations"],
38
+ titleKeywords: ["revenue operations", "revops", "sales operations", "gtm operations"],
39
+ },
40
+ scoring: { threshold: DEFAULT_FIT_THRESHOLD },
41
+ };
42
+ }
43
+ /** The acquire preset for the chosen source, with an explicit (placeholder)
44
+ * assign policy so the seam is visible — leads are never silently ownerless. */
45
+ export function starterEnrichConfig(source) {
46
+ const preset = builtinAcquirePreset(source);
47
+ if (!preset?.acquire) {
48
+ // builtinAcquirePreset covers pipe0/explorium/linkedin, so this is unreachable
49
+ // for the typed InitSource set — guard anyway rather than emit a broken file.
50
+ throw new Error(`init: no acquire preset for source "${source}"`);
51
+ }
52
+ return {
53
+ ...preset,
54
+ acquire: { ...preset.acquire, assign: { strategy: "fixed", ownerId: PLACEHOLDER_OWNER } },
55
+ };
56
+ }
57
+ function profileFlag(profile) {
58
+ return profile ? ` --profile ${profile}` : "";
59
+ }
60
+ /** The workspace PLAYBOOK: the cold-start + outbound-loop recipes wired with the
61
+ * chosen source/provider/profile, pointing at docs/recipes.md for the rest. */
62
+ export function starterPlaybook(opts) {
63
+ const { source, provider, profile } = opts;
64
+ const p = profileFlag(profile);
65
+ const loginSource = source === "linkedin" ? "heyreach" : source;
66
+ return `# Workspace playbook
67
+
68
+ Scaffolded by \`fullstackgtm init\` for **${provider}**, discovery via **${source}**${profile ? `, profile **${profile}**` : ""}.
69
+
70
+ This CLI ships governed **primitives** — there is no \`outbound\` command. **You
71
+ (the coding agent) are the orchestrator:** chain the verbs into the play the
72
+ operator wants, surface the one approve gate, and bridge the last mile to the
73
+ sender. **The package never sends** — \`draft\` produces an approved task/opener;
74
+ the actual send happens in the operator's own channel tool.
75
+
76
+ Full recipe set: **docs/recipes.md** (cold-start, the trigger→judge→draft
77
+ outbound loop, scheduled-continuous, ABM-from-companies, hygiene-gated).
78
+
79
+ ## 0. Connect (secrets via stdin/env, never argv)
80
+
81
+ \`\`\`bash
82
+ echo "$${provider === "hubspot" ? "HUBSPOT_TOKEN" : "SALESFORCE_ACCESS_TOKEN"}" | fullstackgtm login ${provider}${p}
83
+ echo "$${loginSource.toUpperCase()}_API_KEY" | fullstackgtm login ${loginSource}${p}
84
+ \`\`\`
85
+
86
+ ## 1. Edit your targeting + assignment
87
+
88
+ - \`icp.json\` — the starter ICP. Edit it, or rebuild: \`fullstackgtm icp interview\`
89
+ (an agent drives the questions) → \`fullstackgtm icp set answers.json\`.
90
+ - \`${ENRICH_CONFIG_FILE_NAME}\` — set \`acquire.assign.ownerId\` (currently
91
+ \`${PLACEHOLDER_OWNER}\`) so acquired leads are never ownerless, or pass
92
+ \`--assign-owner <id>\` per run. Tune \`acquire.budget\` (records + spend caps).
93
+
94
+ ## 2. Cold start — fill the CRM with targeted, owned, emailed leads
95
+
96
+ \`\`\`bash
97
+ fullstackgtm enrich acquire --source ${source} --provider ${provider}${p} --json # dry-run plan, writes NOTHING
98
+ fullstackgtm enrich acquire --source ${source} --provider ${provider}${p} --save # persist as needs_approval
99
+ fullstackgtm plans approve <plan-id>${p} --operations all
100
+ fullstackgtm apply --plan-id <plan-id> --provider ${provider}${p}
101
+ \`\`\`
102
+
103
+ Each lead lands owner-stamped and linked to a domain-stamped **account**, so the
104
+ signals/judge layer can watch it.
105
+
106
+ ## 3. Outbound loop — reach an account the week something changes
107
+
108
+ \`\`\`bash
109
+ fullstackgtm signals fetch --bucket job,funding --watchlist crm:<segment>${p} --save
110
+ fullstackgtm icp judge --signals-from latest --provider ${provider}${p} --save --json # pass the snapshot → resolves accountId + contact
111
+ fullstackgtm draft --from-judge latest --channel email${p} --save --json # one grounded opener per hot account, as a create_task
112
+ fullstackgtm plans approve <plan-id>${p} --operations all
113
+ fullstackgtm apply --plan-id <plan-id> --provider ${provider}${p}
114
+ # → the agent sends the approved opener via the operator's channel tool, then:
115
+ fullstackgtm signals outcome --account <domain> --contact <contactId> --result replied${p}
116
+ \`\`\`
117
+
118
+ A domain-only judge decision (account not yet in the CRM) is rejected by \`draft\`
119
+ with "acquire it first" — run step 2 for that account, then re-judge.
120
+
121
+ ## The boundary
122
+
123
+ - **Read freely. Write only through \`plans approve → apply\`.**
124
+ - **The package never sends.** \`draft\` is the last governed step.
125
+ - **You are the orchestrator.** These are starting points — compose them.
126
+ `;
127
+ }
128
+ /**
129
+ * Build the set of files `init` would write (pure — no IO). The caller decides
130
+ * which to actually write (skipping existing files unless --force).
131
+ */
132
+ export function scaffoldWorkspace(opts = {}) {
133
+ const source = opts.source ?? "pipe0";
134
+ const provider = opts.provider ?? "hubspot";
135
+ return [
136
+ { path: "icp.json", content: `${JSON.stringify(starterIcp(), null, 2)}\n` },
137
+ {
138
+ path: ENRICH_CONFIG_FILE_NAME,
139
+ content: `${JSON.stringify(starterEnrichConfig(source), null, 2)}\n`,
140
+ },
141
+ { path: "PLAYBOOK.md", content: starterPlaybook({ source, provider, profile: opts.profile }) },
142
+ ];
143
+ }
package/dist/judge.d.ts CHANGED
@@ -26,6 +26,23 @@ import type { CanonicalGtmSnapshot } from "./types.ts";
26
26
  export type JudgeDecisionKind = "send" | "nurture" | "skip";
27
27
  export type JudgeDecision = {
28
28
  accountDomain: string;
29
+ /**
30
+ * The CRM account this domain resolves to, when a snapshot was provided.
31
+ * Absent = the account is not in the CRM (a net-new domain) — downstream
32
+ * verbs must acquire it before they can write against it.
33
+ */
34
+ accountId?: string;
35
+ /**
36
+ * The contact at the account to reach, when resolvable from the snapshot —
37
+ * the answer to "who do I message at this hot account". `draft` targets this
38
+ * contact's id; absent contact + present accountId targets the account.
39
+ */
40
+ contact?: ContactRef;
41
+ /**
42
+ * All in-CRM contacts at the account (primary first), capped — so an agent can
43
+ * multi-thread beyond the single primary. `contact` is `contacts[0]`.
44
+ */
45
+ contacts?: ContactRef[];
29
46
  /** 0-100. */
30
47
  score: number;
31
48
  decision: JudgeDecisionKind;
@@ -125,9 +142,9 @@ export declare function scoreAccount(opts: {
125
142
  */
126
143
  export declare function accountRecentlyTouched(accountDomain: string, snapshot: CanonicalGtmSnapshot, now?: Date, windowDays?: number): boolean;
127
144
  /**
128
- * Find the best-matching contact for an account from the snapshot, for fit
129
- * scoring: the account's contacts, preferring one with a title (a title is what
130
- * fit scores on). Returns undefined when the account/contact isn't in snapshot.
145
+ * Best-matching contact for an account, shaped for fit scoring (the title is
146
+ * what `scoreProspectAgainstIcp` reads). Returns undefined when the
147
+ * account/contact isn't in the snapshot.
131
148
  */
132
149
  export declare function bestContactForAccount(accountDomain: string, snapshot: CanonicalGtmSnapshot): {
133
150
  jobTitle?: string;
@@ -135,6 +152,22 @@ export declare function bestContactForAccount(accountDomain: string, snapshot: C
135
152
  jobDepartment?: string;
136
153
  headline?: string;
137
154
  } | undefined;
155
+ /**
156
+ * Resolve the CRM target for an account domain: its `accountId` (when the
157
+ * account exists in the snapshot) and the best `contact` to reach (id + email +
158
+ * title). This is what `draft` writes against — a real record id, never the
159
+ * domain. `{}` when the account is not in the CRM (a net-new domain).
160
+ */
161
+ export type ContactRef = {
162
+ id: string;
163
+ email?: string;
164
+ title?: string;
165
+ };
166
+ export declare function resolveAccountTarget(accountDomain: string, snapshot: CanonicalGtmSnapshot): {
167
+ accountId?: string;
168
+ contact?: ContactRef;
169
+ contacts?: ContactRef[];
170
+ };
138
171
  /**
139
172
  * Build a `JudgeDecision` from an `AccountScore` with the deterministic baseline:
140
173
  * whyNow taken VERBATIM from the top credited signal's quote (grounded by