fullstackgtm 0.41.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,103 @@ 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
+
90
+ ## [0.42.0] — 2026-06-25
91
+
92
+ ### Added
93
+
94
+ - **Bulk apply for lead creation (HubSpot + Salesforce).** `applyPatchPlan` now
95
+ routes independent, approved `create_record` **contact** ops through a new
96
+ optional connector capability, `applyCreateContactsBatch`, instead of two API
97
+ round-trips per lead (a resolve-first search + a create). HubSpot batches the
98
+ resolve-first via a `search` `IN` and creates via `batch/create` (100/call);
99
+ Salesforce batches via a SOQL `IN` and the Composite sObject Collections API
100
+ (`allOrNone: false`, 200/call). A batch rejection (e.g. a duplicate that the
101
+ provider 4xxs the whole batch over) falls back to per-record `createRecord`, so
102
+ one bad row never sinks the rest and resolve-first is preserved. Turns ~2N API
103
+ calls into ~N/100; ops that aren't safe to batch (grouped, value-overridden,
104
+ company-associated, conflicted, or non-create) stay on the serial path
105
+ unchanged. Connectors without the capability are unaffected.
106
+
10
107
  ## [0.41.0] — 2026-06-23
11
108
 
12
109
  ### 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;
package/dist/connector.js CHANGED
@@ -227,7 +227,41 @@ export async function applyPatchPlan(connector, plan, options) {
227
227
  }
228
228
  }
229
229
  }
