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 +97 -0
- package/dist/calls.d.ts +13 -0
- package/dist/calls.js +14 -3
- package/dist/cli.js +85 -5
- package/dist/connector.js +34 -0
- package/dist/connectors/hubspot.js +182 -24
- package/dist/connectors/salesforce.js +152 -15
- package/dist/draft.js +27 -5
- package/dist/enrich.d.ts +6 -0
- package/dist/enrich.js +47 -1
- package/dist/health.d.ts +12 -0
- package/dist/health.js +29 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init.d.ts +47 -0
- package/dist/init.js +140 -0
- package/dist/judge.d.ts +36 -3
- package/dist/judge.js +46 -10
- package/dist/signals.d.ts +4 -0
- package/dist/signals.js +1 -0
- package/dist/types.d.ts +23 -0
- package/docs/api.md +16 -1
- package/docs/recipes.md +158 -0
- package/llms.txt +25 -0
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +16 -1
- package/src/calls.ts +24 -3
- package/src/cli.ts +96 -5
- package/src/connector.ts +37 -0
- package/src/connectors/hubspot.ts +180 -23
- package/src/connectors/salesforce.ts +152 -14
- package/src/draft.ts +26 -5
- package/src/enrich.ts +51 -1
- package/src/health.ts +47 -2
- package/src/index.ts +10 -0
- package/src/init.ts +163 -0
- package/src/judge.ts +85 -11
- package/src/signals.ts +5 -0
- package/src/types.ts +23 -0
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
|
|
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
|
|
2525
|
-
//
|
|
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
|
|
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-
|
|
410
|
-
async function
|
|
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:
|
|
415
|
-
properties: [
|
|
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
|
|
435
|
-
*
|
|
436
|
-
*
|
|
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
|
|
439
|
-
const
|
|
440
|
-
const already = createdCompaniesByName.get(
|
|
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
|
-
|
|
444
|
-
if (
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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:
|
|
495
|
+
body: JSON.stringify({ properties: props }),
|
|
463
496
|
});
|
|
464
497
|
}
|
|
465
498
|
const id = String(created.id);
|
|
466
|
-
createdCompaniesByName.set(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|