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/dist/enrich.js
CHANGED
|
@@ -247,6 +247,8 @@ export function builtinAcquirePreset(source) {
|
|
|
247
247
|
company: "companyName",
|
|
248
248
|
email: "email",
|
|
249
249
|
},
|
|
250
|
+
associateCompanyFrom: "companyName",
|
|
251
|
+
associateCompanyDomainFrom: "companyDomain",
|
|
250
252
|
},
|
|
251
253
|
},
|
|
252
254
|
},
|
|
@@ -274,6 +276,8 @@ export function builtinAcquirePreset(source) {
|
|
|
274
276
|
company: "companyName",
|
|
275
277
|
hs_linkedin_url: "linkedin",
|
|
276
278
|
},
|
|
279
|
+
associateCompanyFrom: "companyName",
|
|
280
|
+
associateCompanyDomainFrom: "companyDomain",
|
|
277
281
|
},
|
|
278
282
|
},
|
|
279
283
|
},
|
|
@@ -490,6 +494,28 @@ function crmFieldValue(snapshot, objectType, objectId, field) {
|
|
|
490
494
|
function isEmptyValue(value) {
|
|
491
495
|
return value === undefined || value === null || (typeof value === "string" && value.trim() === "");
|
|
492
496
|
}
|
|
497
|
+
/** Bare registrable host from a domain or URL ("https://www.x.com/a" → "x.com"). */
|
|
498
|
+
function normalizeCompanyDomain(value) {
|
|
499
|
+
if (typeof value !== "string" || !value.trim())
|
|
500
|
+
return undefined;
|
|
501
|
+
const host = value
|
|
502
|
+
.trim()
|
|
503
|
+
.toLowerCase()
|
|
504
|
+
.replace(/^https?:\/\//, "")
|
|
505
|
+
.replace(/^www\./, "")
|
|
506
|
+
.replace(/\/.*$/, "")
|
|
507
|
+
.replace(/\s+/g, "");
|
|
508
|
+
return host && host.includes(".") ? host : undefined;
|
|
509
|
+
}
|
|
510
|
+
/** The domain of a work email ("vp@acme.com" → "acme.com"), else undefined. */
|
|
511
|
+
function domainFromEmail(value) {
|
|
512
|
+
if (typeof value !== "string")
|
|
513
|
+
return undefined;
|
|
514
|
+
const at = value.lastIndexOf("@");
|
|
515
|
+
if (at < 0)
|
|
516
|
+
return undefined;
|
|
517
|
+
return normalizeCompanyDomain(value.slice(at + 1));
|
|
518
|
+
}
|
|
493
519
|
/** Values compare as trimmed strings; numbers compare numerically. */
|
|
494
520
|
function sameValue(a, b) {
|
|
495
521
|
if (isEmptyValue(a) && isEmptyValue(b))
|
|
@@ -831,6 +857,21 @@ export function buildAcquirePlan(options) {
|
|
|
831
857
|
if (!isEmptyValue(companyValue))
|
|
832
858
|
associateCompanyName = String(companyValue);
|
|
833
859
|
}
|
|
860
|
+
// The company domain makes the lead's account signal-watchable. Prefer the
|
|
861
|
+
// configured source path; fall back to the work email's domain (free, and
|
|
862
|
+
// for B2B leads the email domain IS the company domain).
|
|
863
|
+
let associateCompanyDomain;
|
|
864
|
+
if (associateCompanyName) {
|
|
865
|
+
const fromPath = createMap.associateCompanyDomainFrom
|
|
866
|
+
? sourceValueAt(record.payload, createMap.associateCompanyDomainFrom)
|
|
867
|
+
: undefined;
|
|
868
|
+
const candidate = !isEmptyValue(fromPath)
|
|
869
|
+
? String(fromPath)
|
|
870
|
+
: domainFromEmail(sourceValueAt(record.payload, "email"));
|
|
871
|
+
const normalized = normalizeCompanyDomain(candidate);
|
|
872
|
+
if (normalized)
|
|
873
|
+
associateCompanyDomain = normalized;
|
|
874
|
+
}
|
|
834
875
|
const recordEvidence = evidenceFor(source, sourceConfig.kind, sourceConfig.format, record, undefined, nowIso);
|
|
835
876
|
evidence.push(recordEvidence);
|
|
836
877
|
// Resolve ownership BEFORE the meter-charged create lands, so the lead is
|
|
@@ -853,8 +894,13 @@ export function buildAcquirePlan(options) {
|
|
|
853
894
|
source,
|
|
854
895
|
estCostUsd: costPerRecord,
|
|
855
896
|
associateCompanyName,
|
|
897
|
+
...(associateCompanyDomain ? { associateCompanyDomain } : {}),
|
|
856
898
|
...(ownerId ? { ownerId, assignedBy } : {}),
|
|
857
899
|
};
|
|
900
|
+
// Surface the account link in the dry-run so it is not a silent side-effect.
|
|
901
|
+
const accountNote = associateCompanyName
|
|
902
|
+
? ` Resolves/creates its account "${associateCompanyName}"${associateCompanyDomain ? ` (${associateCompanyDomain})` : ""} and links the lead.`
|
|
903
|
+
: "";
|
|
858
904
|
operations.push({
|
|
859
905
|
id: `op_acq_${fnv1a(`${source}:${record.objectType}:${matchValue}`)}`,
|
|
860
906
|
objectType: canonicalObjectType(record.objectType),
|
|
@@ -863,7 +909,7 @@ export function buildAcquirePlan(options) {
|
|
|
863
909
|
beforeValue: null,
|
|
864
910
|
afterValue: payload,
|
|
865
911
|
reason: `${source} sourced net-new ${record.objectType} "${describeSourceRecord(record)}" ` +
|
|
866
|
-
`(${createMap.matchKey}=${matchValue}); no CRM match — create as a lead
|
|
912
|
+
`(${createMap.matchKey}=${matchValue}); no CRM match — create as a lead.${accountNote}`,
|
|
867
913
|
sourceRuleOrPolicy: `acquire:${source}`,
|
|
868
914
|
riskLevel: "medium",
|
|
869
915
|
approvalRequired: true,
|
package/dist/health.d.ts
CHANGED
|
@@ -21,6 +21,18 @@ export type HealthEntry = {
|
|
|
21
21
|
byRule: Record<string, number>;
|
|
22
22
|
/** Finding count per severity. */
|
|
23
23
|
severityCounts: Record<AuditFindingSeverity, number>;
|
|
24
|
+
/**
|
|
25
|
+
* Per-object-type breakdown — so "is my contact data clean but my pipeline
|
|
26
|
+
* messy?" is answerable without re-auditing. Each type carries its own
|
|
27
|
+
* record-normalized score (same curve as the overall score, scoped to that
|
|
28
|
+
* type's records + findings).
|
|
29
|
+
*/
|
|
30
|
+
byObjectType: Record<"account" | "contact" | "deal", {
|
|
31
|
+
records: number;
|
|
32
|
+
findings: number;
|
|
33
|
+
weightedFindings: number;
|
|
34
|
+
score: number;
|
|
35
|
+
}>;
|
|
24
36
|
};
|
|
25
37
|
export type HealthRuleDelta = {
|
|
26
38
|
ruleId: string;
|
package/dist/health.js
CHANGED
|
@@ -23,11 +23,21 @@ const SEVERITY_WEIGHT = { info: 1, warning: 3, critical: 10 };
|
|
|
23
23
|
export function computeHealth(plan, snapshot, at) {
|
|
24
24
|
const byRule = {};
|
|
25
25
|
const severityCounts = { info: 0, warning: 0, critical: 0 };
|
|
26
|
+
const typeTally = {
|
|
27
|
+
account: { findings: 0, weightedFindings: 0 },
|
|
28
|
+
contact: { findings: 0, weightedFindings: 0 },
|
|
29
|
+
deal: { findings: 0, weightedFindings: 0 },
|
|
30
|
+
};
|
|
26
31
|
let weightedFindings = 0;
|
|
27
32
|
for (const finding of plan.findings) {
|
|
28
33
|
byRule[finding.ruleId] = (byRule[finding.ruleId] ?? 0) + 1;
|
|
29
34
|
severityCounts[finding.severity] += 1;
|
|
30
35
|
weightedFindings += SEVERITY_WEIGHT[finding.severity];
|
|
36
|
+
const t = finding.objectType;
|
|
37
|
+
if (t === "account" || t === "contact" || t === "deal") {
|
|
38
|
+
typeTally[t].findings += 1;
|
|
39
|
+
typeTally[t].weightedFindings += SEVERITY_WEIGHT[finding.severity];
|
|
40
|
+
}
|
|
31
41
|
}
|
|
32
42
|
const records = {
|
|
33
43
|
accounts: snapshot.accounts.length,
|
|
@@ -35,8 +45,18 @@ export function computeHealth(plan, snapshot, at) {
|
|
|
35
45
|
deals: snapshot.deals.length,
|
|
36
46
|
total: snapshot.accounts.length + snapshot.contacts.length + snapshot.deals.length,
|
|
37
47
|
};
|
|
38
|
-
const
|
|
39
|
-
const
|
|
48
|
+
const scoreFor = (weighted, recordCount) => Math.round(100 / (1 + weighted / Math.max(recordCount, 1)));
|
|
49
|
+
const recordsByType = { account: records.accounts, contact: records.contacts, deal: records.deals };
|
|
50
|
+
const byObjectType = ["account", "contact", "deal"].reduce((acc, t) => {
|
|
51
|
+
acc[t] = {
|
|
52
|
+
records: recordsByType[t],
|
|
53
|
+
findings: typeTally[t].findings,
|
|
54
|
+
weightedFindings: typeTally[t].weightedFindings,
|
|
55
|
+
score: scoreFor(typeTally[t].weightedFindings, recordsByType[t]),
|
|
56
|
+
};
|
|
57
|
+
return acc;
|
|
58
|
+
}, {});
|
|
59
|
+
const score = scoreFor(weightedFindings, records.total);
|
|
40
60
|
return {
|
|
41
61
|
at,
|
|
42
62
|
planId: plan.id,
|
|
@@ -46,6 +66,7 @@ export function computeHealth(plan, snapshot, at) {
|
|
|
46
66
|
records,
|
|
47
67
|
byRule,
|
|
48
68
|
severityCounts,
|
|
69
|
+
byObjectType,
|
|
49
70
|
};
|
|
50
71
|
}
|
|
51
72
|
/** Roll a timeline up into the current state, the change since last time, and per-rule deltas. */
|
|
@@ -95,6 +116,12 @@ export function healthToMarkdown(rollup) {
|
|
|
95
116
|
? "(first audit — no prior reading)"
|
|
96
117
|
: `${arrow(scoreDelta)} ${scoreDelta >= 0 ? "+" : ""}${scoreDelta} since the previous audit`;
|
|
97
118
|
lines.push(`Score: **${current.score}/100** ${deltaText}`, `Audits: ${rollup.auditCount} over ${shortDate(rollup.first)} → ${shortDate(rollup.latest)}`, `Findings: ${current.findings} (${current.severityCounts.critical} critical, ${current.severityCounts.warning} warning, ${current.severityCounts.info} info)`, `Records: ${current.records.accounts} accounts, ${current.records.contacts} contacts, ${current.records.deals} deals`, "");
|
|
119
|
+
lines.push("## By object type", "", "| Type | Score | Findings | Records |", "| --- | --- | --- | --- |");
|
|
120
|
+
for (const t of ["account", "contact", "deal"]) {
|
|
121
|
+
const b = current.byObjectType[t];
|
|
122
|
+
lines.push(`| ${t} | ${b.score}/100 | ${b.findings} | ${b.records} |`);
|
|
123
|
+
}
|
|
124
|
+
lines.push("");
|
|
98
125
|
if (rollup.history.length > 1) {
|
|
99
126
|
lines.push("## Trend", "");
|
|
100
127
|
for (const point of rollup.history) {
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export { createStripeConnector, type StripeConnectorOptions } from "./connectors
|
|
|
13
13
|
export { activeProfile, credentialsDir, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotAccessToken, resolveHubspotConnection, setActiveProfile, storeCredential, type HubspotConnection, type StoredCredential, } from "./credentials.ts";
|
|
14
14
|
export { generateDemoSnapshot, type DemoSnapshotOptions } from "./demo.ts";
|
|
15
15
|
export { buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, enrichRunId, inferIngestObjectType, ingestKeyValue, latestStamps, loadEnrichConfig, matchSourceRecord, parseCsv, parseEnrichConfig, resolveCrmField, selectStaleWork, sourceValueAt, stagedSourceRecords, staleDaysFor, type BuildEnrichPlanOptions, type EnrichAmbiguity, type EnrichConfig, type EnrichCounts, type EnrichFieldConfig, type EnrichMatchConfig, type EnrichMode, type EnrichObjectType, type EnrichPlanResult, type EnrichRun, type EnrichRunStore, type EnrichSourceConfig, type EnrichSourceRecord, type EnrichStamp, type EnrichWorkItem, type MatchOutcome, } from "./enrich.ts";
|
|
16
|
+
export { scaffoldWorkspace, starterEnrichConfig, starterIcp, starterPlaybook, type InitProvider, type InitSource, type ScaffoldFile, type ScaffoldOptions, } from "./init.ts";
|
|
16
17
|
export { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, type ApolloClient, type ApolloClientOptions, type ApolloPullKey, type ApolloPullResult, } from "./enrichApollo.ts";
|
|
17
18
|
export { diffFindings, diffSnapshots, diffToMarkdown, type CollectionDiff, type FieldChange, type FindingsDrift, type RecordChange, type SnapshotDiff, } from "./diff.ts";
|
|
18
19
|
export { mergeSnapshots, type MergeConflict, type MergeMatch, type MergeReport, type MergeSuggestion, } from "./merge.ts";
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ export { createStripeConnector } from "./connectors/stripe.js";
|
|
|
13
13
|
export { activeProfile, credentialsDir, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotAccessToken, resolveHubspotConnection, setActiveProfile, storeCredential, } from "./credentials.js";
|
|
14
14
|
export { generateDemoSnapshot } from "./demo.js";
|
|
15
15
|
export { buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, enrichRunId, inferIngestObjectType, ingestKeyValue, latestStamps, loadEnrichConfig, matchSourceRecord, parseCsv, parseEnrichConfig, resolveCrmField, selectStaleWork, sourceValueAt, stagedSourceRecords, staleDaysFor, } from "./enrich.js";
|
|
16
|
+
export { scaffoldWorkspace, starterEnrichConfig, starterIcp, starterPlaybook, } from "./init.js";
|
|
16
17
|
export { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, } from "./enrichApollo.js";
|
|
17
18
|
export { diffFindings, diffSnapshots, diffToMarkdown, } from "./diff.js";
|
|
18
19
|
export { mergeSnapshots, } from "./merge.js";
|
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
import { type EnrichConfig } from "./enrich.ts";
|
|
18
|
+
import { type Icp } from "./icp.ts";
|
|
19
|
+
export type InitProvider = "hubspot" | "salesforce";
|
|
20
|
+
export type InitSource = "pipe0" | "explorium" | "linkedin";
|
|
21
|
+
export type ScaffoldOptions = {
|
|
22
|
+
/** discovery source the acquire preset + playbook are wired for. Default "pipe0". */
|
|
23
|
+
source?: InitSource;
|
|
24
|
+
/** CRM the playbook writes against. Default "hubspot". */
|
|
25
|
+
provider?: InitProvider;
|
|
26
|
+
/** profile the playbook scopes commands to (omitted = default profile). */
|
|
27
|
+
profile?: string;
|
|
28
|
+
};
|
|
29
|
+
export type ScaffoldFile = {
|
|
30
|
+
path: string;
|
|
31
|
+
content: string;
|
|
32
|
+
};
|
|
33
|
+
/** A generic, valid starter ICP. Edit it, or replace via `icp interview`. */
|
|
34
|
+
export declare function starterIcp(): Icp;
|
|
35
|
+
/** The acquire preset for the chosen source, with an explicit (placeholder)
|
|
36
|
+
* assign policy so the seam is visible — leads are never silently ownerless. */
|
|
37
|
+
export declare function starterEnrichConfig(source: InitSource): EnrichConfig;
|
|
38
|
+
/** The workspace PLAYBOOK: the cold-start + outbound-loop recipes wired with the
|
|
39
|
+
* chosen source/provider/profile, pointing at docs/recipes.md for the rest. */
|
|
40
|
+
export declare function starterPlaybook(opts: Required<Pick<ScaffoldOptions, "source" | "provider">> & {
|
|
41
|
+
profile?: string;
|
|
42
|
+
}): string;
|
|
43
|
+
/**
|
|
44
|
+
* Build the set of files `init` would write (pure — no IO). The caller decides
|
|
45
|
+
* which to actually write (skipping existing files unless --force).
|
|
46
|
+
*/
|
|
47
|
+
export declare function scaffoldWorkspace(opts?: ScaffoldOptions): ScaffoldFile[];
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
import { builtinAcquirePreset, ENRICH_CONFIG_FILE_NAME } from "./enrich.js";
|
|
18
|
+
import { DEFAULT_FIT_THRESHOLD } from "./icp.js";
|
|
19
|
+
/** A placeholder owner id that can never match a real one, so the assign block
|
|
20
|
+
* is VISIBLE (leads route through it) yet safe: an unknown owner resolves to
|
|
21
|
+
* unassigned with a warning, never a wrong owner. Replace before acquiring. */
|
|
22
|
+
const PLACEHOLDER_OWNER = "REPLACE_WITH_OWNER_ID";
|
|
23
|
+
/** A generic, valid starter ICP. Edit it, or replace via `icp interview`. */
|
|
24
|
+
export function starterIcp() {
|
|
25
|
+
return {
|
|
26
|
+
name: "Example ICP — edit me (or rebuild with `fullstackgtm icp interview`)",
|
|
27
|
+
firmographics: {
|
|
28
|
+
industries: ["software", "saas"],
|
|
29
|
+
employeeBands: ["51-200", "201-500", "501-1000"],
|
|
30
|
+
geos: ["us"],
|
|
31
|
+
},
|
|
32
|
+
persona: {
|
|
33
|
+
jobLevels: ["vp", "director", "manager"],
|
|
34
|
+
departments: ["sales", "operations"],
|
|
35
|
+
titleKeywords: ["revenue operations", "revops", "sales operations", "gtm operations"],
|
|
36
|
+
},
|
|
37
|
+
scoring: { threshold: DEFAULT_FIT_THRESHOLD },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** The acquire preset for the chosen source, with an explicit (placeholder)
|
|
41
|
+
* assign policy so the seam is visible — leads are never silently ownerless. */
|
|
42
|
+
export function starterEnrichConfig(source) {
|
|
43
|
+
const preset = builtinAcquirePreset(source);
|
|
44
|
+
if (!preset?.acquire) {
|
|
45
|
+
// builtinAcquirePreset covers pipe0/explorium/linkedin, so this is unreachable
|
|
46
|
+
// for the typed InitSource set — guard anyway rather than emit a broken file.
|
|
47
|
+
throw new Error(`init: no acquire preset for source "${source}"`);
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
...preset,
|
|
51
|
+
acquire: { ...preset.acquire, assign: { strategy: "fixed", ownerId: PLACEHOLDER_OWNER } },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function profileFlag(profile) {
|
|
55
|
+
return profile ? ` --profile ${profile}` : "";
|
|
56
|
+
}
|
|
57
|
+
/** The workspace PLAYBOOK: the cold-start + outbound-loop recipes wired with the
|
|
58
|
+
* chosen source/provider/profile, pointing at docs/recipes.md for the rest. */
|
|
59
|
+
export function starterPlaybook(opts) {
|
|
60
|
+
const { source, provider, profile } = opts;
|
|
61
|
+
const p = profileFlag(profile);
|
|
62
|
+
const loginSource = source === "linkedin" ? "heyreach" : source;
|
|
63
|
+
return `# Workspace playbook
|
|
64
|
+
|
|
65
|
+
Scaffolded by \`fullstackgtm init\` for **${provider}**, discovery via **${source}**${profile ? `, profile **${profile}**` : ""}.
|
|
66
|
+
|
|
67
|
+
This CLI ships governed **primitives** — there is no \`outbound\` command. **You
|
|
68
|
+
(the coding agent) are the orchestrator:** chain the verbs into the play the
|
|
69
|
+
operator wants, surface the one approve gate, and bridge the last mile to the
|
|
70
|
+
sender. **The package never sends** — \`draft\` produces an approved task/opener;
|
|
71
|
+
the actual send happens in the operator's own channel tool.
|
|
72
|
+
|
|
73
|
+
Full recipe set: **docs/recipes.md** (cold-start, the trigger→judge→draft
|
|
74
|
+
outbound loop, scheduled-continuous, ABM-from-companies, hygiene-gated).
|
|
75
|
+
|
|
76
|
+
## 0. Connect (secrets via stdin/env, never argv)
|
|
77
|
+
|
|
78
|
+
\`\`\`bash
|
|
79
|
+
echo "$${provider === "hubspot" ? "HUBSPOT_TOKEN" : "SALESFORCE_ACCESS_TOKEN"}" | fullstackgtm login ${provider}${p}
|
|
80
|
+
echo "$${loginSource.toUpperCase()}_API_KEY" | fullstackgtm login ${loginSource}${p}
|
|
81
|
+
\`\`\`
|
|
82
|
+
|
|
83
|
+
## 1. Edit your targeting + assignment
|
|
84
|
+
|
|
85
|
+
- \`icp.json\` — the starter ICP. Edit it, or rebuild: \`fullstackgtm icp interview\`
|
|
86
|
+
(an agent drives the questions) → \`fullstackgtm icp set answers.json\`.
|
|
87
|
+
- \`${ENRICH_CONFIG_FILE_NAME}\` — set \`acquire.assign.ownerId\` (currently
|
|
88
|
+
\`${PLACEHOLDER_OWNER}\`) so acquired leads are never ownerless, or pass
|
|
89
|
+
\`--assign-owner <id>\` per run. Tune \`acquire.budget\` (records + spend caps).
|
|
90
|
+
|
|
91
|
+
## 2. Cold start — fill the CRM with targeted, owned, emailed leads
|
|
92
|
+
|
|
93
|
+
\`\`\`bash
|
|
94
|
+
fullstackgtm enrich acquire --source ${source} --provider ${provider}${p} --json # dry-run plan, writes NOTHING
|
|
95
|
+
fullstackgtm enrich acquire --source ${source} --provider ${provider}${p} --save # persist as needs_approval
|
|
96
|
+
fullstackgtm plans approve <plan-id>${p} --operations all
|
|
97
|
+
fullstackgtm apply --plan-id <plan-id> --provider ${provider}${p}
|
|
98
|
+
\`\`\`
|
|
99
|
+
|
|
100
|
+
Each lead lands owner-stamped and linked to a domain-stamped **account**, so the
|
|
101
|
+
signals/judge layer can watch it.
|
|
102
|
+
|
|
103
|
+
## 3. Outbound loop — reach an account the week something changes
|
|
104
|
+
|
|
105
|
+
\`\`\`bash
|
|
106
|
+
fullstackgtm signals fetch --bucket job,funding --watchlist crm:<segment>${p} --save
|
|
107
|
+
fullstackgtm icp judge --signals-from latest --provider ${provider}${p} --save --json # pass the snapshot → resolves accountId + contact
|
|
108
|
+
fullstackgtm draft --from-judge latest --channel email${p} --save --json # one grounded opener per hot account, as a create_task
|
|
109
|
+
fullstackgtm plans approve <plan-id>${p} --operations all
|
|
110
|
+
fullstackgtm apply --plan-id <plan-id> --provider ${provider}${p}
|
|
111
|
+
# → the agent sends the approved opener via the operator's channel tool, then:
|
|
112
|
+
fullstackgtm signals outcome --account <domain> --contact <contactId> --result replied${p}
|
|
113
|
+
\`\`\`
|
|
114
|
+
|
|
115
|
+
A domain-only judge decision (account not yet in the CRM) is rejected by \`draft\`
|
|
116
|
+
with "acquire it first" — run step 2 for that account, then re-judge.
|
|
117
|
+
|
|
118
|
+
## The boundary
|
|
119
|
+
|
|
120
|
+
- **Read freely. Write only through \`plans approve → apply\`.**
|
|
121
|
+
- **The package never sends.** \`draft\` is the last governed step.
|
|
122
|
+
- **You are the orchestrator.** These are starting points — compose them.
|
|
123
|
+
`;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Build the set of files `init` would write (pure — no IO). The caller decides
|
|
127
|
+
* which to actually write (skipping existing files unless --force).
|
|
128
|
+
*/
|
|
129
|
+
export function scaffoldWorkspace(opts = {}) {
|
|
130
|
+
const source = opts.source ?? "pipe0";
|
|
131
|
+
const provider = opts.provider ?? "hubspot";
|
|
132
|
+
return [
|
|
133
|
+
{ path: "icp.json", content: `${JSON.stringify(starterIcp(), null, 2)}\n` },
|
|
134
|
+
{
|
|
135
|
+
path: ENRICH_CONFIG_FILE_NAME,
|
|
136
|
+
content: `${JSON.stringify(starterEnrichConfig(source), null, 2)}\n`,
|
|
137
|
+
},
|
|
138
|
+
{ path: "PLAYBOOK.md", content: starterPlaybook({ source, provider, profile: opts.profile }) },
|
|
139
|
+
];
|
|
140
|
+
}
|
package/dist/judge.d.ts
CHANGED
|
@@ -26,6 +26,23 @@ import type { CanonicalGtmSnapshot } from "./types.ts";
|
|
|
26
26
|
export type JudgeDecisionKind = "send" | "nurture" | "skip";
|
|
27
27
|
export type JudgeDecision = {
|
|
28
28
|
accountDomain: string;
|
|
29
|
+
/**
|
|
30
|
+
* The CRM account this domain resolves to, when a snapshot was provided.
|
|
31
|
+
* Absent = the account is not in the CRM (a net-new domain) — downstream
|
|
32
|
+
* verbs must acquire it before they can write against it.
|
|
33
|
+
*/
|
|
34
|
+
accountId?: string;
|
|
35
|
+
/**
|
|
36
|
+
* The contact at the account to reach, when resolvable from the snapshot —
|
|
37
|
+
* the answer to "who do I message at this hot account". `draft` targets this
|
|
38
|
+
* contact's id; absent contact + present accountId targets the account.
|
|
39
|
+
*/
|
|
40
|
+
contact?: ContactRef;
|
|
41
|
+
/**
|
|
42
|
+
* All in-CRM contacts at the account (primary first), capped — so an agent can
|
|
43
|
+
* multi-thread beyond the single primary. `contact` is `contacts[0]`.
|
|
44
|
+
*/
|
|
45
|
+
contacts?: ContactRef[];
|
|
29
46
|
/** 0-100. */
|
|
30
47
|
score: number;
|
|
31
48
|
decision: JudgeDecisionKind;
|
|
@@ -125,9 +142,9 @@ export declare function scoreAccount(opts: {
|
|
|
125
142
|
*/
|
|
126
143
|
export declare function accountRecentlyTouched(accountDomain: string, snapshot: CanonicalGtmSnapshot, now?: Date, windowDays?: number): boolean;
|
|
127
144
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
145
|
+
* Best-matching contact for an account, shaped for fit scoring (the title is
|
|
146
|
+
* what `scoreProspectAgainstIcp` reads). Returns undefined when the
|
|
147
|
+
* account/contact isn't in the snapshot.
|
|
131
148
|
*/
|
|
132
149
|
export declare function bestContactForAccount(accountDomain: string, snapshot: CanonicalGtmSnapshot): {
|
|
133
150
|
jobTitle?: string;
|
|
@@ -135,6 +152,22 @@ export declare function bestContactForAccount(accountDomain: string, snapshot: C
|
|
|
135
152
|
jobDepartment?: string;
|
|
136
153
|
headline?: string;
|
|
137
154
|
} | undefined;
|
|
155
|
+
/**
|
|
156
|
+
* Resolve the CRM target for an account domain: its `accountId` (when the
|
|
157
|
+
* account exists in the snapshot) and the best `contact` to reach (id + email +
|
|
158
|
+
* title). This is what `draft` writes against — a real record id, never the
|
|
159
|
+
* domain. `{}` when the account is not in the CRM (a net-new domain).
|
|
160
|
+
*/
|
|
161
|
+
export type ContactRef = {
|
|
162
|
+
id: string;
|
|
163
|
+
email?: string;
|
|
164
|
+
title?: string;
|
|
165
|
+
};
|
|
166
|
+
export declare function resolveAccountTarget(accountDomain: string, snapshot: CanonicalGtmSnapshot): {
|
|
167
|
+
accountId?: string;
|
|
168
|
+
contact?: ContactRef;
|
|
169
|
+
contacts?: ContactRef[];
|
|
170
|
+
};
|
|
138
171
|
/**
|
|
139
172
|
* Build a `JudgeDecision` from an `AccountScore` with the deterministic baseline:
|
|
140
173
|
* whyNow taken VERBATIM from the top credited signal's quote (grounded by
|
package/dist/judge.js
CHANGED
|
@@ -223,22 +223,54 @@ export function accountRecentlyTouched(accountDomain, snapshot, now = new Date()
|
|
|
223
223
|
return false;
|
|
224
224
|
}
|
|
225
225
|
/**
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
* fit
|
|
226
|
+
* Resolve a signal's account domain to the CRM account record and the best
|
|
227
|
+
* contact at it (preferring one with a title). The single domain→account→contact
|
|
228
|
+
* join used by both fit scoring and target surfacing — so "who do I message at
|
|
229
|
+
* this account" is computed once and consistently.
|
|
229
230
|
*/
|
|
230
|
-
|
|
231
|
+
function findAccountAndBestContact(accountDomain, snapshot) {
|
|
231
232
|
const domain = normalizeAccountDomain(accountDomain);
|
|
232
233
|
if (!domain)
|
|
233
234
|
return undefined;
|
|
234
235
|
const account = (snapshot.accounts ?? []).find((a) => normalizeAccountDomain(a.domain ?? "") === domain);
|
|
235
236
|
if (!account)
|
|
236
237
|
return undefined;
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
|
|
238
|
+
// Titled contacts first (a title is what fit scores on); the first is primary.
|
|
239
|
+
const contacts = (snapshot.contacts ?? [])
|
|
240
|
+
.filter((c) => c.accountId === account.id)
|
|
241
|
+
.sort((a, b) => (b.title ? 1 : 0) - (a.title ? 1 : 0));
|
|
242
|
+
return { account, contact: contacts[0], contacts };
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Best-matching contact for an account, shaped for fit scoring (the title is
|
|
246
|
+
* what `scoreProspectAgainstIcp` reads). Returns undefined when the
|
|
247
|
+
* account/contact isn't in the snapshot.
|
|
248
|
+
*/
|
|
249
|
+
export function bestContactForAccount(accountDomain, snapshot) {
|
|
250
|
+
const found = findAccountAndBestContact(accountDomain, snapshot);
|
|
251
|
+
if (!found?.contact)
|
|
240
252
|
return undefined;
|
|
241
|
-
return { jobTitle:
|
|
253
|
+
return { jobTitle: found.contact.title };
|
|
254
|
+
}
|
|
255
|
+
/** Cap on candidate contacts surfaced per account (the rest stay in the CRM). */
|
|
256
|
+
const MAX_CANDIDATE_CONTACTS = 10;
|
|
257
|
+
const toContactRef = (c) => ({
|
|
258
|
+
id: c.id,
|
|
259
|
+
...(c.email ? { email: c.email } : {}),
|
|
260
|
+
...(c.title ? { title: c.title } : {}),
|
|
261
|
+
});
|
|
262
|
+
export function resolveAccountTarget(accountDomain, snapshot) {
|
|
263
|
+
const found = findAccountAndBestContact(accountDomain, snapshot);
|
|
264
|
+
if (!found)
|
|
265
|
+
return {};
|
|
266
|
+
const { account, contact, contacts } = found;
|
|
267
|
+
return {
|
|
268
|
+
accountId: account.id,
|
|
269
|
+
...(contact ? { contact: toContactRef(contact) } : {}),
|
|
270
|
+
// The candidate contacts at the account, so an agent can multi-thread
|
|
271
|
+
// beyond the single primary. Capped; primary is contacts[0].
|
|
272
|
+
...(contacts.length ? { contacts: contacts.slice(0, MAX_CANDIDATE_CONTACTS).map(toContactRef) } : {}),
|
|
273
|
+
};
|
|
242
274
|
}
|
|
243
275
|
// ---------------------------------------------------------------------------
|
|
244
276
|
// Deterministic baseline decision
|
|
@@ -406,8 +438,9 @@ export async function judgeSignals(opts) {
|
|
|
406
438
|
}
|
|
407
439
|
const decisions = [];
|
|
408
440
|
for (const [domain, signals] of byAccount) {
|
|
441
|
+
// Memory + fit stay gated on --with-history (scoring semantics unchanged).
|
|
409
442
|
const recentlyTouched = opts.withHistory && opts.snapshot ? accountRecentlyTouched(domain, opts.snapshot, now) : false;
|
|
410
|
-
const bestContact = opts.icp && opts.snapshot ? bestContactForAccount(domain, opts.snapshot) : undefined;
|
|
443
|
+
const bestContact = opts.withHistory && opts.icp && opts.snapshot ? bestContactForAccount(domain, opts.snapshot) : undefined;
|
|
411
444
|
const score = scoreAccount({
|
|
412
445
|
accountDomain: domain,
|
|
413
446
|
signals,
|
|
@@ -422,7 +455,10 @@ export async function judgeSignals(opts) {
|
|
|
422
455
|
const decision = opts.llm && opts.promptTemplate
|
|
423
456
|
? await judgeDecisionLlm(score, opts.promptTemplate, opts.llm)
|
|
424
457
|
: deterministicDecision(score);
|
|
425
|
-
|
|
458
|
+
// Surface the CRM target (accountId + best contact) whenever a snapshot is
|
|
459
|
+
// present — independent of --with-history. This is what makes a decision
|
|
460
|
+
// actionable: draft writes against contact.id / accountId, never the domain.
|
|
461
|
+
decisions.push(opts.snapshot ? { ...decision, ...resolveAccountTarget(domain, opts.snapshot) } : decision);
|
|
426
462
|
}
|
|
427
463
|
return decisions.sort((a, b) => b.score - a.score || a.accountDomain.localeCompare(b.accountDomain));
|
|
428
464
|
}
|
package/dist/signals.d.ts
CHANGED
|
@@ -53,6 +53,9 @@ export type SignalOutcome = {
|
|
|
53
53
|
id: string;
|
|
54
54
|
accountDomain: string;
|
|
55
55
|
touchId?: string;
|
|
56
|
+
/** The contact the touch went to (from the judge decision), when known — so an
|
|
57
|
+
* outcome credits the person reached, not just the account domain. */
|
|
58
|
+
contactId?: string;
|
|
56
59
|
result: SignalOutcomeResult;
|
|
57
60
|
recordedAt: string;
|
|
58
61
|
/** Signal ids credited (from the judge decision). */
|
|
@@ -191,6 +194,7 @@ export declare function createFileSignalStore(directory?: string): SignalStore;
|
|
|
191
194
|
export declare function makeOutcome(input: {
|
|
192
195
|
accountDomain: string;
|
|
193
196
|
touchId?: string;
|
|
197
|
+
contactId?: string;
|
|
194
198
|
result: SignalOutcomeResult;
|
|
195
199
|
creditedSignals?: string[];
|
|
196
200
|
recordedAt?: string;
|
package/dist/signals.js
CHANGED
|
@@ -508,6 +508,7 @@ export function makeOutcome(input) {
|
|
|
508
508
|
id: outcomeId({ accountDomain, touchId: input.touchId, result: input.result, recordedAt }),
|
|
509
509
|
accountDomain,
|
|
510
510
|
...(input.touchId !== undefined ? { touchId: input.touchId } : {}),
|
|
511
|
+
...(input.contactId !== undefined ? { contactId: input.contactId } : {}),
|
|
511
512
|
result: input.result,
|
|
512
513
|
recordedAt,
|
|
513
514
|
creditedSignals: input.creditedSignals ?? [],
|
package/dist/types.d.ts
CHANGED
|
@@ -24,6 +24,13 @@ export type CreateRecordPayload = {
|
|
|
24
24
|
source: string;
|
|
25
25
|
estCostUsd?: number;
|
|
26
26
|
associateCompanyName?: string;
|
|
27
|
+
/**
|
|
28
|
+
* The company's domain, when known. The connector resolves the account by
|
|
29
|
+
* DOMAIN first (the accurate key), creates it with the domain, and fills the
|
|
30
|
+
* domain on an existing account that lacks one — so the lead's account is a
|
|
31
|
+
* real, signal-watchable record (`signals`/`icp judge` key on account domain).
|
|
32
|
+
*/
|
|
33
|
+
associateCompanyDomain?: string;
|
|
27
34
|
/**
|
|
28
35
|
* Owner to stamp on the new record (canonical owner id). The connector maps
|
|
29
36
|
* it to the CRM's owner property (HubSpot `hubspot_owner_id`) at create time
|
|
@@ -291,7 +298,12 @@ export type PatchPlan = {
|
|
|
291
298
|
status: ApprovalStatus;
|
|
292
299
|
dryRun: true;
|
|
293
300
|
summary: string;
|
|
301
|
+
/** The COMPLETE, object-typed findings list — every account/contact/deal
|
|
302
|
+
* finding, each carrying its `objectType`. This is the canonical set. */
|
|
294
303
|
findings: AuditFinding[];
|
|
304
|
+
/** A deal/sales-PIPELINE projection of `findings` (deal-level + the
|
|
305
|
+
* call-next-step type) for the pipeline-trust queue — intentionally a subset.
|
|
306
|
+
* Account/contact hygiene findings are NOT here; read `findings` for those. */
|
|
295
307
|
pipelineFindings?: PipelineFinding[];
|
|
296
308
|
evidence?: GtmEvidence[];
|
|
297
309
|
operations: PatchOperation[];
|
package/docs/api.md
CHANGED
|
@@ -139,6 +139,14 @@ a possible dup), capped by the meter's headroom. `builtinAcquirePreset` is the
|
|
|
139
139
|
zero-config `EnrichConfig.acquire` (budget, provider, create-mapping) so
|
|
140
140
|
`enrich acquire` runs with just an `icp.json`.
|
|
141
141
|
|
|
142
|
+
**Contacts or accounts.** `acquire.create` is keyed by object type, so the same
|
|
143
|
+
spine acquires net-new **accounts** for ABM, not just contacts: configure
|
|
144
|
+
`acquire.create.company` (match key `domain`, properties → `name`/`domain`) and
|
|
145
|
+
feed company rows (`enrich ingest companies.csv --objects companies`). Each
|
|
146
|
+
unmatched company becomes a `create_record` op of `objectType: account` with its
|
|
147
|
+
domain stamped — so the acquired account is immediately signal-watchable
|
|
148
|
+
(`signals`/`icp judge` key on account domain).
|
|
149
|
+
|
|
142
150
|
- **ICP** (`icp.ts`): `Icp` type, `parseIcp`, `icpToExploriumFilters` /
|
|
143
151
|
`icpToCrustdataFilters` (per-provider discovery filters), `scoreProspectAgainstIcp`
|
|
144
152
|
(persona fit 0..1), `fitThreshold`, `INTERVIEW_SPEC` + `icpFromAnswers` (the
|