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/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[];
|
|
@@ -384,6 +396,17 @@ export type GtmConnector = {
|
|
|
384
396
|
provider: CrmProvider;
|
|
385
397
|
fetchSnapshot: () => Promise<CanonicalGtmSnapshot>;
|
|
386
398
|
applyOperation?: (operation: PatchOperation) => Promise<PatchOperationResult>;
|
|
399
|
+
/**
|
|
400
|
+
* Bulk fast-path for independent `create_record` contact ops: batch the
|
|
401
|
+
* resolve-first read and the create writes instead of one round-trip per
|
|
402
|
+
* record (orders of magnitude fewer API calls for lead acquisition). The
|
|
403
|
+
* caller (`applyPatchPlan`) only routes ops here that are safe to batch —
|
|
404
|
+
* approved, no group, no value override, no company association — and falls
|
|
405
|
+
* back to `applyOperation` for the rest. Implementations MUST preserve
|
|
406
|
+
* resolve-first (never create over an existing match) and return exactly one
|
|
407
|
+
* result per input op. Optional: connectors without it use `applyOperation`.
|
|
408
|
+
*/
|
|
409
|
+
applyCreateContactsBatch?: (operations: PatchOperation[]) => Promise<PatchOperationResult[]>;
|
|
387
410
|
/**
|
|
388
411
|
* Read the live value of one canonical field, used for compare-and-set:
|
|
389
412
|
* apply orchestration refuses to write over values that drifted since the
|
package/docs/api.md
CHANGED
|
@@ -37,9 +37,16 @@ release.
|
|
|
37
37
|
|
|
38
38
|
## Connectors
|
|
39
39
|
|
|
40
|
-
- `GtmConnector` — `{ provider, fetchSnapshot(), applyOperation?, readField?, fetchChanges? }`.
|
|
40
|
+
- `GtmConnector` — `{ provider, fetchSnapshot(), applyOperation?, applyCreateContactsBatch?, readField?, fetchChanges? }`.
|
|
41
41
|
- Connectors never silently drop unresolvable records; audits surface them.
|
|
42
42
|
- `fetchChanges(sinceIso)` returns a partial snapshot; change feeds may omit associations.
|
|
43
|
+
- `applyCreateContactsBatch?(operations)` — optional bulk fast-path for
|
|
44
|
+
independent `create_record` contact ops. `applyPatchPlan` routes safe-to-batch
|
|
45
|
+
creates here (batched resolve-first + batched create — ~N/100 calls instead of
|
|
46
|
+
~2N), and falls back to `applyOperation` per record on a batch rejection and
|
|
47
|
+
for any op that isn't batch-safe (grouped, value-overridden, company-
|
|
48
|
+
associated, conflicted). HubSpot: `search IN` + `batch/create`; Salesforce:
|
|
49
|
+
SOQL `IN` + Composite sObject Collections (`allOrNone:false`).
|
|
43
50
|
- `createHubspotConnector(options)` — read/write/readField/fetchChanges. `applyOperation` implements every `PatchOperationType`: `set_field`, `clear_field`, `link_record`, `create_task`, `create_record` (resolve-first net-new contact/company create — re-checks the dedupe key at apply, never double-creates), `archive_record`, `merge_records` (HubSpot v3 merge — pairwise, irreversible; survivor must belong to the duplicate group). (The Salesforce connector implements the same operation set, with the platform-specific constraints noted below.)
|
|
44
51
|
- `createSalesforceConnector(options)` — read/write/readField/fetchChanges; probabilities normalized to 0..1. `applyOperation` implements every operation type, with two platform constraints: `merge_records` covers **Accounts and Contacts** only (Salesforce exposes no Opportunity merge), and `create_record` resolve-first creates **contacts/accounts** (SOQL search on the match key, create only on a confirmed miss; stamps the canonical `ownerId` onto `OwnerId`).
|
|
45
52
|
- `createStripeConnector(options)` — read-only billing by design (`applyOperation` returns `skipped`); email domains are the cross-system merge keys. Implements `fetchChanges` (incremental via `created[gte]`).
|
|
@@ -132,6 +139,14 @@ a possible dup), capped by the meter's headroom. `builtinAcquirePreset` is the
|
|
|
132
139
|
zero-config `EnrichConfig.acquire` (budget, provider, create-mapping) so
|
|
133
140
|
`enrich acquire` runs with just an `icp.json`.
|
|
134
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
|
+
|
|
135
150
|
- **ICP** (`icp.ts`): `Icp` type, `parseIcp`, `icpToExploriumFilters` /
|
|
136
151
|
`icpToCrustdataFilters` (per-provider discovery filters), `scoreProspectAgainstIcp`
|
|
137
152
|
(persona fit 0..1), `fitThreshold`, `INTERVIEW_SPEC` + `icpFromAnswers` (the
|
package/docs/recipes.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Recipes — composing the primitives into GTM plays
|
|
2
|
+
|
|
3
|
+
fullstackgtm ships **governed primitives**, not a workflow engine. The
|
|
4
|
+
orchestrator is **your coding agent**: it composes these verbs into the play the
|
|
5
|
+
operator wants, surfaces the one human approval gate, and bridges the last mile
|
|
6
|
+
to the sender. There is deliberately no `outbound` mega-command — that would
|
|
7
|
+
hard-code one play when the value is that you assemble the play you need.
|
|
8
|
+
|
|
9
|
+
Every verb emits stable `--json`, names the object type it operates on, and
|
|
10
|
+
surfaces the join it traversed (`accountId` / `domain` / `contactId`), so chains
|
|
11
|
+
compose without hand-gluing the contact↔account relationship. Every write goes
|
|
12
|
+
through the same `dry-run → plans approve → apply` gate. **The package never
|
|
13
|
+
sends** — `draft` produces an approved *task/opener*; the actual send happens in
|
|
14
|
+
the operator's channel tool (HeyReach for LinkedIn, an email tool for email),
|
|
15
|
+
which the agent drives with the operator's credentials.
|
|
16
|
+
|
|
17
|
+
The data spine: a **contact** belongs to an **account** (`contact.accountId`); an
|
|
18
|
+
account is keyed by its **domain**. `signals`/`icp judge` watch account domains;
|
|
19
|
+
`acquire` creates contacts *and* their domain-stamped accounts, so an acquired
|
|
20
|
+
lead is immediately watchable. Keep that join in mind when chaining.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Recipe 1 — Cold start: fill the CRM with targeted, owned, emailed leads
|
|
25
|
+
|
|
26
|
+
**Goal:** go from nothing to a CRM seeded with on-ICP contacts (and their
|
|
27
|
+
signal-watchable accounts).
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# 1. Connect (secrets via stdin/env, never argv)
|
|
31
|
+
echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot
|
|
32
|
+
echo "$PIPE0_KEY" | fullstackgtm login pipe0 # work-email + company-domain resolution
|
|
33
|
+
echo "$EXPLORIUM_KEY" | fullstackgtm login explorium # net-new discovery (optional)
|
|
34
|
+
|
|
35
|
+
# 2. Build the ICP (the agent drives the interview — the CLI can't call AskUserQuestion itself)
|
|
36
|
+
fullstackgtm icp interview # emits INTERVIEW_SPEC; agent asks the questions
|
|
37
|
+
fullstackgtm icp set answers.json # writes ./icp.json
|
|
38
|
+
fullstackgtm icp show # renders the ICP + the per-provider discovery filters
|
|
39
|
+
|
|
40
|
+
# 3. Acquire — dry-run first (writes NOTHING), then approve + apply
|
|
41
|
+
fullstackgtm enrich acquire --source pipe0 --provider hubspot --json # scored, deduped, metered create_record plan
|
|
42
|
+
fullstackgtm enrich acquire --source pipe0 --provider hubspot --save # persist as needs_approval
|
|
43
|
+
fullstackgtm plans approve <plan-id> --operations all
|
|
44
|
+
fullstackgtm apply --plan-id <plan-id> --provider hubspot
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**What lands:** net-new contacts, owner-stamped (never born ownerless), each
|
|
48
|
+
linked to a **domain-stamped account** (so `signals`/`judge` can watch it). The
|
|
49
|
+
run is **metered** (records + spend, per profile) and **resolve-first** (never
|
|
50
|
+
creates over a possible dup). The dry-run op reason names the account it will
|
|
51
|
+
create/link — review it before `--save`.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Recipe 2 — Trigger-based outbound loop (the core play)
|
|
56
|
+
|
|
57
|
+
**Goal:** reach an account the week something changes, with a grounded opener
|
|
58
|
+
that lands on a real contact.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# 1. Detect — fresh buying triggers (free ATS boards in the box; ingest for funding/social)
|
|
62
|
+
fullstackgtm signals fetch --bucket job,funding --watchlist crm:<segment> --save
|
|
63
|
+
|
|
64
|
+
# 2. Judge — rank into send/nurture/skip. PASS THE SNAPSHOT so each decision
|
|
65
|
+
# resolves its CRM target (accountId + the contact[s] to reach).
|
|
66
|
+
fullstackgtm icp judge --signals-from latest --provider hubspot --save --json
|
|
67
|
+
# → each decision carries: accountDomain, accountId, contact {id,email,title},
|
|
68
|
+
# contacts[] (all in-CRM contacts at the account — for multi-threading)
|
|
69
|
+
|
|
70
|
+
# 3. Draft — one trigger-grounded opener per hot account, as a governed create_task
|
|
71
|
+
fullstackgtm draft --from-judge latest --channel email --save --json
|
|
72
|
+
# → the task targets the resolved contact.id (or accountId). A domain-only
|
|
73
|
+
# decision (account not in the CRM yet) is REJECTED with "acquire it first"
|
|
74
|
+
# — run Recipe 1 for that account, then re-judge with the snapshot.
|
|
75
|
+
|
|
76
|
+
# 4. Approve + apply (the ONE human gate)
|
|
77
|
+
fullstackgtm plans approve <plan-id> --operations all
|
|
78
|
+
fullstackgtm apply --plan-id <plan-id> --provider hubspot
|
|
79
|
+
|
|
80
|
+
# 5. Send — OUTSIDE the package. The agent pushes the approved opener to the
|
|
81
|
+
# operator's sender (e.g. HeyReach for LinkedIn, an email tool for email)
|
|
82
|
+
# using the operator's credentials. The package never sends.
|
|
83
|
+
|
|
84
|
+
# 6. Record the outcome — credit the contact you reached, so weights re-learn
|
|
85
|
+
fullstackgtm signals outcome --account <domain> --contact <contactId> --result replied
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Why the snapshot in step 2 matters:** without it, decisions are domain-only and
|
|
89
|
+
`draft` cannot target a real record — it will reject them. With it, the loop is
|
|
90
|
+
contact-coherent end to end. `--with-history` additionally enables the memory
|
|
91
|
+
(don't re-touch a recently-touched account) and fit-scoring inputs.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Recipe 3 — Continuous: schedule the detect/plan side, approve daily
|
|
96
|
+
|
|
97
|
+
**Goal:** run steps 1–3 of Recipe 2 on a cadence; keep the write gated.
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
fullstackgtm schedule add "signals fetch --bucket job,funding --watchlist crm:<segment> --save" --cron "0 7 * * 1-5"
|
|
101
|
+
fullstackgtm schedule add "icp judge --signals-from latest --provider hubspot --save" --cron "15 7 * * 1-5"
|
|
102
|
+
fullstackgtm schedule add "draft --from-judge latest --save" --cron "30 7 * * 1-5"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`schedule` is **read/plan-side only and NEVER auto-approves** — each morning a
|
|
106
|
+
queue of `needs_approval` plans is waiting; the agent presents them, the operator
|
|
107
|
+
approves, and `apply` runs (only `apply --plan-id <id>` is schedulable, and the
|
|
108
|
+
plan's `approved` status is re-checked at every firing).
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Recipe 4 — ABM: bring your target accounts, watch them, draft when they move
|
|
113
|
+
|
|
114
|
+
**Goal:** start from a list of target *companies* (not people).
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# 1. Acquire the ACCOUNTS (acquire is keyed by object type — company rows → account create_record)
|
|
118
|
+
fullstackgtm enrich ingest target-companies.csv --source clay --objects companies
|
|
119
|
+
fullstackgtm enrich acquire --source clay --provider hubspot --save # create.company → accounts WITH domains
|
|
120
|
+
fullstackgtm plans approve <id> --operations all && fullstackgtm apply --plan-id <id> --provider hubspot
|
|
121
|
+
|
|
122
|
+
# 2. From here it's Recipe 2 — the accounts now carry domains, so signals/judge watch them,
|
|
123
|
+
# and (optionally) Recipe 1's acquire fills in contacts at each.
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The `acquire.create.company` mapping (match key `domain`, properties →
|
|
127
|
+
`name`/`domain`) makes each company a **signal-watchable account**. See
|
|
128
|
+
[api.md → Acquire](./api.md).
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Recipe 5 — Hygiene-gate the outbound (don't outbound into a dirty CRM)
|
|
133
|
+
|
|
134
|
+
**Goal:** clean first, so signals/judge reason over correct ownership and
|
|
135
|
+
de-duplicated accounts; measure the lift.
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
fullstackgtm health --json # per-object-type score + trend (read-only)
|
|
139
|
+
fullstackgtm audit --provider hubspot --save # → plan id
|
|
140
|
+
fullstackgtm dedupe account --key domain --save # collapse duplicate accounts (signals key on domain)
|
|
141
|
+
fullstackgtm reassign --assign-unowned --to <ownerId> --save # no ownerless leads
|
|
142
|
+
# approve + apply each, then run Recipe 2. Re-check `health` after to attribute the lift.
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`health --json.byObjectType` tells you *which* object type is messy (clean
|
|
146
|
+
contacts but a messy pipeline read differently). Clean the accounts before
|
|
147
|
+
outbound — `signals`/`judge` key on account domain, so duplicate/owner-less
|
|
148
|
+
accounts distort the queue.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## The boundary, restated
|
|
153
|
+
|
|
154
|
+
- **Read freely. Write only through `plans approve → apply`.** Never bypass it.
|
|
155
|
+
- **The package never sends.** `draft` is the last governed step; the send is the
|
|
156
|
+
agent calling the operator's channel tool with the operator's credentials.
|
|
157
|
+
- **You (the agent) are the orchestrator.** These recipes are starting points —
|
|
158
|
+
compose, branch, and loop them into the play the operator actually wants.
|
package/llms.txt
CHANGED
|
@@ -15,6 +15,7 @@ at/above `--fail-on`.
|
|
|
15
15
|
- [README](https://github.com/fullstackgtm/core/blob/main/README.md): install, five-minute loop, auth ladder, MCP setup, programmatic use
|
|
16
16
|
- [INSTALL_FOR_AGENTS](https://github.com/fullstackgtm/core/blob/main/INSTALL_FOR_AGENTS.md): deterministic install-and-verify steps with expected outputs
|
|
17
17
|
- [Agent skill](https://github.com/fullstackgtm/core/blob/main/skills/fullstackgtm/SKILL.md): compact operating guide, installable via `npx skills add fullstackgtm/core`
|
|
18
|
+
- [Recipes](https://github.com/fullstackgtm/core/blob/main/packages/fullstackgtm/docs/recipes.md): five composable GTM plays over the primitives (cold-start lead-fill, the trigger→judge→draft outbound loop, scheduled-continuous, ABM-from-companies, hygiene-gated outbound) — the CLI ships primitives, your agent is the orchestrator, the package never sends
|
|
18
19
|
- [Architecture](https://github.com/fullstackgtm/core/blob/main/docs/architecture.md): module map + snapshot → audit → plan → apply data flow; "where do I add a rule/connector/operation"
|
|
19
20
|
- [Contributing](https://github.com/fullstackgtm/core/blob/main/CONTRIBUTING.md): dev setup, the open-core mirror model, the release ritual
|
|
20
21
|
- [API reference](https://github.com/fullstackgtm/core/blob/main/docs/api.md): semver-covered surfaces — canonical model, rule interface, plan/apply contract, connector contract, config, CLI, MCP tools
|
|
@@ -117,6 +118,30 @@ injectable `LinkedInProvider` (default HeyReach; `FakeLinkedInProvider` for test
|
|
|
117
118
|
LinkedIn is read-only — Phase 1 has no `linkedin_connect`/`linkedin_message` ops
|
|
118
119
|
and no webhook ingestion (Phase 2). Never auto-writes.
|
|
119
120
|
|
|
121
|
+
## Key invariants (GTM brain — signals / icp / judge / draft)
|
|
122
|
+
|
|
123
|
+
The timing/outbound layer turns "who changed" into one grounded, governed
|
|
124
|
+
opener — and **never sends**. `signals fetch` captures fresh buying triggers
|
|
125
|
+
into a profile-scoped ledger (free Greenhouse/Lever/Ashby ATS scrapers in the
|
|
126
|
+
box; funding/social via `ingest`/`--from`), keyed by **account domain**, ranked
|
|
127
|
+
by learned `weights`; it writes NOTHING to the CRM. `icp interview|set|show`
|
|
128
|
+
builds `icp.json`; `icp judge` ranks fresh signals into send/nurture/skip — pass
|
|
129
|
+
a snapshot (`--provider`/`--input`/`--demo`) and each decision resolves its CRM
|
|
130
|
+
target: `accountId`, the best `contact {id,email,title}`, and `contacts[]` (all
|
|
131
|
+
in-CRM contacts at the account, titled-first, cap 10, for multi-threading);
|
|
132
|
+
`--with-history` additionally gates memory (don't re-touch a recently-touched
|
|
133
|
+
account) and fit-scoring. `icp eval` is the calibration gate (exit 2 below the
|
|
134
|
+
bar). `draft` emits ONE trigger-grounded opener per hot account as a governed
|
|
135
|
+
`create_task` targeting the resolved `contact.id` (or `accountId`) — a
|
|
136
|
+
domain-only decision (account not yet in the CRM) is REJECTED with "acquire it
|
|
137
|
+
first" (run `enrich acquire`, re-judge with the snapshot). `--channel
|
|
138
|
+
email|linkedin|task` shapes the opener; it is a task the operator sends from
|
|
139
|
+
their own tool, never an automated send. `signals outcome --account <domain>
|
|
140
|
+
--contact <id> --result replied|...` credits the contact reached so weights
|
|
141
|
+
re-learn. The contact↔account join (`contact.accountId` + `account.domain`) is
|
|
142
|
+
surfaced at every hop, so the loop is contact-coherent end to end — see the
|
|
143
|
+
[recipes](https://github.com/fullstackgtm/core/blob/main/packages/fullstackgtm/docs/recipes.md).
|
|
144
|
+
|
|
120
145
|
## Key invariants (schedule)
|
|
121
146
|
|
|
122
147
|
`fullstackgtm schedule` is the horizontal scheduler — no feature namespace
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.0",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
|
|
@@ -62,7 +62,10 @@ credentials AND stored plans per client org.
|
|
|
62
62
|
| `fix --rule <id>` | audit one rule → suggest → approve at the confidence bar → apply only with `--yes` |
|
|
63
63
|
| `call parse\|score\|link\|plan` | Transcripts → evidence-quoted insights, rubric scorecards, deal linking, governed next-step writes |
|
|
64
64
|
| `enrich append\|refresh\|ingest\|status` | Governed enrichment (Apollo pull / Clay ingest), fill-blanks-only plans |
|
|
65
|
-
| `enrich acquire [--source explorium\|pipe0\|linkedin]` | Net-new ICP-targeted lead gen: resolve-first deduped `create_record` plans, capped by a per-profile windowed meter (records + spend); owner-stamped via `acquire.assign`/`--assign-owner` (never born ownerless); `--source linkedin --list <id>` reads a HeyReach lead list (Phase 1 discovery, read-only — never sends); never auto-writes |
|
|
65
|
+
| `enrich acquire [--source explorium\|pipe0\|linkedin]` | Net-new ICP-targeted lead gen: resolve-first deduped `create_record` plans, capped by a per-profile windowed meter (records + spend); owner-stamped via `acquire.assign`/`--assign-owner` (never born ownerless); links each lead to a domain-stamped, signal-watchable account; `acquire.create.company` acquires accounts (ABM); `--source linkedin --list <id>` reads a HeyReach lead list (Phase 1 discovery, read-only — never sends); never auto-writes |
|
|
66
|
+
| `signals fetch\|list\|outcome\|weights` | Detect-side timing layer: capture fresh buying triggers into a profile-scoped ledger (free Greenhouse/Lever/Ashby ATS in the box; funding/social via ingest), ranked by learned weights. Writes NOTHING to the CRM; `outcome --account <domain> --contact <id> --result …` re-weights which triggers earn a touch |
|
|
67
|
+
| `icp interview\|set\|show\|judge\|eval` | Build the ICP, then `judge` ranks fresh signals into send/nurture/skip — pass a snapshot (`--provider`/`--input`) and each decision resolves its CRM target (`accountId` + the `contact`/`contacts` to reach). `eval` is the calibration gate (exit 2 below the bar) |
|
|
68
|
+
| `draft [--from-judge latest] [--channel email\|linkedin\|task]` | One trigger-grounded opener per hot account → a governed `create_task` targeting the resolved `contact.id` (or `accountId`); rejects a domain-only decision ("acquire it first"). **Never sends** — the opener is a task the operator sends from their own tool |
|
|
66
69
|
| `market init\|capture\|classify\|worksheet\|observe\|fronts\|axes\|overlay\|scale\|report\|refresh` | Competitive category map; evidence quotes verified verbatim against stored captures |
|
|
67
70
|
| `schedule add\|list\|remove\|enable\|disable\|run\|install\|uninstall\|status` | Horizontal cron; read/plan-side allowlist only — scheduling NEVER auto-approves |
|
|
68
71
|
| `report` | Client-ready audit deliverable (markdown or self-contained HTML) |
|
|
@@ -84,8 +87,20 @@ Tools over stdio: `fullstackgtm_audit` (read-only), `fullstackgtm_rules`,
|
|
|
84
87
|
`fullstackgtm_resolve`, `fullstackgtm_market_worksheet`,
|
|
85
88
|
`fullstackgtm_market_observe`.
|
|
86
89
|
|
|
90
|
+
## Composing the primitives into plays
|
|
91
|
+
|
|
92
|
+
This CLI ships governed **primitives** — there is no `outbound` mega-command.
|
|
93
|
+
**You (the agent) are the orchestrator:** chain these verbs into the play the
|
|
94
|
+
operator wants, surface the one approve gate, and bridge the last mile to the
|
|
95
|
+
sender (**the package never sends**). [docs/recipes.md](https://github.com/fullstackgtm/core/blob/main/packages/fullstackgtm/docs/recipes.md)
|
|
96
|
+
has five worked plays — cold-start lead-fill, the trigger→judge→draft outbound
|
|
97
|
+
loop, scheduled-continuous, ABM-from-companies, and hygiene-gated outbound.
|
|
98
|
+
`fullstackgtm init` scaffolds a workspace (icp.json + enrich config + a PLAYBOOK
|
|
99
|
+
pointing at those recipes) to start from.
|
|
100
|
+
|
|
87
101
|
## Going deeper
|
|
88
102
|
|
|
103
|
+
- [docs/recipes.md](https://github.com/fullstackgtm/core/blob/main/packages/fullstackgtm/docs/recipes.md) — five composable GTM plays over the primitives (cold-start, outbound loop, scheduled, ABM, hygiene-gated)
|
|
89
104
|
- [llms.txt](https://github.com/fullstackgtm/core/blob/main/llms.txt) — the full invariant map per layer (calls, market, write verbs, enrich, schedule, engagement/health)
|
|
90
105
|
- [INSTALL_FOR_AGENTS.md](https://github.com/fullstackgtm/core/blob/main/INSTALL_FOR_AGENTS.md) — deterministic install-and-verify with expected outputs
|
|
91
106
|
- [docs/api.md](https://github.com/fullstackgtm/core/blob/main/docs/api.md) — semver-covered surfaces: canonical model, rule interface, plan/apply contract, connectors, config, CLI, MCP
|