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.
@@ -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.
@@ -597,6 +622,118 @@ export function createSalesforceConnector(
597
622
  };
598
623
  }
599
624
 
625
+ /**
626
+ * Batch create_record for contacts: bulk resolve-first (SOQL IN), then create
627
+ * the misses via the Composite sObject Collections API (allOrNone:false,
628
+ * 200/call), instead of two round-trips per lead. A record-level failure in
629
+ * the collection (e.g. a duplicate rule) falls back to per-record createRecord
630
+ * so one bad row never sinks the rest. Returns one result per input op.
631
+ */
632
+ async function applyCreateContactsBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
633
+ const byId = new Map<string, PatchOperationResult>();
634
+ const groups = new Map<string, PatchOperation[]>();
635
+ for (const op of operations) {
636
+ const matchKey = (op.afterValue as CreateRecordPayload | undefined)?.matchKey || "email";
637
+ const searchField = mappedField(mappings, "contacts", matchKey, SALESFORCE_DEFAULT_FIELD_MAPPINGS.contacts[matchKey] ?? matchKey);
638
+ let arr = groups.get(searchField);
639
+ if (!arr) groups.set(searchField, (arr = []));
640
+ arr.push(op);
641
+ }
642
+
643
+ for (const [searchField, ops] of groups) {
644
+ // 1. Bulk resolve-first via SOQL IN (chunked; values SOQL-escaped).
645
+ const existing = new Map<string, string>();
646
+ const uniqueValues = [
647
+ ...new Set(ops.map((op) => String((op.afterValue as CreateRecordPayload).matchValue ?? "").trim()).filter(Boolean)),
648
+ ];
649
+ for (let i = 0; i < uniqueValues.length; i += 200) {
650
+ const inList = uniqueValues
651
+ .slice(i, i + 200)
652
+ .map((v) => `'${v.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`)
653
+ .join(",");
654
+ const rows = await query(`SELECT Id, ${searchField} FROM Contact WHERE ${searchField} IN (${inList})`);
655
+ for (const row of rows) {
656
+ const v = row?.[searchField];
657
+ if (typeof v === "string" && row.Id) existing.set(v.toLowerCase(), String(row.Id));
658
+ }
659
+ }
660
+
661
+ // 2. Partition: existing / already-this-run / dup-in-batch / to-create.
662
+ const toCreate: PatchOperation[] = [];
663
+ const seenInBatch = new Set<string>();
664
+ for (const op of ops) {
665
+ const payload = op.afterValue as CreateRecordPayload;
666
+ const matchKey = payload.matchKey || "email";
667
+ const matchValue = String(payload.matchValue ?? "").trim();
668
+ if (!matchValue) {
669
+ byId.set(op.id, { operationId: op.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." });
670
+ continue;
671
+ }
672
+ const lower = matchValue.toLowerCase();
673
+ const dedupeKey = `${matchKey}:${lower}`;
674
+ const already = createdContactsByMatch.get(dedupeKey);
675
+ if (already) {
676
+ 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 } });
677
+ continue;
678
+ }
679
+ const existingId = existing.get(lower);
680
+ if (existingId) {
681
+ createdContactsByMatch.set(dedupeKey, existingId);
682
+ 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 } });
683
+ continue;
684
+ }
685
+ if (seenInBatch.has(lower)) {
686
+ byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} duplicated within this batch; not duplicating.` });
687
+ continue;
688
+ }
689
+ seenInBatch.add(lower);
690
+ toCreate.push(op);
691
+ }
692
+
693
+ // 3. Bulk create via Composite sObject Collections (allOrNone:false →
694
+ // per-record results in request order).
695
+ for (let i = 0; i < toCreate.length; i += 200) {
696
+ const chunk = toCreate.slice(i, i + 200);
697
+ const records = chunk.map((op) => {
698
+ const payload = op.afterValue as CreateRecordPayload;
699
+ const fields: Record<string, string> = { ...payload.properties };
700
+ if (payload.ownerId) {
701
+ const ownerField = mappedField(mappings, "contacts", "ownerId", SALESFORCE_DEFAULT_FIELD_MAPPINGS.contacts.ownerId ?? "OwnerId");
702
+ if (!fields[ownerField]) fields[ownerField] = String(payload.ownerId);
703
+ }
704
+ return { attributes: { type: "Contact" }, ...fields };
705
+ });
706
+ let results: Array<{ id?: string; success?: boolean }>;
707
+ try {
708
+ results = (await request(`/services/data/${apiVersion}/composite/sobjects`, {
709
+ method: "POST",
710
+ body: JSON.stringify({ allOrNone: false, records }),
711
+ })) as Array<{ id?: string; success?: boolean }>;
712
+ } catch {
713
+ for (const op of chunk) byId.set(op.id, await createRecord(op));
714
+ continue;
715
+ }
716
+ for (let j = 0; j < chunk.length; j++) {
717
+ const op = chunk[j];
718
+ const res = Array.isArray(results) ? results[j] : undefined;
719
+ const payload = op.afterValue as CreateRecordPayload;
720
+ const matchKey = payload.matchKey || "email";
721
+ const matchValue = String(payload.matchValue).trim();
722
+ if (res?.success && res.id) {
723
+ createdContactsByMatch.set(`${matchKey}:${matchValue.toLowerCase()}`, String(res.id));
724
+ byId.set(op.id, { operationId: op.id, status: "applied", detail: `Created contact ${matchKey}=${matchValue} (${res.id}).`, providerData: { id: String(res.id), created: true } });
725
+ } else {
726
+ byId.set(op.id, await createRecord(op)); // record-level failure → isolate per record
727
+ }
728
+ }
729
+ }
730
+ }
731
+
732
+ return operations.map(
733
+ (op) => byId.get(op.id) ?? { operationId: op.id, status: "skipped", detail: "create_record was not processed." },
734
+ );
735
+ }
736
+
600
737
  async function applyOperation(operation: PatchOperation): Promise<PatchOperationResult> {
601
738
  try {
602
739
  switch (operation.operation) {
@@ -614,7 +751,7 @@ export function createSalesforceConnector(
614
751
  if (!name) {
615
752
  return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
616
753
  }
617
- const resolved = await resolveOrCreateAccountByName(name);
754
+ const resolved = await resolveOrCreateAccount(name);
618
755
  if ("ambiguous" in resolved) {
619
756
  return {
620
757
  operationId: operation.id,
@@ -683,6 +820,7 @@ export function createSalesforceConnector(
683
820
  fetchSnapshot,
684
821
  fetchChanges,
685
822
  applyOperation,
823
+ applyCreateContactsBatch,
686
824
  readField,
687
825
  };
688
826
  }
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
+ }