fullstackgtm 0.42.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -0
- package/dist/calls.d.ts +13 -0
- package/dist/calls.js +14 -3
- package/dist/cli.js +85 -5
- package/dist/connectors/hubspot.js +58 -24
- package/dist/connectors/salesforce.js +40 -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 +12 -0
- package/docs/api.md +8 -0
- 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/connectors/hubspot.ts +55 -23
- package/src/connectors/salesforce.ts +39 -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 +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,86 @@ The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md)
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.43.0] — 2026-06-25
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`init` — scaffold a GTM workspace from cold scratch.** `fullstackgtm init
|
|
15
|
+
[--source pipe0|explorium|linkedin] [--provider hubspot|salesforce]` writes
|
|
16
|
+
three files so the first acquire/signals/judge/draft commands work: a valid
|
|
17
|
+
starter `icp.json`, an acquire-ready `enrich.config.json` (the source preset
|
|
18
|
+
plus an explicit `acquire.assign` seam, placeholder owner id, so leads are
|
|
19
|
+
never silently ownerless), and a `PLAYBOOK.md` wired with the cold-start and
|
|
20
|
+
outbound-loop recipes for this workspace's source/provider/profile. Pure
|
|
21
|
+
file-writer: no network, keeps existing files unless `--force`. Library:
|
|
22
|
+
`scaffoldWorkspace`, `starterIcp`, `starterEnrichConfig`, `starterPlaybook`.
|
|
23
|
+
- **`docs/recipes.md` — five composable GTM plays.** Documents how the governed
|
|
24
|
+
primitives compose into real plays (cold-start lead-fill, the
|
|
25
|
+
trigger→judge→draft outbound loop, scheduled-continuous, ABM-from-companies,
|
|
26
|
+
hygiene-gated outbound) — making explicit that the CLI ships primitives, the
|
|
27
|
+
coding agent is the orchestrator (no `outbound` mega-verb), and the package
|
|
28
|
+
never sends. Surfaced from the agent skill and `llms.txt`, which also now
|
|
29
|
+
carry the previously-undocumented GTM-brain layer (`signals`/`icp`/`judge`/
|
|
30
|
+
`draft`) and the `init`/`enrich acquire` account-stamping behavior.
|
|
31
|
+
- **Account-level acquire for ABM (seam I).** `enrich acquire` creates net-new
|
|
32
|
+
**accounts**, not just contacts: configure `acquire.create.company` (match key
|
|
33
|
+
`domain`) and feed company rows (`enrich ingest companies.csv --objects
|
|
34
|
+
companies`). Each unmatched company becomes a `create_record` of
|
|
35
|
+
`objectType: account` with its domain stamped — so the acquired account is
|
|
36
|
+
immediately signal-watchable. (The spine already routed by object type; this
|
|
37
|
+
validates + documents the account path so it's discoverable.)
|
|
38
|
+
- **`health` breaks down by object type.** `HealthEntry.byObjectType` reports a
|
|
39
|
+
separate record-normalized score (same 100/(1+weighted-per-record) curve) plus
|
|
40
|
+
finding/record counts for `account`, `contact`, and `deal` — so "is my contact
|
|
41
|
+
data clean but my pipeline messy?" is answerable from one audit, and `health`
|
|
42
|
+
speaks each object type instead of a single aggregate. Rendered as a "By object
|
|
43
|
+
type" table in `healthToMarkdown`.
|
|
44
|
+
- **Outbound is contact-granular end to end (seam D).** `icp judge` now surfaces
|
|
45
|
+
`contacts` — all in-CRM contacts at the hot account (primary first, capped) —
|
|
46
|
+
alongside the single `contact`, so an agent can multi-thread instead of being
|
|
47
|
+
limited to one person. `signals outcome --contact <id>` records which contact a
|
|
48
|
+
touch reached (`SignalOutcome.contactId`), so the feedback loop credits the
|
|
49
|
+
person, not just the account domain.
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- **`call link` says HOW it matched (seam H); the findings split is documented
|
|
54
|
+
(seam G).** `CallDealSuggestion` now carries `resolvedVia`
|
|
55
|
+
(`account_domain` | `contact_email` | `both`) and `matchedContacts`, so an
|
|
56
|
+
agent can weight a deal match by whether it came from the company-wide domain
|
|
57
|
+
or a single (possibly stale) attendee email — and the contact↔account hop is
|
|
58
|
+
visible, not just the resolved account. Separately, `PatchPlan.findings` (the
|
|
59
|
+
complete, object-typed list) and `pipelineFindings` (the deliberately
|
|
60
|
+
deal/sales-pipeline subset) are now documented so account/contact findings
|
|
61
|
+
aren't mistaken for "dropped" — they live in `findings`.
|
|
62
|
+
- **`enrich acquire` now gives every lead a signal-watchable account (contact↔
|
|
63
|
+
account seam).** Previously the presets wrote the company as a text field only,
|
|
64
|
+
so an acquired lead had no account record — invisible to `signals`/`icp judge`,
|
|
65
|
+
which key on account domain. Now acquire resolves-or-creates the lead's account
|
|
66
|
+
**by domain**: `AcquireCreateMap.associateCompanyDomainFrom` (preset:
|
|
67
|
+
`companyDomain`) threads the resolved domain — or the work-email's domain as a
|
|
68
|
+
free fallback — onto `CreateRecordPayload.associateCompanyDomain`. The HubSpot
|
|
69
|
+
and Salesforce connectors then match the account by domain first (the accurate
|
|
70
|
+
key), create it **with** the domain (`domain` / `Website`), and fill the domain
|
|
71
|
+
on a name-matched account that lacks one (fill-blank, never clobber). The dry-
|
|
72
|
+
run op reason now names the account + domain instead of resolving it silently.
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
|
|
76
|
+
- **Outbound now writes against a real record, not a domain (contact↔account
|
|
77
|
+
seam).** `draft` previously emitted a `create_task` op hardcoded to
|
|
78
|
+
`objectType: "contact"` while carrying the account **domain** as `objectId` —
|
|
79
|
+
an incoherent operation the apply layer had to re-interpret. The bridge is now
|
|
80
|
+
explicit: `icp judge` resolves each decision's CRM target and surfaces it on
|
|
81
|
+
`JudgeDecision` — `accountId` plus the best `contact` (`id`/`email`/`title`) at
|
|
82
|
+
the account — whenever a snapshot source is given (`--provider`/`--input`/etc;
|
|
83
|
+
`--with-history` still gates the memory + fit scoring inputs, so scores are
|
|
84
|
+
unchanged). `draft` writes the task against `contact.id` (who to message), or
|
|
85
|
+
the `accountId` when no contact resolves, and **rejects a domain-only decision**
|
|
86
|
+
("acquire it first") instead of forging a contact-typed op with a domain. The
|
|
87
|
+
object-type coherence invariant — every op's `objectId` is a real id of its
|
|
88
|
+
declared type — now holds for the outbound path.
|
|
89
|
+
|
|
10
90
|
## [0.42.0] — 2026-06-25
|
|
11
91
|
|
|
12
92
|
### Added
|
package/dist/calls.d.ts
CHANGED
|
@@ -63,6 +63,19 @@ export type CallDealSuggestion = {
|
|
|
63
63
|
dealName?: string;
|
|
64
64
|
accountId?: string;
|
|
65
65
|
accountName?: string;
|
|
66
|
+
/**
|
|
67
|
+
* HOW the deal's account was matched — `account_domain` (the company-wide,
|
|
68
|
+
* reliable signal) vs `contact_email` (a specific person, possibly stale) vs
|
|
69
|
+
* `both`. An agent needs this to weight the match; the bare confidence hides it.
|
|
70
|
+
*/
|
|
71
|
+
resolvedVia?: "account_domain" | "contact_email" | "both";
|
|
72
|
+
/** The attendee contacts that matched (the email path), so the contact↔account
|
|
73
|
+
* hop is visible, not just the resolved account. */
|
|
74
|
+
matchedContacts?: {
|
|
75
|
+
id: string;
|
|
76
|
+
email?: string;
|
|
77
|
+
accountId?: string;
|
|
78
|
+
}[];
|
|
66
79
|
confidence: "high" | "low" | "none";
|
|
67
80
|
reason: string;
|
|
68
81
|
};
|
package/dist/calls.js
CHANGED
|
@@ -295,18 +295,27 @@ export function suggestCallDeal(snapshot, options) {
|
|
|
295
295
|
return { dealId: null, confidence: "none", reason: "No attendee emails or domain supplied to match on." };
|
|
296
296
|
}
|
|
297
297
|
const accountIds = new Set();
|
|
298
|
+
let viaDomain = false;
|
|
299
|
+
let viaEmail = false;
|
|
300
|
+
const matchedContacts = [];
|
|
298
301
|
for (const account of snapshot.accounts) {
|
|
299
302
|
const domain = account.domain?.trim().toLowerCase().replace(/^www\./, "");
|
|
300
|
-
if (domain && domains.has(domain))
|
|
303
|
+
if (domain && domains.has(domain)) {
|
|
301
304
|
accountIds.add(account.id);
|
|
305
|
+
viaDomain = true;
|
|
306
|
+
}
|
|
302
307
|
}
|
|
303
308
|
for (const contact of snapshot.contacts) {
|
|
304
309
|
const email = contact.email?.trim().toLowerCase();
|
|
305
310
|
const at = email ? email.indexOf("@") : -1;
|
|
306
311
|
if (email && at > 0 && domains.has(email.slice(at + 1)) && contact.accountId) {
|
|
307
312
|
accountIds.add(contact.accountId);
|
|
313
|
+
viaEmail = true;
|
|
314
|
+
matchedContacts.push({ id: contact.id, ...(contact.email ? { email: contact.email } : {}), accountId: contact.accountId });
|
|
308
315
|
}
|
|
309
316
|
}
|
|
317
|
+
const resolvedVia = viaDomain && viaEmail ? "both" : viaDomain ? "account_domain" : "contact_email";
|
|
318
|
+
const via = { resolvedVia, ...(matchedContacts.length ? { matchedContacts } : {}) };
|
|
310
319
|
if (accountIds.size === 0) {
|
|
311
320
|
return {
|
|
312
321
|
dealId: null,
|
|
@@ -335,8 +344,9 @@ export function suggestCallDeal(snapshot, options) {
|
|
|
335
344
|
dealName: top.name,
|
|
336
345
|
accountId: top.accountId,
|
|
337
346
|
accountName: account?.name,
|
|
347
|
+
...via,
|
|
338
348
|
confidence: "high",
|
|
339
|
-
reason: `"${top.name}" is the only open deal on matched account "${account?.name ?? top.accountId}".`,
|
|
349
|
+
reason: `"${top.name}" is the only open deal on matched account "${account?.name ?? top.accountId}" (via ${resolvedVia}).`,
|
|
340
350
|
};
|
|
341
351
|
}
|
|
342
352
|
return {
|
|
@@ -344,8 +354,9 @@ export function suggestCallDeal(snapshot, options) {
|
|
|
344
354
|
dealName: top.name,
|
|
345
355
|
accountId: top.accountId,
|
|
346
356
|
accountName: account?.name,
|
|
357
|
+
...via,
|
|
347
358
|
confidence: "low",
|
|
348
|
-
reason: `${openDeals.length} open deals on matched account(s); "${top.name}" has the most recent activity. Confirm before writing.`,
|
|
359
|
+
reason: `${openDeals.length} open deals on matched account(s); "${top.name}" has the most recent activity (matched via ${resolvedVia}). Confirm before writing.`,
|
|
349
360
|
};
|
|
350
361
|
}
|
|
351
362
|
function callHash(value) {
|
package/dist/cli.js
CHANGED
|
@@ -32,6 +32,7 @@ import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
|
|
|
32
32
|
import { DEFAULT_RUBRIC, classifyCallLlm, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
|
|
33
33
|
import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, builtinAcquirePreset, builtinEnrichPreset, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor, } from "./enrich.js";
|
|
34
34
|
import { loadMeter, recordConsumption, remaining, } from "./acquireMeter.js";
|
|
35
|
+
import { scaffoldWorkspace, } from "./init.js";
|
|
35
36
|
import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspects, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys, } from "./connectors/prospectSources.js";
|
|
36
37
|
import { loadSeen, recordSeen } from "./acquireSeen.js";
|
|
37
38
|
import { reportCounts, reportEvent } from "./runReport.js";
|
|
@@ -67,6 +68,10 @@ Usage:
|
|
|
67
68
|
the process list and shell history. Pipe them on stdin or enter them at the
|
|
68
69
|
interactive prompt:
|
|
69
70
|
echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot
|
|
71
|
+
fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
|
|
72
|
+
cold start: scaffold icp.json + enrich.config.json + a
|
|
73
|
+
PLAYBOOK wired for this workspace (the CLI ships primitives,
|
|
74
|
+
your agent is the orchestrator — see docs/recipes.md)
|
|
70
75
|
fullstackgtm snapshot [source options] [--since <iso>] [--out <path> | --archive <dir>]
|
|
71
76
|
fullstackgtm audit [source options] [audit options] [--save]
|
|
72
77
|
fullstackgtm report [source options] [audit options] [report options]
|
|
@@ -269,6 +274,16 @@ Safety:
|
|
|
269
274
|
and never writes requires_human_* placeholders without a --value override.`;
|
|
270
275
|
}
|
|
271
276
|
const HELP = {
|
|
277
|
+
// Get started
|
|
278
|
+
init: {
|
|
279
|
+
summary: "scaffold a workspace (icp.json + enrich.config.json + PLAYBOOK)",
|
|
280
|
+
phase: "Setup",
|
|
281
|
+
synopsis: [
|
|
282
|
+
"fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]",
|
|
283
|
+
],
|
|
284
|
+
detail: "Cold start: writes a starter ICP, an acquire-ready enrich.config.json (with a visible assign seam so leads are never ownerless), and a PLAYBOOK wired with the cold-start + outbound-loop recipes for this workspace. Pure file-writer — no network, keeps existing files unless --force. The CLI ships governed primitives; your coding agent is the orchestrator (see docs/recipes.md).",
|
|
285
|
+
seeAlso: ["icp", "enrich", "signals"],
|
|
286
|
+
},
|
|
272
287
|
// Setup & health
|
|
273
288
|
login: {
|
|
274
289
|
summary: "connect a provider or LLM key (secrets via stdin/env, never argv)",
|
|
@@ -502,11 +517,12 @@ const HELP = {
|
|
|
502
517
|
};
|
|
503
518
|
// Verbs that print their own richer multi-subcommand help; runCli routes their
|
|
504
519
|
// `--help` to themselves, so commandHelp() only renders these via `help <verb>`.
|
|
505
|
-
const BESPOKE_HELP = ["call", "market", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
|
|
520
|
+
const BESPOKE_HELP = ["init", "call", "market", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
|
|
506
521
|
// Lifecycle-grouped front door. One line per verb, organized by the
|
|
507
522
|
// Prevent→Detect→Remediate→Verify loop so a new user sees ~6 jobs, not 22 verbs.
|
|
508
523
|
function shortUsage() {
|
|
509
524
|
const groups = [
|
|
525
|
+
["Get started", ["init"]],
|
|
510
526
|
["Setup & health", ["login", "logout", "doctor", "profiles", "health"]],
|
|
511
527
|
["Detect — read-only", ["audit", "report", "snapshot", "diff", "rules"]],
|
|
512
528
|
["Prevent — gate writes", ["resolve"]],
|
|
@@ -2277,7 +2293,8 @@ NEVER emits a patch plan; --save persists only the local signal ledger (used by
|
|
|
2277
2293
|
throw new Error(`signals outcome: --result must be one of ${valid.join(", ")}.`);
|
|
2278
2294
|
}
|
|
2279
2295
|
const touchId = option(rest, "--touch") ?? undefined;
|
|
2280
|
-
const
|
|
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;
|
|
@@ -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.`;
|
|
@@ -37,29 +37,53 @@ export function createSalesforceConnector(options) {
|
|
|
37
37
|
// confirmed miss is created, and an ambiguous name (>1 match) is refused.
|
|
38
38
|
// Caches within the run so the same name is never created twice — shared by
|
|
39
39
|
// `link_record`'s create:<Name> path and `create_record`'s company linking.
|
|
40
|
-
async function
|
|
41
|
-
const
|
|
42
|
-
const cached = createdAccountsByName.get(
|
|
40
|
+
async function resolveOrCreateAccount(name, domain) {
|
|
41
|
+
const cacheKey = domain ? `d:${domain.toLowerCase()}` : `n:${name.toLowerCase()}`;
|
|
42
|
+
const cached = createdAccountsByName.get(cacheKey);
|
|
43
43
|
if (cached)
|
|
44
44
|
return { id: cached, createdNew: false };
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
const esc = (v) => v.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
46
|
+
// 1. Domain-first match on Website (the accurate key; Website often stores a
|
|
47
|
+
// full URL, so match by substring).
|
|
48
|
+
if (domain) {
|
|
49
|
+
const byDomain = await query(`SELECT Id FROM Account WHERE Website LIKE '%${esc(domain)}%' LIMIT 3`);
|
|
50
|
+
if (byDomain.length > 1)
|
|
51
|
+
return { ambiguous: byDomain.length };
|
|
52
|
+
if (byDomain.length === 1) {
|
|
53
|
+
const id = String(byDomain[0].Id);
|
|
54
|
+
createdAccountsByName.set(cacheKey, id);
|
|
55
|
+
return { id, createdNew: false };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// 2. Exact-name match; stamp Website if the account has none (fill-blank).
|
|
59
|
+
const byName = await query(`SELECT Id, Website FROM Account WHERE Name = '${esc(name)}' LIMIT 3`);
|
|
60
|
+
if (byName.length > 1)
|
|
61
|
+
return { ambiguous: byName.length };
|
|
49
62
|
let id;
|
|
50
63
|
let createdNew = false;
|
|
51
|
-
if (
|
|
52
|
-
id = String(
|
|
64
|
+
if (byName.length === 1) {
|
|
65
|
+
id = String(byName[0].Id);
|
|
66
|
+
if (domain && !stringOrUndefined(byName[0].Website)) {
|
|
67
|
+
try {
|
|
68
|
+
await request(`/services/data/${apiVersion}/sobjects/Account/${encodeURIComponent(id)}`, {
|
|
69
|
+
method: "PATCH",
|
|
70
|
+
body: JSON.stringify({ Website: domain }),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// best-effort fill — a failed Website stamp must not sink the create.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
53
77
|
}
|
|
54
78
|
else {
|
|
55
79
|
const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
|
|
56
80
|
method: "POST",
|
|
57
|
-
body: JSON.stringify({ Name: name }),
|
|
81
|
+
body: JSON.stringify({ Name: name, ...(domain ? { Website: domain } : {}) }),
|
|
58
82
|
});
|
|
59
83
|
id = String(created.id);
|
|
60
84
|
createdNew = true;
|
|
61
85
|
}
|
|
62
|
-
createdAccountsByName.set(
|
|
86
|
+
createdAccountsByName.set(cacheKey, id);
|
|
63
87
|
return { id, createdNew };
|
|
64
88
|
}
|
|
65
89
|
async function request(path, init = {}) {
|
|
@@ -427,7 +451,7 @@ export function createSalesforceConnector(options) {
|
|
|
427
451
|
return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
|
|
428
452
|
}
|
|
429
453
|
if (operation.objectType === "account") {
|
|
430
|
-
const resolved = await
|
|
454
|
+
const resolved = await resolveOrCreateAccount(matchValue);
|
|
431
455
|
if ("ambiguous" in resolved) {
|
|
432
456
|
return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — ${resolved.ambiguous} accounts named "${matchValue}". Not creating.` };
|
|
433
457
|
}
|
|
@@ -465,7 +489,7 @@ export function createSalesforceConnector(options) {
|
|
|
465
489
|
let companyNote = "";
|
|
466
490
|
if (payload.associateCompanyName) {
|
|
467
491
|
try {
|
|
468
|
-
const resolved = await
|
|
492
|
+
const resolved = await resolveOrCreateAccount(payload.associateCompanyName, payload.associateCompanyDomain);
|
|
469
493
|
if ("ambiguous" in resolved) {
|
|
470
494
|
companyNote = ` Account "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
|
|
471
495
|
}
|
|
@@ -474,7 +498,8 @@ export function createSalesforceConnector(options) {
|
|
|
474
498
|
method: "PATCH",
|
|
475
499
|
body: JSON.stringify({ AccountId: resolved.id }),
|
|
476
500
|
});
|
|
477
|
-
|
|
501
|
+
const domainNote = payload.associateCompanyDomain ? ` (${payload.associateCompanyDomain})` : "";
|
|
502
|
+
companyNote = ` Linked to account "${payload.associateCompanyName}"${domainNote} (${resolved.id}).`;
|
|
478
503
|
}
|
|
479
504
|
}
|
|
480
505
|
catch (error) {
|
|
@@ -617,7 +642,7 @@ export function createSalesforceConnector(options) {
|
|
|
617
642
|
if (!name) {
|
|
618
643
|
return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
|
|
619
644
|
}
|
|
620
|
-
const resolved = await
|
|
645
|
+
const resolved = await resolveOrCreateAccount(name);
|
|
621
646
|
if ("ambiguous" in resolved) {
|
|
622
647
|
return {
|
|
623
648
|
operationId: operation.id,
|
package/dist/draft.js
CHANGED
|
@@ -197,9 +197,6 @@ export function draft(opts) {
|
|
|
197
197
|
const minScore = opts.minScore ?? 80;
|
|
198
198
|
const channel = opts.channel ?? "task";
|
|
199
199
|
const freshnessDays = opts.freshnessDays ?? DEFAULT_FRESHNESS_DAYS;
|
|
200
|
-
// `task`/`email`/`linkedin` all write a CRM task through the same gate; the
|
|
201
|
-
// object the task hangs off is the contact when known, else the account.
|
|
202
|
-
const objectType = "contact";
|
|
203
200
|
const hot = opts.decisions
|
|
204
201
|
.filter((d) => d.decision === "send" && d.score >= minScore)
|
|
205
202
|
.sort((a, b) => b.score - a.score || a.accountDomain.localeCompare(b.accountDomain));
|
|
@@ -235,17 +232,42 @@ export function draft(opts) {
|
|
|
235
232
|
});
|
|
236
233
|
continue;
|
|
237
234
|
}
|
|
235
|
+
// Resolve a REAL CRM target from the judge decision: a contact id (preferred
|
|
236
|
+
// — that's who we message), else the account id. A domain-only decision
|
|
237
|
+
// (the account isn't in the CRM yet) cannot hang a task off a record — reject
|
|
238
|
+
// it with the fix, instead of forging a contact-typed op carrying a domain.
|
|
239
|
+
let objectType;
|
|
240
|
+
let objectId;
|
|
241
|
+
let targetNote;
|
|
242
|
+
if (decision.contact?.id) {
|
|
243
|
+
objectType = "contact";
|
|
244
|
+
objectId = decision.contact.id;
|
|
245
|
+
targetNote = `contact ${decision.contact.email ?? decision.contact.id}`;
|
|
246
|
+
}
|
|
247
|
+
else if (decision.accountId) {
|
|
248
|
+
objectType = "account";
|
|
249
|
+
objectId = decision.accountId;
|
|
250
|
+
targetNote = `account ${domain}`;
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
rejected.push({
|
|
254
|
+
accountDomain: domain,
|
|
255
|
+
opener,
|
|
256
|
+
reason: `account ${domain} is not in the CRM — acquire it first (enrich acquire), then judge with a snapshot, before drafting`,
|
|
257
|
+
});
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
238
260
|
const stale = isStaleTrigger(signal.firstSeen, now, freshnessDays);
|
|
239
261
|
const ev = draftEvidence(signal, nowIso);
|
|
240
262
|
evidence.push(ev);
|
|
241
|
-
const reasonBase = `Signal-grounded opener for ${domain}: "${signal.trigger}"`;
|
|
263
|
+
const reasonBase = `Signal-grounded opener for ${domain} → ${targetNote}: "${signal.trigger}"`;
|
|
242
264
|
const reason = stale
|
|
243
265
|
? `${reasonBase} [staleTrigger: grounding signal first seen ${signal.firstSeen}, older than ${freshnessDays}d — could this have gone out last month unchanged?]`
|
|
244
266
|
: reasonBase;
|
|
245
267
|
operations.push({
|
|
246
268
|
id: draftOperationId(channel, domain),
|
|
247
269
|
objectType,
|
|
248
|
-
objectId
|
|
270
|
+
objectId,
|
|
249
271
|
operation: "create_task",
|
|
250
272
|
field: "follow_up_task",
|
|
251
273
|
beforeValue: null,
|
package/dist/enrich.d.ts
CHANGED
|
@@ -68,6 +68,12 @@ export type AcquireCreateMap = {
|
|
|
68
68
|
matchKey: string;
|
|
69
69
|
/** Optional source path to a company name; the connector resolves-or-creates it. */
|
|
70
70
|
associateCompanyFrom?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Optional source path to the company domain. With it (or a derivable email
|
|
73
|
+
* domain) the connector resolves the account by domain and stamps the domain
|
|
74
|
+
* on it — so the lead's account is signal-watchable, not just a text field.
|
|
75
|
+
*/
|
|
76
|
+
associateCompanyDomainFrom?: string;
|
|
71
77
|
};
|
|
72
78
|
/**
|
|
73
79
|
* Net-new discovery for an API acquire source. `provider` selects the adapter
|