230
+ // Bulk fast-path: route independent, approved create_record CONTACT ops
231
+ // through the connector's batch API (batched resolve-first read + batched
232
+ // writes) instead of one round-trip per record. Only ops that are safe to
233
+ // batch are eligible — every other op (grouped, needs an override, carries a
234
+ // company association, conflicted, or any non-create op) stays on the serial
235
+ // path below, so the safety contract is unchanged. Results are merged in by
236
+ // id; the serial loop short-circuits anything already resolved here.
237
+ const batched = new Map();
238
+ if (connector.applyCreateContactsBatch && !guardFailure) {
239
+ const eligible = plan.operations.filter((operation) => approved.has(operation.id) &&
240
+ operation.operation === "create_record" &&
241
+ operation.objectType === "contact" &&
242
+ options.valueOverrides?.[operation.id] === undefined &&
243
+ !requiresHumanInput(operation.afterValue) &&
244
+ !operation.groupId &&
245
+ !operation.afterValue?.associateCompanyName &&
246
+ !conflicts.has(operation.id) &&
247
+ !irreversibleStale.has(operation.id) &&
248
+ !(staleByFilter && staleByFilter.has(operation.objectId)));
249
+ // Only worth the batch machinery for more than a couple of creates.
250
+ if (eligible.length > 2) {
251
+ const batchResults = await connector.applyCreateContactsBatch(eligible);
252
+ for (const result of batchResults)
253
+ batched.set(result.operationId, result);
254
+ }
255
+ }
230
256
  for (const operation of plan.operations) {
257
+ const batchedResult = batched.get(operation.id);
258
+ if (batchedResult) {
259
+ results.push(batchedResult);
260
+ attempted += 1;
261
+ if (batchedResult.status === "applied")
262
+ applied += 1;
263
+ continue;
264
+ }
231
265
  if (!approved.has(operation.id)) {
232
266
  results.push({
233
267
  operationId: operation.id,
@@ -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.`;
@@ -555,6 +589,129 @@ export function createHubspotConnector(options) {
555
589
  providerData: { id: contactId, created: true },
556
590
  };
557
591
  }
592
+ /** Bulk resolve-first lookup: which match values already exist (value→id). */
593
+ async function searchContactsByValues(property, values) {
594
+ const found = new Map();
595
+ for (let i = 0; i < values.length; i += 100) {
596
+ const chunk = values.slice(i, i + 100);
597
+ const data = await request(`/crm/v3/objects/contacts/search`, {
598
+ method: "POST",
599
+ body: JSON.stringify({
600
+ filterGroups: [{ filters: [{ propertyName: property, operator: "IN", values: chunk }] }],
601
+ properties: [property],
602
+ limit: 100,
603
+ }),
604
+ });
605
+ for (const rec of (data?.results ?? [])) {
606
+ const v = rec?.properties?.[property];
607
+ if (typeof v === "string" && rec.id)
608
+ found.set(v.toLowerCase(), String(rec.id));
609
+ }
610
+ }
611
+ return found;
612
+ }
613
+ /**
614
+ * Batch create_record for contacts: bulk resolve-first (search IN), then
615
+ * bulk-create the genuine misses (batch/create, 100/call) — two API calls per
616
+ * ~100 leads instead of two per lead. On a batch-create rejection (e.g. an
617
+ * email collision HubSpot 409s the whole batch over), it falls back to
618
+ * per-record createRecord for that chunk, so one bad row never sinks the rest
619
+ * and resolve-first is preserved. Returns one result per input op.
620
+ */
621
+ async function applyCreateContactsBatch(operations) {
622
+ const byId = new Map();
623
+ // Group by the HubSpot search property (matchKey is usually uniform across a
624
+ // plan, but a mixed plan stays correct this way).
625
+ const groups = new Map();
626
+ for (const op of operations) {
627
+ const matchKey = op.afterValue?.matchKey || "email";
628
+ const searchProperty = HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts[matchKey] ?? matchKey;
629
+ let arr = groups.get(searchProperty);
630
+ if (!arr)
631
+ groups.set(searchProperty, (arr = []));
632
+ arr.push(op);
633
+ }
634
+ for (const [searchProperty, ops] of groups) {
635
+ const wanted = ops
636
+ .map((op) => String(op.afterValue.matchValue ?? "").trim())
637
+ .filter(Boolean);
638
+ const existing = await searchContactsByValues(searchProperty, [...new Set(wanted.map((v) => v.toLowerCase()))]);
639
+ const toCreate = [];
640
+ const seenInBatch = new Set();
641
+ for (const op of ops) {
642
+ const payload = op.afterValue;
643
+ const matchKey = payload.matchKey || "email";
644
+ const matchValue = String(payload.matchValue ?? "").trim();
645
+ if (!matchValue) {
646
+ byId.set(op.id, { operationId: op.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." });
647
+ continue;
648
+ }
649
+ const lower = matchValue.toLowerCase();
650
+ const dedupeKey = `${matchKey}:${lower}`;
651
+ const already = createdContactsByMatch.get(dedupeKey);
652
+ if (already) {
653
+ byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: already, existing: true } });
654
+ continue;
655
+ }
656
+ const existingId = existing.get(lower);
657
+ if (existingId) {
658
+ createdContactsByMatch.set(dedupeKey, existingId);
659
+ byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already exists (${existingId}); resolve-first declined to create.`, providerData: { id: existingId, existing: true } });
660
+ continue;
661
+ }
662
+ if (seenInBatch.has(lower)) {
663
+ byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} duplicated within this batch; not duplicating.` });
664
+ continue;
665
+ }
666
+ seenInBatch.add(lower);
667
+ toCreate.push(op);
668
+ }
669
+ for (let i = 0; i < toCreate.length; i += 100) {
670
+ const chunk = toCreate.slice(i, i + 100);
671
+ const inputs = chunk.map((op) => {
672
+ const payload = op.afterValue;
673
+ const properties = { ...payload.properties };
674
+ if (payload.ownerId && !properties.hubspot_owner_id)
675
+ properties.hubspot_owner_id = String(payload.ownerId);
676
+ properties.hs_object_source_detail_2 = `fullstackgtm acquire (${op.id})`;
677
+ return { properties };
678
+ });
679
+ try {
680
+ const data = await request(`/crm/v3/objects/contacts/batch/create`, {
681
+ method: "POST",
682
+ body: JSON.stringify({ inputs }),
683
+ });
684
+ const created = (data?.results ?? []);
685
+ const idByValue = new Map();
686
+ for (const rec of created) {
687
+ const v = rec?.properties?.[searchProperty];
688
+ if (typeof v === "string" && rec.id)
689
+ idByValue.set(v.toLowerCase(), String(rec.id));
690
+ }
691
+ for (const op of chunk) {
692
+ const payload = op.afterValue;
693
+ const matchKey = payload.matchKey || "email";
694
+ const matchValue = String(payload.matchValue).trim();
695
+ const id = idByValue.get(matchValue.toLowerCase());
696
+ if (id) {
697
+ createdContactsByMatch.set(`${matchKey}:${matchValue.toLowerCase()}`, id);
698
+ byId.set(op.id, { operationId: op.id, status: "applied", detail: `Created contact ${matchKey}=${matchValue} (${id}).`, providerData: { id, created: true } });
699
+ }
700
+ else {
701
+ // Result didn't echo this value — resolve it per-record to be safe.
702
+ byId.set(op.id, await createRecord(op));
703
+ }
704
+ }
705
+ }
706
+ catch {
707
+ // Whole-chunk rejection (e.g. a collision). Isolate per record.
708
+ for (const op of chunk)
709
+ byId.set(op.id, await createRecord(op));
710
+ }
711
+ }
712
+ }
713
+ return operations.map((op) => byId.get(op.id) ?? { operationId: op.id, status: "skipped", detail: "create_record was not processed." });
714
+ }
558
715
  async function createTask(operation) {
559
716
  const associationTypeId = TASK_ASSOCIATION_TYPE_IDS[operation.objectType];
560
717
  if (associationTypeId === undefined) {
@@ -801,6 +958,7 @@ export function createHubspotConnector(options) {
801
958
  fetchSnapshot,
802
959
  fetchChanges,
803
960
  applyOperation,
961
+ applyCreateContactsBatch,
804
962
  readField,
805
963
  };
806
964
  }