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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,86 @@ The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md)
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.43.0] — 2026-06-25
11
+
12
+ ### Added
13
+
14
+ - **`init` — scaffold a GTM workspace from cold scratch.** `fullstackgtm init
15
+ [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce]` writes
16
+ three files so the first acquire/signals/judge/draft commands work: a valid
17
+ starter `icp.json`, an acquire-ready `enrich.config.json` (the source preset
18
+ plus an explicit `acquire.assign` seam, placeholder owner id, so leads are
19
+ never silently ownerless), and a `PLAYBOOK.md` wired with the cold-start and
20
+ outbound-loop recipes for this workspace's source/provider/profile. Pure
21
+ file-writer: no network, keeps existing files unless `--force`. Library:
22
+ `scaffoldWorkspace`, `starterIcp`, `starterEnrichConfig`, `starterPlaybook`.
23
+ - **`docs/recipes.md` — five composable GTM plays.** Documents how the governed
24
+ primitives compose into real plays (cold-start lead-fill, the
25
+ trigger→judge→draft outbound loop, scheduled-continuous, ABM-from-companies,
26
+ hygiene-gated outbound) — making explicit that the CLI ships primitives, the
27
+ coding agent is the orchestrator (no `outbound` mega-verb), and the package
28
+ never sends. Surfaced from the agent skill and `llms.txt`, which also now
29
+ carry the previously-undocumented GTM-brain layer (`signals`/`icp`/`judge`/
30
+ `draft`) and the `init`/`enrich acquire` account-stamping behavior.
31
+ - **Account-level acquire for ABM (seam I).** `enrich acquire` creates net-new
32
+ **accounts**, not just contacts: configure `acquire.create.company` (match key
33
+ `domain`) and feed company rows (`enrich ingest companies.csv --objects
34
+ companies`). Each unmatched company becomes a `create_record` of
35
+ `objectType: account` with its domain stamped — so the acquired account is
36
+ immediately signal-watchable. (The spine already routed by object type; this
37
+ validates + documents the account path so it's discoverable.)
38
+ - **`health` breaks down by object type.** `HealthEntry.byObjectType` reports a
39
+ separate record-normalized score (same 100/(1+weighted-per-record) curve) plus
40
+ finding/record counts for `account`, `contact`, and `deal` — so "is my contact
41
+ data clean but my pipeline messy?" is answerable from one audit, and `health`
42
+ speaks each object type instead of a single aggregate. Rendered as a "By object
43
+ type" table in `healthToMarkdown`.
44
+ - **Outbound is contact-granular end to end (seam D).** `icp judge` now surfaces
45
+ `contacts` — all in-CRM contacts at the hot account (primary first, capped) —
46
+ alongside the single `contact`, so an agent can multi-thread instead of being
47
+ limited to one person. `signals outcome --contact <id>` records which contact a
48
+ touch reached (`SignalOutcome.contactId`), so the feedback loop credits the
49
+ person, not just the account domain.
50
+
51
+ ### Changed
52
+
53
+ - **`call link` says HOW it matched (seam H); the findings split is documented
54
+ (seam G).** `CallDealSuggestion` now carries `resolvedVia`
55
+ (`account_domain` | `contact_email` | `both`) and `matchedContacts`, so an
56
+ agent can weight a deal match by whether it came from the company-wide domain
57
+ or a single (possibly stale) attendee email — and the contact↔account hop is
58
+ visible, not just the resolved account. Separately, `PatchPlan.findings` (the
59
+ complete, object-typed list) and `pipelineFindings` (the deliberately
60
+ deal/sales-pipeline subset) are now documented so account/contact findings
61
+ aren't mistaken for "dropped" — they live in `findings`.
62
+ - **`enrich acquire` now gives every lead a signal-watchable account (contact↔
63
+ account seam).** Previously the presets wrote the company as a text field only,
64
+ so an acquired lead had no account record — invisible to `signals`/`icp judge`,
65
+ which key on account domain. Now acquire resolves-or-creates the lead's account
66
+ **by domain**: `AcquireCreateMap.associateCompanyDomainFrom` (preset:
67
+ `companyDomain`) threads the resolved domain — or the work-email's domain as a
68
+ free fallback — onto `CreateRecordPayload.associateCompanyDomain`. The HubSpot
69
+ and Salesforce connectors then match the account by domain first (the accurate
70
+ key), create it **with** the domain (`domain` / `Website`), and fill the domain
71
+ on a name-matched account that lacks one (fill-blank, never clobber). The dry-
72
+ run op reason now names the account + domain instead of resolving it silently.
73
+
74
+ ### Fixed
75
+
76
+ - **Outbound now writes against a real record, not a domain (contact↔account
77
+ seam).** `draft` previously emitted a `create_task` op hardcoded to
78
+ `objectType: "contact"` while carrying the account **domain** as `objectId` —
79
+ an incoherent operation the apply layer had to re-interpret. The bridge is now
80
+ explicit: `icp judge` resolves each decision's CRM target and surfaces it on
81
+ `JudgeDecision` — `accountId` plus the best `contact` (`id`/`email`/`title`) at
82
+ the account — whenever a snapshot source is given (`--provider`/`--input`/etc;
83
+ `--with-history` still gates the memory + fit scoring inputs, so scores are
84
+ unchanged). `draft` writes the task against `contact.id` (who to message), or
85
+ the `accountId` when no contact resolves, and **rejects a domain-only decision**
86
+ ("acquire it first") instead of forging a contact-typed op with a domain. The
87
+ object-type coherence invariant — every op's `objectId` is a real id of its
88
+ declared type — now holds for the outbound path.
89
+
10
90
  ## [0.42.0] — 2026-06-25
11
91
 
12
92
  ### Added
package/dist/calls.d.ts CHANGED
@@ -63,6 +63,19 @@ export type CallDealSuggestion = {
63
63
  dealName?: string;
64
64
  accountId?: string;
65
65
  accountName?: string;
66
+ /**
67
+ * HOW the deal's account was matched — `account_domain` (the company-wide,
68
+ * reliable signal) vs `contact_email` (a specific person, possibly stale) vs
69
+ * `both`. An agent needs this to weight the match; the bare confidence hides it.
70
+ */
71
+ resolvedVia?: "account_domain" | "contact_email" | "both";
72
+ /** The attendee contacts that matched (the email path), so the contact↔account
73
+ * hop is visible, not just the resolved account. */
74
+ matchedContacts?: {
75
+ id: string;
76
+ email?: string;
77
+ accountId?: string;
78
+ }[];
66
79
  confidence: "high" | "low" | "none";
67
80
  reason: string;
68
81
  };
package/dist/calls.js CHANGED
@@ -295,18 +295,27 @@ export function suggestCallDeal(snapshot, options) {
295
295
  return { dealId: null, confidence: "none", reason: "No attendee emails or domain supplied to match on." };
296
296
  }
297
297
  const accountIds = new Set();
298
+ let viaDomain = false;
299
+ let viaEmail = false;
300
+ const matchedContacts = [];
298
301
  for (const account of snapshot.accounts) {
299
302
  const domain = account.domain?.trim().toLowerCase().replace(/^www\./, "");
300
- if (domain && domains.has(domain))
303
+ if (domain && domains.has(domain)) {
301
304
  accountIds.add(account.id);
305
+ viaDomain = true;
306
+ }
302
307
  }
303
308
  for (const contact of snapshot.contacts) {
304
309
  const email = contact.email?.trim().toLowerCase();
305
310
  const at = email ? email.indexOf("@") : -1;
306
311
  if (email && at > 0 && domains.has(email.slice(at + 1)) && contact.accountId) {
307
312
  accountIds.add(contact.accountId);
313
+ viaEmail = true;
314
+ matchedContacts.push({ id: contact.id, ...(contact.email ? { email: contact.email } : {}), accountId: contact.accountId });
308
315
  }
309
316
  }
317
+ const resolvedVia = viaDomain && viaEmail ? "both" : viaDomain ? "account_domain" : "contact_email";
318
+ const via = { resolvedVia, ...(matchedContacts.length ? { matchedContacts } : {}) };
310
319
  if (accountIds.size === 0) {
311
320
  return {
312
321
  dealId: null,
@@ -335,8 +344,9 @@ export function suggestCallDeal(snapshot, options) {
335
344
  dealName: top.name,
336
345
  accountId: top.accountId,
337
346
  accountName: account?.name,
347
+ ...via,
338
348
  confidence: "high",
339
- reason: `"${top.name}" is the only open deal on matched account "${account?.name ?? top.accountId}".`,
349
+ reason: `"${top.name}" is the only open deal on matched account "${account?.name ?? top.accountId}" (via ${resolvedVia}).`,
340
350
  };
341
351
  }
342
352
  return {
@@ -344,8 +354,9 @@ export function suggestCallDeal(snapshot, options) {
344
354
  dealName: top.name,
345
355
  accountId: top.accountId,
346
356
  accountName: account?.name,
357
+ ...via,
347
358
  confidence: "low",
348
- reason: `${openDeals.length} open deals on matched account(s); "${top.name}" has the most recent activity. Confirm before writing.`,
359
+ reason: `${openDeals.length} open deals on matched account(s); "${top.name}" has the most recent activity (matched via ${resolvedVia}). Confirm before writing.`,
349
360
  };
350
361
  }
351
362
  function callHash(value) {
package/dist/cli.js CHANGED
@@ -32,6 +32,7 @@ import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
32
32
  import { DEFAULT_RUBRIC, classifyCallLlm, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
33
33
  import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, builtinAcquirePreset, builtinEnrichPreset, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor, } from "./enrich.js";
34
34
  import { loadMeter, recordConsumption, remaining, } from "./acquireMeter.js";
35
+ import { scaffoldWorkspace, } from "./init.js";
35
36
  import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspects, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys, } from "./connectors/prospectSources.js";
36
37
  import { loadSeen, recordSeen } from "./acquireSeen.js";
37
38
  import { reportCounts, reportEvent } from "./runReport.js";
@@ -67,6 +68,10 @@ Usage:
67
68
  the process list and shell history. Pipe them on stdin or enter them at the
68
69
  interactive prompt:
69
70
  echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot
71
+ fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
72
+ cold start: scaffold icp.json + enrich.config.json + a
73
+ PLAYBOOK wired for this workspace (the CLI ships primitives,
74
+ your agent is the orchestrator — see docs/recipes.md)
70
75
  fullstackgtm snapshot [source options] [--since <iso>] [--out <path> | --archive <dir>]
71
76
  fullstackgtm audit [source options] [audit options] [--save]
72
77
  fullstackgtm report [source options] [audit options] [report options]
@@ -269,6 +274,16 @@ Safety:
269
274
  and never writes requires_human_* placeholders without a --value override.`;
270
275
  }
271
276
  const HELP = {
277
+ // Get started
278
+ init: {
279
+ summary: "scaffold a workspace (icp.json + enrich.config.json + PLAYBOOK)",
280
+ phase: "Setup",
281
+ synopsis: [
282
+ "fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]",
283
+ ],
284
+ detail: "Cold start: writes a starter ICP, an acquire-ready enrich.config.json (with a visible assign seam so leads are never ownerless), and a PLAYBOOK wired with the cold-start + outbound-loop recipes for this workspace. Pure file-writer — no network, keeps existing files unless --force. The CLI ships governed primitives; your coding agent is the orchestrator (see docs/recipes.md).",
285
+ seeAlso: ["icp", "enrich", "signals"],
286
+ },
272
287
  // Setup & health
273
288
  login: {
274
289
  summary: "connect a provider or LLM key (secrets via stdin/env, never argv)",
@@ -502,11 +517,12 @@ const HELP = {
502
517
  };
503
518
  // Verbs that print their own richer multi-subcommand help; runCli routes their
504
519
  // `--help` to themselves, so commandHelp() only renders these via `help <verb>`.
505
- const BESPOKE_HELP = ["call", "market", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
520
+ const BESPOKE_HELP = ["init", "call", "market", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
506
521
  // Lifecycle-grouped front door. One line per verb, organized by the
507
522
  // Prevent→Detect→Remediate→Verify loop so a new user sees ~6 jobs, not 22 verbs.
508
523
  function shortUsage() {
509
524
  const groups = [
525
+ ["Get started", ["init"]],
510
526
  ["Setup & health", ["login", "logout", "doctor", "profiles", "health"]],
511
527
  ["Detect — read-only", ["audit", "report", "snapshot", "diff", "rules"]],
512
528
  ["Prevent — gate writes", ["resolve"]],
@@ -2277,7 +2293,8 @@ NEVER emits a patch plan; --save persists only the local signal ledger (used by
2277
2293
  throw new Error(`signals outcome: --result must be one of ${valid.join(", ")}.`);
2278
2294
  }
2279
2295
  const touchId = option(rest, "--touch") ?? undefined;
2280
- const outcome = makeOutcome({ accountDomain: accountArg, touchId, result: result });
2296
+ const contactId = option(rest, "--contact") ?? undefined;
2297
+ const outcome = makeOutcome({ accountDomain: accountArg, touchId, contactId, result: result });
2281
2298
  await createFileSignalStore().appendOutcome(outcome);
2282
2299
  console.error(`Recorded outcome "${outcome.result}" for ${outcome.accountDomain} (${outcome.id}). ` +
2283
2300
  `Re-run \`fullstackgtm signals weights\` to see the learned shift.`);
@@ -2447,6 +2464,58 @@ LLM key it emits a clearly-labeled stub rather than fake authored copy.`);
2447
2464
  console.error("(not saved — re-run with --save to stage the plan for plans approve -> apply)");
2448
2465
  }
2449
2466
  }
2467
+ /**
2468
+ * `init` — scaffold a GTM workspace from cold scratch: a starter icp.json, an
2469
+ * enrich.config.json (acquire preset + a visible assign seam), and a PLAYBOOK
2470
+ * wired with the chosen source/provider that points at the recipes. Pure
2471
+ * file-writer: no network, never overwrites without --force.
2472
+ */
2473
+ function initCommand(args) {
2474
+ if (args.includes("--help") || args.includes("-h")) {
2475
+ console.log(`Usage:
2476
+ fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
2477
+
2478
+ Scaffolds a workspace so the first acquire/signals/judge/draft commands work:
2479
+ icp.json starter ICP (edit, or rebuild with \`icp interview\`)
2480
+ enrich.config.json acquire preset for --source + a placeholder assign policy
2481
+ PLAYBOOK.md the cold-start + outbound-loop recipes wired for this workspace
2482
+
2483
+ The CLI ships governed primitives; your coding agent is the orchestrator. See
2484
+ docs/recipes.md for the full play set. Existing files are kept unless --force.`);
2485
+ return;
2486
+ }
2487
+ const source = (option(args, "--source") ?? "pipe0");
2488
+ if (!["pipe0", "explorium", "linkedin"].includes(source)) {
2489
+ throw new Error(`init: --source must be pipe0|explorium|linkedin (got "${source}")`);
2490
+ }
2491
+ const provider = (option(args, "--provider") ?? "hubspot");
2492
+ if (!["hubspot", "salesforce"].includes(provider)) {
2493
+ throw new Error(`init: --provider must be hubspot|salesforce (got "${provider}")`);
2494
+ }
2495
+ const outDir = resolve(process.cwd(), option(args, "--out") ?? ".");
2496
+ const force = args.includes("--force");
2497
+ const current = activeProfile();
2498
+ const profile = current === DEFAULT_PROFILE ? undefined : current;
2499
+ const files = scaffoldWorkspace({ source, provider, profile });
2500
+ const wrote = [];
2501
+ const kept = [];
2502
+ for (const file of files) {
2503
+ const path = resolve(outDir, file.path);
2504
+ if (existsSync(path) && !force) {
2505
+ kept.push(file.path);
2506
+ continue;
2507
+ }
2508
+ writeFileSync(path, file.content);
2509
+ wrote.push(file.path);
2510
+ }
2511
+ console.log(`Scaffolded workspace (${provider}, discovery via ${source}${profile ? `, profile ${profile}` : ""}).`);
2512
+ if (wrote.length)
2513
+ console.log(` wrote: ${wrote.join(", ")}`);
2514
+ if (kept.length)
2515
+ console.log(` kept (use --force to overwrite): ${kept.join(", ")}`);
2516
+ console.log("\nNext: edit icp.json + set acquire.assign.ownerId in enrich.config.json, then follow PLAYBOOK.md\n" +
2517
+ "(full recipe set: docs/recipes.md).");
2518
+ }
2450
2519
  /**
2451
2520
  * `icp` — develop and inspect the Ideal Customer Profile that targets acquire.
2452
2521
  * The CLI can't run AskUserQuestion itself; `icp interview` emits the question
@@ -2521,12 +2590,19 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
2521
2590
  const unjudged = signalRun.signals.filter((s) => !s.judgedBy);
2522
2591
  if (unjudged.length === 0)
2523
2592
  throw new Error(`Signal run "${signalRun.runLabel}" has no unjudged signals.`);
2524
- // 2) Optional inputs: ICP (may be undefined), snapshot (only --with-history),
2525
- // outcomes + config from the signal store / DEFAULT_SIGNALS_CONFIG.
2593
+ // 2) Optional inputs: ICP (may be undefined), snapshot, outcomes + config.
2594
+ // The snapshot is loaded whenever a source is given (--provider/--input/
2595
+ // --demo/--sample) — it resolves each decision's CRM target (accountId +
2596
+ // best contact) so `draft` can write against a real record. --with-history
2597
+ // additionally enables the memory + fit scoring inputs.
2526
2598
  const icp = loadIcp(rest);
2527
2599
  const config = DEFAULT_SIGNALS_CONFIG;
2528
2600
  const outcomes = await signalStore.listOutcomes();
2529
- const snapshot = withHistory ? await readSnapshot(rest) : undefined;
2601
+ const hasSnapshotSource = Boolean(option(rest, "--provider")) ||
2602
+ Boolean(option(rest, "--input")) ||
2603
+ rest.includes("--demo") ||
2604
+ rest.includes("--sample");
2605
+ const snapshot = withHistory || hasSnapshotSource ? await readSnapshot(rest) : undefined;
2530
2606
  // 3) Resolve the LLM seam ONLY if a key already exists (deterministic baseline
2531
2607
  // otherwise — never PROMPT here; judge must run key-free).
2532
2608
  const cred = resolveLlmCredential();
@@ -4599,6 +4675,10 @@ export async function runCli(argv) {
4599
4675
  await enrichCommand(args);
4600
4676
  return;
4601
4677
  }
4678
+ if (command === "init") {
4679
+ initCommand(args);
4680
+ return;
4681
+ }
4602
4682
  if (command === "icp") {
4603
4683
  await icpCommand(args);
4604
4684
  return;
@@ -406,18 +406,35 @@ export function createHubspotConnector(options) {
406
406
  providerData: { companyId, ...(createdCompanyName ? { createdCompany: true } : {}) },
407
407
  };
408
408
  }
409
- /** Exact-name company lookup for resolve-first creates. Returns matching ids (max 3 fetched). */
410
- async function searchCompaniesByName(name) {
409
+ /** Exact-value company lookup (by `name` or `domain`) for resolve-first creates. */
410
+ async function searchCompaniesBy(property, value) {
411
411
  const data = await request(`/crm/v3/objects/companies/search`, {
412
412
  method: "POST",
413
413
  body: JSON.stringify({
414
- filterGroups: [{ filters: [{ propertyName: "name", operator: "EQ", value: name }] }],
415
- properties: ["name"],
414
+ filterGroups: [{ filters: [{ propertyName: property, operator: "EQ", value }] }],
415
+ properties: [property],
416
416
  limit: 3,
417
417
  }),
418
418
  });
419
419
  return (data?.results ?? []).map((row) => String(row.id));
420
420
  }
421
+ const searchCompaniesByName = (name) => searchCompaniesBy("name", name);
422
+ /** Stamp `domain` on a company only when it has none (fill-blank, never clobber). */
423
+ async function fillCompanyDomainIfEmpty(companyId, domain) {
424
+ try {
425
+ const data = await request(`/crm/v3/objects/companies/${encodeURIComponent(companyId)}?properties=domain`);
426
+ const current = data?.properties?.domain;
427
+ if (typeof current === "string" && current.trim())
428
+ return; // already has one — leave it
429
+ await request(`/crm/v3/objects/companies/${encodeURIComponent(companyId)}`, {
430
+ method: "PATCH",
431
+ body: JSON.stringify({ properties: { domain } }),
432
+ });
433
+ }
434
+ catch {
435
+ // Best-effort: a failed domain fill must not sink the contact create.
436
+ }
437
+ }
421
438
  /** Exact-value contact lookup (by any searchable property) for resolve-first creates. */
422
439
  async function searchContactsBy(property, value) {
423
440
  const data = await request(`/crm/v3/objects/contacts/search`, {
@@ -431,39 +448,55 @@ export function createHubspotConnector(options) {
431
448
  return (data?.results ?? []).map((row) => String(row.id));
432
449
  }
433
450
  /**
434
- * Resolve a company by exact name, creating it on a confirmed miss. Returns
435
- * the company id, or null on ambiguity (≥2 existing) so the caller skips the
436
- * association rather than guessing. Same-run safe via createdCompaniesByName.
451
+ * Resolve a company to a real account record, creating it on a confirmed miss.
452
+ * Matches by DOMAIN first (the accurate key) and falls back to exact name;
453
+ * creates with the domain stamped, and fills the domain on a name-matched
454
+ * account that lacks one — so the account is signal-watchable. Returns null on
455
+ * ambiguity (≥2 matches) so the caller skips the association rather than
456
+ * guessing. Same-run safe via createdCompaniesByName.
437
457
  */
438
- async function resolveOrCreateCompanyByName(name, opId) {
439
- const nameKey = name.toLowerCase();
440
- const already = createdCompaniesByName.get(nameKey);
458
+ async function resolveOrCreateCompany(name, domain, opId) {
459
+ const cacheKey = domain ? `d:${domain.toLowerCase()}` : `n:${name.toLowerCase()}`;
460
+ const already = createdCompaniesByName.get(cacheKey);
441
461
  if (already)
442
462
  return already;
443
- const matches = await searchCompaniesByName(name);
444
- if (matches.length > 1)
445
- return null;
446
- if (matches.length === 1) {
447
- createdCompaniesByName.set(nameKey, matches[0]);
448
- return matches[0];
463
+ // 1. Domain-first match (accurate; an account matched here already has it).
464
+ if (domain) {
465
+ const byDomain = await searchCompaniesBy("domain", domain);
466
+ if (byDomain.length > 1)
467
+ return null;
468
+ if (byDomain.length === 1) {
469
+ createdCompaniesByName.set(cacheKey, byDomain[0]);
470
+ return byDomain[0];
471
+ }
449
472
  }
473
+ // 2. Exact-name match; stamp the domain if the account has none.
474
+ const byName = await searchCompaniesByName(name);
475
+ if (byName.length > 1)
476
+ return null;
477
+ if (byName.length === 1) {
478
+ if (domain)
479
+ await fillCompanyDomainIfEmpty(byName[0], domain);
480
+ createdCompaniesByName.set(cacheKey, byName[0]);
481
+ return byName[0];
482
+ }
483
+ // 3. Create with name + domain (provenance is best-effort).
484
+ const props = { name, ...(domain ? { domain } : {}) };
450
485
  let created;
451
486
  try {
452
487
  created = await request(`/crm/v3/objects/companies`, {
453
488
  method: "POST",
454
- body: JSON.stringify({
455
- properties: { name, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` },
456
- }),
489
+ body: JSON.stringify({ properties: { ...props, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` } }),
457
490
  });
458
491
  }
459
492
  catch {
460
493
  created = await request(`/crm/v3/objects/companies`, {
461
494
  method: "POST",
462
- body: JSON.stringify({ properties: { name } }),
495
+ body: JSON.stringify({ properties: props }),
463
496
  });
464
497
  }
465
498
  const id = String(created.id);
466
- createdCompaniesByName.set(nameKey, id);
499
+ createdCompaniesByName.set(cacheKey, id);
467
500
  return id;
468
501
  }
469
502
  /**
@@ -487,7 +520,7 @@ export function createHubspotConnector(options) {
487
520
  return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
488
521
  }
489
522
  if (operation.objectType === "account") {
490
- const id = await resolveOrCreateCompanyByName(matchValue, operation.id);
523
+ const id = await resolveOrCreateCompany(matchValue, stringOrUndefined(payload.properties?.domain), operation.id);
491
524
  if (id === null) {
492
525
  return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — multiple companies named "${matchValue}". Not creating.` };
493
526
  }
@@ -534,10 +567,11 @@ export function createHubspotConnector(options) {
534
567
  let companyNote = "";
535
568
  if (payload.associateCompanyName) {
536
569
  try {
537
- const companyId = await resolveOrCreateCompanyByName(payload.associateCompanyName, operation.id);
570
+ const companyId = await resolveOrCreateCompany(payload.associateCompanyName, payload.associateCompanyDomain, operation.id);
538
571
  if (companyId) {
539
572
  await request(`/crm/v4/objects/contacts/${encodeURIComponent(contactId)}/associations/default/companies/${encodeURIComponent(companyId)}`, { method: "PUT" });
540
- companyNote = ` Linked to company "${payload.associateCompanyName}" (${companyId}).`;
573
+ const domainNote = payload.associateCompanyDomain ? ` (${payload.associateCompanyDomain})` : "";
574
+ companyNote = ` Linked to company "${payload.associateCompanyName}"${domainNote} (${companyId}).`;
541
575
  }
542
576
  else {
543
577
  companyNote = ` Company "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
@@ -37,29 +37,53 @@ export function createSalesforceConnector(options) {
37
37
  // confirmed miss is created, and an ambiguous name (>1 match) is refused.
38
38
  // Caches within the run so the same name is never created twice — shared by
39
39
  // `link_record`'s create:<Name> path and `create_record`'s company linking.
40
- async function resolveOrCreateAccountByName(name) {
41
- const nameKey = name.toLowerCase();
42
- const cached = createdAccountsByName.get(nameKey);
40
+ async function resolveOrCreateAccount(name, domain) {
41
+ const cacheKey = domain ? `d:${domain.toLowerCase()}` : `n:${name.toLowerCase()}`;
42
+ const cached = createdAccountsByName.get(cacheKey);
43
43
  if (cached)
44
44
  return { id: cached, createdNew: false };
45
- const soqlName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
46
- const matches = await query(`SELECT Id FROM Account WHERE Name = '${soqlName}' LIMIT 3`);
47
- if (matches.length > 1)
48
- return { ambiguous: matches.length };
45
+ const esc = (v) => v.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
46
+ // 1. Domain-first match on Website (the accurate key; Website often stores a
47
+ // full URL, so match by substring).
48
+ if (domain) {
49
+ const byDomain = await query(`SELECT Id FROM Account WHERE Website LIKE '%${esc(domain)}%' LIMIT 3`);
50
+ if (byDomain.length > 1)
51
+ return { ambiguous: byDomain.length };
52
+ if (byDomain.length === 1) {
53
+ const id = String(byDomain[0].Id);
54
+ createdAccountsByName.set(cacheKey, id);
55
+ return { id, createdNew: false };
56
+ }
57
+ }
58
+ // 2. Exact-name match; stamp Website if the account has none (fill-blank).
59
+ const byName = await query(`SELECT Id, Website FROM Account WHERE Name = '${esc(name)}' LIMIT 3`);
60
+ if (byName.length > 1)
61
+ return { ambiguous: byName.length };
49
62
  let id;
50
63
  let createdNew = false;
51
- if (matches.length === 1) {
52
- id = String(matches[0].Id);
64
+ if (byName.length === 1) {
65
+ id = String(byName[0].Id);
66
+ if (domain && !stringOrUndefined(byName[0].Website)) {
67
+ try {
68
+ await request(`/services/data/${apiVersion}/sobjects/Account/${encodeURIComponent(id)}`, {
69
+ method: "PATCH",
70
+ body: JSON.stringify({ Website: domain }),
71
+ });
72
+ }
73
+ catch {
74
+ // best-effort fill — a failed Website stamp must not sink the create.
75
+ }
76
+ }
53
77
  }
54
78
  else {
55
79
  const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
56
80
  method: "POST",
57
- body: JSON.stringify({ Name: name }),
81
+ body: JSON.stringify({ Name: name, ...(domain ? { Website: domain } : {}) }),
58
82
  });
59
83
  id = String(created.id);
60
84
  createdNew = true;
61
85
  }
62
- createdAccountsByName.set(nameKey, id);
86
+ createdAccountsByName.set(cacheKey, id);
63
87
  return { id, createdNew };
64
88
  }
65
89
  async function request(path, init = {}) {
@@ -427,7 +451,7 @@ export function createSalesforceConnector(options) {
427
451
  return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
428
452
  }
429
453
  if (operation.objectType === "account") {
430
- const resolved = await resolveOrCreateAccountByName(matchValue);
454
+ const resolved = await resolveOrCreateAccount(matchValue);
431
455
  if ("ambiguous" in resolved) {
432
456
  return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — ${resolved.ambiguous} accounts named "${matchValue}". Not creating.` };
433
457
  }
@@ -465,7 +489,7 @@ export function createSalesforceConnector(options) {
465
489
  let companyNote = "";
466
490
  if (payload.associateCompanyName) {
467
491
  try {
468
- const resolved = await resolveOrCreateAccountByName(payload.associateCompanyName);
492
+ const resolved = await resolveOrCreateAccount(payload.associateCompanyName, payload.associateCompanyDomain);
469
493
  if ("ambiguous" in resolved) {
470
494
  companyNote = ` Account "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
471
495
  }
@@ -474,7 +498,8 @@ export function createSalesforceConnector(options) {
474
498
  method: "PATCH",
475
499
  body: JSON.stringify({ AccountId: resolved.id }),
476
500
  });
477
- companyNote = ` Linked to account "${payload.associateCompanyName}" (${resolved.id}).`;
501
+ const domainNote = payload.associateCompanyDomain ? ` (${payload.associateCompanyDomain})` : "";
502
+ companyNote = ` Linked to account "${payload.associateCompanyName}"${domainNote} (${resolved.id}).`;
478
503
  }
479
504
  }
480
505
  catch (error) {
@@ -617,7 +642,7 @@ export function createSalesforceConnector(options) {
617
642
  if (!name) {
618
643
  return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
619
644
  }
620
- const resolved = await resolveOrCreateAccountByName(name);
645
+ const resolved = await resolveOrCreateAccount(name);
621
646
  if ("ambiguous" in resolved) {
622
647
  return {
623
648
  operationId: operation.id,
package/dist/draft.js CHANGED
@@ -197,9 +197,6 @@ export function draft(opts) {
197
197
  const minScore = opts.minScore ?? 80;
198
198
  const channel = opts.channel ?? "task";
199
199
  const freshnessDays = opts.freshnessDays ?? DEFAULT_FRESHNESS_DAYS;
200
- // `task`/`email`/`linkedin` all write a CRM task through the same gate; the
201
- // object the task hangs off is the contact when known, else the account.
202
- const objectType = "contact";
203
200
  const hot = opts.decisions
204
201
  .filter((d) => d.decision === "send" && d.score >= minScore)
205
202
  .sort((a, b) => b.score - a.score || a.accountDomain.localeCompare(b.accountDomain));
@@ -235,17 +232,42 @@ export function draft(opts) {
235
232
  });
236
233
  continue;
237
234
  }
235
+ // Resolve a REAL CRM target from the judge decision: a contact id (preferred
236
+ // — that's who we message), else the account id. A domain-only decision
237
+ // (the account isn't in the CRM yet) cannot hang a task off a record — reject
238
+ // it with the fix, instead of forging a contact-typed op carrying a domain.
239
+ let objectType;
240
+ let objectId;
241
+ let targetNote;
242
+ if (decision.contact?.id) {
243
+ objectType = "contact";
244
+ objectId = decision.contact.id;
245
+ targetNote = `contact ${decision.contact.email ?? decision.contact.id}`;
246
+ }
247
+ else if (decision.accountId) {
248
+ objectType = "account";
249
+ objectId = decision.accountId;
250
+ targetNote = `account ${domain}`;
251
+ }
252
+ else {
253
+ rejected.push({
254
+ accountDomain: domain,
255
+ opener,
256
+ reason: `account ${domain} is not in the CRM — acquire it first (enrich acquire), then judge with a snapshot, before drafting`,
257
+ });
258
+ continue;
259
+ }
238
260
  const stale = isStaleTrigger(signal.firstSeen, now, freshnessDays);
239
261
  const ev = draftEvidence(signal, nowIso);
240
262
  evidence.push(ev);
241
- const reasonBase = `Signal-grounded opener for ${domain}: "${signal.trigger}"`;
263
+ const reasonBase = `Signal-grounded opener for ${domain} → ${targetNote}: "${signal.trigger}"`;
242
264
  const reason = stale
243
265
  ? `${reasonBase} [staleTrigger: grounding signal first seen ${signal.firstSeen}, older than ${freshnessDays}d — could this have gone out last month unchanged?]`
244
266
  : reasonBase;
245
267
  operations.push({
246
268
  id: draftOperationId(channel, domain),
247
269
  objectType,
248
- objectId: domain,
270
+ objectId,
249
271
  operation: "create_task",
250
272
  field: "follow_up_task",
251
273
  beforeValue: null,
package/dist/enrich.d.ts CHANGED
@@ -68,6 +68,12 @@ export type AcquireCreateMap = {
68
68
  matchKey: string;
69
69
  /** Optional source path to a company name; the connector resolves-or-creates it. */
70
70
  associateCompanyFrom?: string;
71
+ /**
72
+ * Optional source path to the company domain. With it (or a derivable email
73
+ * domain) the connector resolves the account by domain and stamps the domain
74
+ * on it — so the lead's account is signal-watchable, not just a text field.
75
+ */
76
+ associateCompanyDomainFrom?: string;
71
77
  };
72
78
  /**
73
79
  * Net-new discovery for an API acquire source. `provider` selects the adapter