fullstackgtm 0.42.0 → 0.43.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.
@@ -80,28 +80,52 @@ export function createSalesforceConnector(
80
80
  // confirmed miss is created, and an ambiguous name (>1 match) is refused.
81
81
  // Caches within the run so the same name is never created twice — shared by
82
82
  // `link_record`'s create:<Name> path and `create_record`'s company linking.
83
- async function resolveOrCreateAccountByName(
83
+ async function resolveOrCreateAccount(
84
84
  name: string,
85
+ domain?: string,
85
86
  ): Promise<{ id: string; createdNew: boolean } | { ambiguous: number }> {
86
- const nameKey = name.toLowerCase();
87
- const cached = createdAccountsByName.get(nameKey);
87
+ const cacheKey = domain ? `d:${domain.toLowerCase()}` : `n:${name.toLowerCase()}`;
88
+ const cached = createdAccountsByName.get(cacheKey);
88
89
  if (cached) return { id: cached, createdNew: false };
89
- const soqlName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
90
- const matches = await query(`SELECT Id FROM Account WHERE Name = '${soqlName}' LIMIT 3`);
91
- if (matches.length > 1) return { ambiguous: matches.length };
90
+ const esc = (v: string) => v.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
91
+
92
+ // 1. Domain-first match on Website (the accurate key; Website often stores a
93
+ // full URL, so match by substring).
94
+ if (domain) {
95
+ const byDomain = await query(`SELECT Id FROM Account WHERE Website LIKE '%${esc(domain)}%' LIMIT 3`);
96
+ if (byDomain.length > 1) return { ambiguous: byDomain.length };
97
+ if (byDomain.length === 1) {
98
+ const id = String(byDomain[0].Id);
99
+ createdAccountsByName.set(cacheKey, id);
100
+ return { id, createdNew: false };
101
+ }
102
+ }
103
+ // 2. Exact-name match; stamp Website if the account has none (fill-blank).
104
+ const byName = await query(`SELECT Id, Website FROM Account WHERE Name = '${esc(name)}' LIMIT 3`);
105
+ if (byName.length > 1) return { ambiguous: byName.length };
92
106
  let id: string;
93
107
  let createdNew = false;
94
- if (matches.length === 1) {
95
- id = String(matches[0].Id);
108
+ if (byName.length === 1) {
109
+ id = String(byName[0].Id);
110
+ if (domain && !stringOrUndefined(byName[0].Website)) {
111
+ try {
112
+ await request(`/services/data/${apiVersion}/sobjects/Account/${encodeURIComponent(id)}`, {
113
+ method: "PATCH",
114
+ body: JSON.stringify({ Website: domain }),
115
+ });
116
+ } catch {
117
+ // best-effort fill — a failed Website stamp must not sink the create.
118
+ }
119
+ }
96
120
  } else {
97
121
  const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
98
122
  method: "POST",
99
- body: JSON.stringify({ Name: name }),
123
+ body: JSON.stringify({ Name: name, ...(domain ? { Website: domain } : {}) }),
100
124
  });
101
125
  id = String(created.id);
102
126
  createdNew = true;
103
127
  }
104
- createdAccountsByName.set(nameKey, id);
128
+ createdAccountsByName.set(cacheKey, id);
105
129
  return { id, createdNew };
106
130
  }
107
131
 
@@ -534,7 +558,7 @@ export function createSalesforceConnector(
534
558
  }
535
559
 
536
560
  if (operation.objectType === "account") {
537
- const resolved = await resolveOrCreateAccountByName(matchValue);
561
+ const resolved = await resolveOrCreateAccount(matchValue);
538
562
  if ("ambiguous" in resolved) {
539
563
  return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — ${resolved.ambiguous} accounts named "${matchValue}". Not creating.` };
540
564
  }
@@ -574,7 +598,7 @@ export function createSalesforceConnector(
574
598
  let companyNote = "";
575
599
  if (payload.associateCompanyName) {
576
600
  try {
577
- const resolved = await resolveOrCreateAccountByName(payload.associateCompanyName);
601
+ const resolved = await resolveOrCreateAccount(payload.associateCompanyName, payload.associateCompanyDomain);
578
602
  if ("ambiguous" in resolved) {
579
603
  companyNote = ` Account "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
580
604
  } else {
@@ -582,7 +606,8 @@ export function createSalesforceConnector(
582
606
  method: "PATCH",
583
607
  body: JSON.stringify({ AccountId: resolved.id }),
584
608
  });
585
- companyNote = ` Linked to account "${payload.associateCompanyName}" (${resolved.id}).`;
609
+ const domainNote = payload.associateCompanyDomain ? ` (${payload.associateCompanyDomain})` : "";
610
+ companyNote = ` Linked to account "${payload.associateCompanyName}"${domainNote} (${resolved.id}).`;
586
611
  }
587
612
  } catch (error) {
588
613
  // Association is best-effort — the contact create already succeeded.
@@ -726,7 +751,7 @@ export function createSalesforceConnector(
726
751
  if (!name) {
727
752
  return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
728
753
  }
729
- const resolved = await resolveOrCreateAccountByName(name);
754
+ const resolved = await resolveOrCreateAccount(name);
730
755
  if ("ambiguous" in resolved) {
731
756
  return {
732
757
  operationId: operation.id,
package/src/draft.ts CHANGED
@@ -305,9 +305,6 @@ export function draft(opts: {
305
305
  const minScore = opts.minScore ?? 80;
306
306
  const channel: DraftChannel = opts.channel ?? "task";
307
307
  const freshnessDays = opts.freshnessDays ?? DEFAULT_FRESHNESS_DAYS;
308
- // `task`/`email`/`linkedin` all write a CRM task through the same gate; the
309
- // object the task hangs off is the contact when known, else the account.
310
- const objectType: GtmObjectType = "contact";
311
308
 
312
309
  const hot = opts.decisions
313
310
  .filter((d) => d.decision === "send" && d.score >= minScore)
@@ -349,11 +346,35 @@ export function draft(opts: {
349
346
  continue;
350
347
  }
351
348
 
349
+ // Resolve a REAL CRM target from the judge decision: a contact id (preferred
350
+ // — that's who we message), else the account id. A domain-only decision
351
+ // (the account isn't in the CRM yet) cannot hang a task off a record — reject
352
+ // it with the fix, instead of forging a contact-typed op carrying a domain.
353
+ let objectType: GtmObjectType;
354
+ let objectId: string;
355
+ let targetNote: string;
356
+ if (decision.contact?.id) {
357
+ objectType = "contact";
358
+ objectId = decision.contact.id;
359
+ targetNote = `contact ${decision.contact.email ?? decision.contact.id}`;
360
+ } else if (decision.accountId) {
361
+ objectType = "account";
362
+ objectId = decision.accountId;
363
+ targetNote = `account ${domain}`;
364
+ } else {
365
+ rejected.push({
366
+ accountDomain: domain,
367
+ opener,
368
+ reason: `account ${domain} is not in the CRM — acquire it first (enrich acquire), then judge with a snapshot, before drafting`,
369
+ });
370
+ continue;
371
+ }
372
+
352
373
  const stale = isStaleTrigger(signal.firstSeen, now, freshnessDays);
353
374
  const ev = draftEvidence(signal, nowIso);
354
375
  evidence.push(ev);
355
376
 
356
- const reasonBase = `Signal-grounded opener for ${domain}: "${signal.trigger}"`;
377
+ const reasonBase = `Signal-grounded opener for ${domain} → ${targetNote}: "${signal.trigger}"`;
357
378
  const reason = stale
358
379
  ? `${reasonBase} [staleTrigger: grounding signal first seen ${signal.firstSeen}, older than ${freshnessDays}d — could this have gone out last month unchanged?]`
359
380
  : reasonBase;
@@ -361,7 +382,7 @@ export function draft(opts: {
361
382
  operations.push({
362
383
  id: draftOperationId(channel, domain),
363
384
  objectType,
364
- objectId: domain,
385
+ objectId,
365
386
  operation: "create_task",
366
387
  field: "follow_up_task",
367
388
  beforeValue: null,
package/src/enrich.ts CHANGED
@@ -92,6 +92,12 @@ export type AcquireCreateMap = {
92
92
  matchKey: string;
93
93
  /** Optional source path to a company name; the connector resolves-or-creates it. */
94
94
  associateCompanyFrom?: string;
95
+ /**
96
+ * Optional source path to the company domain. With it (or a derivable email
97
+ * domain) the connector resolves the account by domain and stamps the domain
98
+ * on it — so the lead's account is signal-watchable, not just a text field.
99
+ */
100
+ associateCompanyDomainFrom?: string;
95
101
  };
96
102
 
97
103
  /**
@@ -392,6 +398,8 @@ export function builtinAcquirePreset(source: string | undefined): EnrichConfig |
392
398
  company: "companyName",
393
399
  email: "email",
394
400
  },
401
+ associateCompanyFrom: "companyName",
402
+ associateCompanyDomainFrom: "companyDomain",
395
403
  },
396
404
  },
397
405
  },
@@ -418,6 +426,8 @@ export function builtinAcquirePreset(source: string | undefined): EnrichConfig |
418
426
  company: "companyName",
419
427
  hs_linkedin_url: "linkedin",
420
428
  },
429
+ associateCompanyFrom: "companyName",
430
+ associateCompanyDomainFrom: "companyDomain",
421
431
  },
422
432
  },
423
433
  },
@@ -733,6 +743,27 @@ function isEmptyValue(value: unknown): boolean {
733
743
  return value === undefined || value === null || (typeof value === "string" && value.trim() === "");
734
744
  }
735
745
 
746
+ /** Bare registrable host from a domain or URL ("https://www.x.com/a" → "x.com"). */
747
+ function normalizeCompanyDomain(value: unknown): string | undefined {
748
+ if (typeof value !== "string" || !value.trim()) return undefined;
749
+ const host = value
750
+ .trim()
751
+ .toLowerCase()
752
+ .replace(/^https?:\/\//, "")
753
+ .replace(/^www\./, "")
754
+ .replace(/\/.*$/, "")
755
+ .replace(/\s+/g, "");
756
+ return host && host.includes(".") ? host : undefined;
757
+ }
758
+
759
+ /** The domain of a work email ("vp@acme.com" → "acme.com"), else undefined. */
760
+ function domainFromEmail(value: unknown): string | undefined {
761
+ if (typeof value !== "string") return undefined;
762
+ const at = value.lastIndexOf("@");
763
+ if (at < 0) return undefined;
764
+ return normalizeCompanyDomain(value.slice(at + 1));
765
+ }
766
+
736
767
  /** Values compare as trimmed strings; numbers compare numerically. */
737
768
  function sameValue(a: unknown, b: unknown): boolean {
738
769
  if (isEmptyValue(a) && isEmptyValue(b)) return true;
@@ -1130,6 +1161,20 @@ export function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanR
1130
1161
  const companyValue = sourceValueAt(record.payload, createMap.associateCompanyFrom);
1131
1162
  if (!isEmptyValue(companyValue)) associateCompanyName = String(companyValue);
1132
1163
  }
1164
+ // The company domain makes the lead's account signal-watchable. Prefer the
1165
+ // configured source path; fall back to the work email's domain (free, and
1166
+ // for B2B leads the email domain IS the company domain).
1167
+ let associateCompanyDomain: string | undefined;
1168
+ if (associateCompanyName) {
1169
+ const fromPath = createMap.associateCompanyDomainFrom
1170
+ ? sourceValueAt(record.payload, createMap.associateCompanyDomainFrom)
1171
+ : undefined;
1172
+ const candidate = !isEmptyValue(fromPath)
1173
+ ? String(fromPath)
1174
+ : domainFromEmail(sourceValueAt(record.payload, "email"));
1175
+ const normalized = normalizeCompanyDomain(candidate);
1176
+ if (normalized) associateCompanyDomain = normalized;
1177
+ }
1133
1178
 
1134
1179
  const recordEvidence = evidenceFor(
1135
1180
  source,
@@ -1162,8 +1207,13 @@ export function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanR
1162
1207
  source,
1163
1208
  estCostUsd: costPerRecord,
1164
1209
  associateCompanyName,
1210
+ ...(associateCompanyDomain ? { associateCompanyDomain } : {}),
1165
1211
  ...(ownerId ? { ownerId, assignedBy } : {}),
1166
1212
  };
1213
+ // Surface the account link in the dry-run so it is not a silent side-effect.
1214
+ const accountNote = associateCompanyName
1215
+ ? ` Resolves/creates its account "${associateCompanyName}"${associateCompanyDomain ? ` (${associateCompanyDomain})` : ""} and links the lead.`
1216
+ : "";
1167
1217
  operations.push({
1168
1218
  id: `op_acq_${fnv1a(`${source}:${record.objectType}:${matchValue}`)}`,
1169
1219
  objectType: canonicalObjectType(record.objectType),
@@ -1173,7 +1223,7 @@ export function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanR
1173
1223
  afterValue: payload,
1174
1224
  reason:
1175
1225
  `${source} sourced net-new ${record.objectType} "${describeSourceRecord(record)}" ` +
1176
- `(${createMap.matchKey}=${matchValue}); no CRM match — create as a lead.`,
1226
+ `(${createMap.matchKey}=${matchValue}); no CRM match — create as a lead.${accountNote}`,
1177
1227
  sourceRuleOrPolicy: `acquire:${source}`,
1178
1228
  riskLevel: "medium",
1179
1229
  approvalRequired: true,
package/src/health.ts CHANGED
@@ -35,6 +35,18 @@ export type HealthEntry = {
35
35
  byRule: Record<string, number>;
36
36
  /** Finding count per severity. */
37
37
  severityCounts: Record<AuditFindingSeverity, number>;
38
+ /**
39
+ * Per-object-type breakdown — so "is my contact data clean but my pipeline
40
+ * messy?" is answerable without re-auditing. Each type carries its own
41
+ * record-normalized score (same curve as the overall score, scoped to that
42
+ * type's records + findings).
43
+ */
44
+ byObjectType: Record<"account" | "contact" | "deal", {
45
+ records: number;
46
+ findings: number;
47
+ weightedFindings: number;
48
+ score: number;
49
+ }>;
38
50
  };
39
51
 
40
52
  export type HealthRuleDelta = {
@@ -68,11 +80,21 @@ export type HealthRollup = {
68
80
  export function computeHealth(plan: PatchPlan, snapshot: CanonicalGtmSnapshot, at: string): HealthEntry {
69
81
  const byRule: Record<string, number> = {};
70
82
  const severityCounts: Record<AuditFindingSeverity, number> = { info: 0, warning: 0, critical: 0 };
83
+ const typeTally: Record<"account" | "contact" | "deal", { findings: number; weightedFindings: number }> = {
84
+ account: { findings: 0, weightedFindings: 0 },
85
+ contact: { findings: 0, weightedFindings: 0 },
86
+ deal: { findings: 0, weightedFindings: 0 },
87
+ };
71
88
  let weightedFindings = 0;
72
89
  for (const finding of plan.findings) {
73
90
  byRule[finding.ruleId] = (byRule[finding.ruleId] ?? 0) + 1;
74
91
  severityCounts[finding.severity] += 1;
75
92
  weightedFindings += SEVERITY_WEIGHT[finding.severity];
93
+ const t = finding.objectType;
94
+ if (t === "account" || t === "contact" || t === "deal") {
95
+ typeTally[t].findings += 1;
96
+ typeTally[t].weightedFindings += SEVERITY_WEIGHT[finding.severity];
97
+ }
76
98
  }
77
99
 
78
100
  const records = {
@@ -82,8 +104,23 @@ export function computeHealth(plan: PatchPlan, snapshot: CanonicalGtmSnapshot, a
82
104
  total: snapshot.accounts.length + snapshot.contacts.length + snapshot.deals.length,
83
105
  };
84
106
 
85
- const penalty = weightedFindings / Math.max(records.total, 1);
86
- const score = Math.round(100 / (1 + penalty));
107
+ const scoreFor = (weighted: number, recordCount: number) =>
108
+ Math.round(100 / (1 + weighted / Math.max(recordCount, 1)));
109
+ const recordsByType = { account: records.accounts, contact: records.contacts, deal: records.deals };
110
+ const byObjectType = (["account", "contact", "deal"] as const).reduce(
111
+ (acc, t) => {
112
+ acc[t] = {
113
+ records: recordsByType[t],
114
+ findings: typeTally[t].findings,
115
+ weightedFindings: typeTally[t].weightedFindings,
116
+ score: scoreFor(typeTally[t].weightedFindings, recordsByType[t]),
117
+ };
118
+ return acc;
119
+ },
120
+ {} as HealthEntry["byObjectType"],
121
+ );
122
+
123
+ const score = scoreFor(weightedFindings, records.total);
87
124
 
88
125
  return {
89
126
  at,
@@ -94,6 +131,7 @@ export function computeHealth(plan: PatchPlan, snapshot: CanonicalGtmSnapshot, a
94
131
  records,
95
132
  byRule,
96
133
  severityCounts,
134
+ byObjectType,
97
135
  };
98
136
  }
99
137
 
@@ -155,6 +193,13 @@ export function healthToMarkdown(rollup: HealthRollup): string {
155
193
  "",
156
194
  );
157
195
 
196
+ lines.push("## By object type", "", "| Type | Score | Findings | Records |", "| --- | --- | --- | --- |");
197
+ for (const t of ["account", "contact", "deal"] as const) {
198
+ const b = current.byObjectType[t];
199
+ lines.push(`| ${t} | ${b.score}/100 | ${b.findings} | ${b.records} |`);
200
+ }
201
+ lines.push("");
202
+
158
203
  if (rollup.history.length > 1) {
159
204
  lines.push("## Trend", "");
160
205
  for (const point of rollup.history) {
package/src/index.ts CHANGED
@@ -97,6 +97,16 @@ export {
97
97
  type EnrichWorkItem,
98
98
  type MatchOutcome,
99
99
  } from "./enrich.ts";
100
+ export {
101
+ scaffoldWorkspace,
102
+ starterEnrichConfig,
103
+ starterIcp,
104
+ starterPlaybook,
105
+ type InitProvider,
106
+ type InitSource,
107
+ type ScaffoldFile,
108
+ type ScaffoldOptions,
109
+ } from "./init.ts";
100
110
  export {
101
111
  apolloPullKeysForAppend,
102
112
  apolloPullKeysForRefresh,
package/src/init.ts ADDED
@@ -0,0 +1,163 @@
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
+
18
+ import { builtinAcquirePreset, ENRICH_CONFIG_FILE_NAME, type EnrichConfig } from "./enrich.ts";
19
+ import { DEFAULT_FIT_THRESHOLD, type Icp } from "./icp.ts";
20
+
21
+ export type InitProvider = "hubspot" | "salesforce";
22
+ export type InitSource = "pipe0" | "explorium" | "linkedin";
23
+
24
+ export type ScaffoldOptions = {
25
+ /** discovery source the acquire preset + playbook are wired for. Default "pipe0". */
26
+ source?: InitSource;
27
+ /** CRM the playbook writes against. Default "hubspot". */
28
+ provider?: InitProvider;
29
+ /** profile the playbook scopes commands to (omitted = default profile). */
30
+ profile?: string;
31
+ };
32
+
33
+ export type ScaffoldFile = { path: string; content: string };
34
+
35
+ /** A placeholder owner id that can never match a real one, so the assign block
36
+ * is VISIBLE (leads route through it) yet safe: an unknown owner resolves to
37
+ * unassigned with a warning, never a wrong owner. Replace before acquiring. */
38
+ const PLACEHOLDER_OWNER = "REPLACE_WITH_OWNER_ID";
39
+
40
+ /** A generic, valid starter ICP. Edit it, or replace via `icp interview`. */
41
+ export function starterIcp(): Icp {
42
+ return {
43
+ name: "Example ICP — edit me (or rebuild with `fullstackgtm icp interview`)",
44
+ firmographics: {
45
+ industries: ["software", "saas"],
46
+ employeeBands: ["51-200", "201-500", "501-1000"],
47
+ geos: ["us"],
48
+ },
49
+ persona: {
50
+ jobLevels: ["vp", "director", "manager"],
51
+ departments: ["sales", "operations"],
52
+ titleKeywords: ["revenue operations", "revops", "sales operations", "gtm operations"],
53
+ },
54
+ scoring: { threshold: DEFAULT_FIT_THRESHOLD },
55
+ };
56
+ }
57
+
58
+ /** The acquire preset for the chosen source, with an explicit (placeholder)
59
+ * assign policy so the seam is visible — leads are never silently ownerless. */
60
+ export function starterEnrichConfig(source: InitSource): EnrichConfig {
61
+ const preset = builtinAcquirePreset(source);
62
+ if (!preset?.acquire) {
63
+ // builtinAcquirePreset covers pipe0/explorium/linkedin, so this is unreachable
64
+ // for the typed InitSource set — guard anyway rather than emit a broken file.
65
+ throw new Error(`init: no acquire preset for source "${source}"`);
66
+ }
67
+ return {
68
+ ...preset,
69
+ acquire: { ...preset.acquire, assign: { strategy: "fixed", ownerId: PLACEHOLDER_OWNER } },
70
+ };
71
+ }
72
+
73
+ function profileFlag(profile?: string): string {
74
+ return profile ? ` --profile ${profile}` : "";
75
+ }
76
+
77
+ /** The workspace PLAYBOOK: the cold-start + outbound-loop recipes wired with the
78
+ * chosen source/provider/profile, pointing at docs/recipes.md for the rest. */
79
+ export function starterPlaybook(opts: Required<Pick<ScaffoldOptions, "source" | "provider">> & { profile?: string }): string {
80
+ const { source, provider, profile } = opts;
81
+ const p = profileFlag(profile);
82
+ const loginSource = source === "linkedin" ? "heyreach" : source;
83
+ return `# Workspace playbook
84
+
85
+ Scaffolded by \`fullstackgtm init\` for **${provider}**, discovery via **${source}**${
86
+ profile ? `, profile **${profile}**` : ""
87
+ }.
88
+
89
+ This CLI ships governed **primitives** — there is no \`outbound\` command. **You
90
+ (the coding agent) are the orchestrator:** chain the verbs into the play the
91
+ operator wants, surface the one approve gate, and bridge the last mile to the
92
+ sender. **The package never sends** — \`draft\` produces an approved task/opener;
93
+ the actual send happens in the operator's own channel tool.
94
+
95
+ Full recipe set: **docs/recipes.md** (cold-start, the trigger→judge→draft
96
+ outbound loop, scheduled-continuous, ABM-from-companies, hygiene-gated).
97
+
98
+ ## 0. Connect (secrets via stdin/env, never argv)
99
+
100
+ \`\`\`bash
101
+ echo "$${provider === "hubspot" ? "HUBSPOT_TOKEN" : "SALESFORCE_ACCESS_TOKEN"}" | fullstackgtm login ${provider}${p}
102
+ echo "$${loginSource.toUpperCase()}_API_KEY" | fullstackgtm login ${loginSource}${p}
103
+ \`\`\`
104
+
105
+ ## 1. Edit your targeting + assignment
106
+
107
+ - \`icp.json\` — the starter ICP. Edit it, or rebuild: \`fullstackgtm icp interview\`
108
+ (an agent drives the questions) → \`fullstackgtm icp set answers.json\`.
109
+ - \`${ENRICH_CONFIG_FILE_NAME}\` — set \`acquire.assign.ownerId\` (currently
110
+ \`${PLACEHOLDER_OWNER}\`) so acquired leads are never ownerless, or pass
111
+ \`--assign-owner <id>\` per run. Tune \`acquire.budget\` (records + spend caps).
112
+
113
+ ## 2. Cold start — fill the CRM with targeted, owned, emailed leads
114
+
115
+ \`\`\`bash
116
+ fullstackgtm enrich acquire --source ${source} --provider ${provider}${p} --json # dry-run plan, writes NOTHING
117
+ fullstackgtm enrich acquire --source ${source} --provider ${provider}${p} --save # persist as needs_approval
118
+ fullstackgtm plans approve <plan-id>${p} --operations all
119
+ fullstackgtm apply --plan-id <plan-id> --provider ${provider}${p}
120
+ \`\`\`
121
+
122
+ Each lead lands owner-stamped and linked to a domain-stamped **account**, so the
123
+ signals/judge layer can watch it.
124
+
125
+ ## 3. Outbound loop — reach an account the week something changes
126
+
127
+ \`\`\`bash
128
+ fullstackgtm signals fetch --bucket job,funding --watchlist crm:<segment>${p} --save
129
+ fullstackgtm icp judge --signals-from latest --provider ${provider}${p} --save --json # pass the snapshot → resolves accountId + contact
130
+ fullstackgtm draft --from-judge latest --channel email${p} --save --json # one grounded opener per hot account, as a create_task
131
+ fullstackgtm plans approve <plan-id>${p} --operations all
132
+ fullstackgtm apply --plan-id <plan-id> --provider ${provider}${p}
133
+ # → the agent sends the approved opener via the operator's channel tool, then:
134
+ fullstackgtm signals outcome --account <domain> --contact <contactId> --result replied${p}
135
+ \`\`\`
136
+
137
+ A domain-only judge decision (account not yet in the CRM) is rejected by \`draft\`
138
+ with "acquire it first" — run step 2 for that account, then re-judge.
139
+
140
+ ## The boundary
141
+
142
+ - **Read freely. Write only through \`plans approve → apply\`.**
143
+ - **The package never sends.** \`draft\` is the last governed step.
144
+ - **You are the orchestrator.** These are starting points — compose them.
145
+ `;
146
+ }
147
+
148
+ /**
149
+ * Build the set of files `init` would write (pure — no IO). The caller decides
150
+ * which to actually write (skipping existing files unless --force).
151
+ */
152
+ export function scaffoldWorkspace(opts: ScaffoldOptions = {}): ScaffoldFile[] {
153
+ const source: InitSource = opts.source ?? "pipe0";
154
+ const provider: InitProvider = opts.provider ?? "hubspot";
155
+ return [
156
+ { path: "icp.json", content: `${JSON.stringify(starterIcp(), null, 2)}\n` },
157
+ {
158
+ path: ENRICH_CONFIG_FILE_NAME,
159
+ content: `${JSON.stringify(starterEnrichConfig(source), null, 2)}\n`,
160
+ },
161
+ { path: "PLAYBOOK.md", content: starterPlaybook({ source, provider, profile: opts.profile }) },
162
+ ];
163
+ }
package/src/judge.ts CHANGED
@@ -35,6 +35,23 @@ export type JudgeDecisionKind = "send" | "nurture" | "skip";
35
35
 
36
36
  export type JudgeDecision = {
37
37
  accountDomain: string;
38
+ /**
39
+ * The CRM account this domain resolves to, when a snapshot was provided.
40
+ * Absent = the account is not in the CRM (a net-new domain) — downstream
41
+ * verbs must acquire it before they can write against it.
42
+ */
43
+ accountId?: string;
44
+ /**
45
+ * The contact at the account to reach, when resolvable from the snapshot —
46
+ * the answer to "who do I message at this hot account". `draft` targets this
47
+ * contact's id; absent contact + present accountId targets the account.
48
+ */
49
+ contact?: ContactRef;
50
+ /**
51
+ * All in-CRM contacts at the account (primary first), capped — so an agent can
52
+ * multi-thread beyond the single primary. `contact` is `contacts[0]`.
53
+ */
54
+ contacts?: ContactRef[];
38
55
  /** 0-100. */
39
56
  score: number;
40
57
  decision: JudgeDecisionKind;
@@ -333,22 +350,75 @@ export function accountRecentlyTouched(
333
350
  }
334
351
 
335
352
  /**
336
- * Find the best-matching contact for an account from the snapshot, for fit
337
- * scoring: the account's contacts, preferring one with a title (a title is what
338
- * fit scores on). Returns undefined when the account/contact isn't in snapshot.
353
+ * Resolve a signal's account domain to the CRM account record and the best
354
+ * contact at it (preferring one with a title). The single domain→account→contact
355
+ * join used by both fit scoring and target surfacing so "who do I message at
356
+ * this account" is computed once and consistently.
339
357
  */
340
- export function bestContactForAccount(
358
+ function findAccountAndBestContact(
341
359
  accountDomain: string,
342
360
  snapshot: CanonicalGtmSnapshot,
343
- ): { jobTitle?: string; jobLevel?: string; jobDepartment?: string; headline?: string } | undefined {
361
+ ): {
362
+ account: CanonicalGtmSnapshot["accounts"][number];
363
+ contact?: CanonicalGtmSnapshot["contacts"][number];
364
+ contacts: CanonicalGtmSnapshot["contacts"];
365
+ } | undefined {
344
366
  const domain = normalizeAccountDomain(accountDomain);
345
367
  if (!domain) return undefined;
346
368
  const account = (snapshot.accounts ?? []).find((a) => normalizeAccountDomain(a.domain ?? "") === domain);
347
369
  if (!account) return undefined;
348
- const contacts = (snapshot.contacts ?? []).filter((c) => c.accountId === account.id);
349
- const withTitle = contacts.find((c) => c.title) ?? contacts[0];
350
- if (!withTitle) return undefined;
351
- return { jobTitle: withTitle.title };
370
+ // Titled contacts first (a title is what fit scores on); the first is primary.
371
+ const contacts = (snapshot.contacts ?? [])
372
+ .filter((c) => c.accountId === account.id)
373
+ .sort((a, b) => (b.title ? 1 : 0) - (a.title ? 1 : 0));
374
+ return { account, contact: contacts[0], contacts };
375
+ }
376
+
377
+ /**
378
+ * Best-matching contact for an account, shaped for fit scoring (the title is
379
+ * what `scoreProspectAgainstIcp` reads). Returns undefined when the
380
+ * account/contact isn't in the snapshot.
381
+ */
382
+ export function bestContactForAccount(
383
+ accountDomain: string,
384
+ snapshot: CanonicalGtmSnapshot,
385
+ ): { jobTitle?: string; jobLevel?: string; jobDepartment?: string; headline?: string } | undefined {
386
+ const found = findAccountAndBestContact(accountDomain, snapshot);
387
+ if (!found?.contact) return undefined;
388
+ return { jobTitle: found.contact.title };
389
+ }
390
+
391
+ /**
392
+ * Resolve the CRM target for an account domain: its `accountId` (when the
393
+ * account exists in the snapshot) and the best `contact` to reach (id + email +
394
+ * title). This is what `draft` writes against — a real record id, never the
395
+ * domain. `{}` when the account is not in the CRM (a net-new domain).
396
+ */
397
+ export type ContactRef = { id: string; email?: string; title?: string };
398
+
399
+ /** Cap on candidate contacts surfaced per account (the rest stay in the CRM). */
400
+ const MAX_CANDIDATE_CONTACTS = 10;
401
+
402
+ const toContactRef = (c: CanonicalGtmSnapshot["contacts"][number]): ContactRef => ({
403
+ id: c.id,
404
+ ...(c.email ? { email: c.email } : {}),
405
+ ...(c.title ? { title: c.title } : {}),
406
+ });
407
+
408
+ export function resolveAccountTarget(
409
+ accountDomain: string,
410
+ snapshot: CanonicalGtmSnapshot,
411
+ ): { accountId?: string; contact?: ContactRef; contacts?: ContactRef[] } {
412
+ const found = findAccountAndBestContact(accountDomain, snapshot);
413
+ if (!found) return {};
414
+ const { account, contact, contacts } = found;
415
+ return {
416
+ accountId: account.id,
417
+ ...(contact ? { contact: toContactRef(contact) } : {}),
418
+ // The candidate contacts at the account, so an agent can multi-thread
419
+ // beyond the single primary. Capped; primary is contacts[0].
420
+ ...(contacts.length ? { contacts: contacts.slice(0, MAX_CANDIDATE_CONTACTS).map(toContactRef) } : {}),
421
+ };
352
422
  }
353
423
 
354
424
  // ---------------------------------------------------------------------------
@@ -559,10 +629,11 @@ export async function judgeSignals(opts: {
559
629
 
560
630
  const decisions: JudgeDecision[] = [];
561
631
  for (const [domain, signals] of byAccount) {
632
+ // Memory + fit stay gated on --with-history (scoring semantics unchanged).
562
633
  const recentlyTouched =
563
634
  opts.withHistory && opts.snapshot ? accountRecentlyTouched(domain, opts.snapshot, now) : false;
564
635
  const bestContact =
565
- opts.icp && opts.snapshot ? bestContactForAccount(domain, opts.snapshot) : undefined;
636
+ opts.withHistory && opts.icp && opts.snapshot ? bestContactForAccount(domain, opts.snapshot) : undefined;
566
637
  const score = scoreAccount({
567
638
  accountDomain: domain,
568
639
  signals,
@@ -578,7 +649,10 @@ export async function judgeSignals(opts: {
578
649
  opts.llm && opts.promptTemplate
579
650
  ? await judgeDecisionLlm(score, opts.promptTemplate, opts.llm)
580
651
  : deterministicDecision(score);
581
- decisions.push(decision);
652
+ // Surface the CRM target (accountId + best contact) whenever a snapshot is
653
+ // present — independent of --with-history. This is what makes a decision
654
+ // actionable: draft writes against contact.id / accountId, never the domain.
655
+ decisions.push(opts.snapshot ? { ...decision, ...resolveAccountTarget(domain, opts.snapshot) } : decision);
582
656
  }
583
657
 
584
658
  return decisions.sort((a, b) => b.score - a.score || a.accountDomain.localeCompare(b.accountDomain));