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/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
|
package/src/calls.ts
CHANGED
|
@@ -369,6 +369,15 @@ export type CallDealSuggestion = {
|
|
|
369
369
|
dealName?: string;
|
|
370
370
|
accountId?: string;
|
|
371
371
|
accountName?: string;
|
|
372
|
+
/**
|
|
373
|
+
* HOW the deal's account was matched — `account_domain` (the company-wide,
|
|
374
|
+
* reliable signal) vs `contact_email` (a specific person, possibly stale) vs
|
|
375
|
+
* `both`. An agent needs this to weight the match; the bare confidence hides it.
|
|
376
|
+
*/
|
|
377
|
+
resolvedVia?: "account_domain" | "contact_email" | "both";
|
|
378
|
+
/** The attendee contacts that matched (the email path), so the contact↔account
|
|
379
|
+
* hop is visible, not just the resolved account. */
|
|
380
|
+
matchedContacts?: { id: string; email?: string; accountId?: string }[];
|
|
372
381
|
confidence: "high" | "low" | "none";
|
|
373
382
|
reason: string;
|
|
374
383
|
};
|
|
@@ -393,17 +402,27 @@ export function suggestCallDeal(
|
|
|
393
402
|
}
|
|
394
403
|
|
|
395
404
|
const accountIds = new Set<string>();
|
|
405
|
+
let viaDomain = false;
|
|
406
|
+
let viaEmail = false;
|
|
407
|
+
const matchedContacts: { id: string; email?: string; accountId?: string }[] = [];
|
|
396
408
|
for (const account of snapshot.accounts) {
|
|
397
409
|
const domain = account.domain?.trim().toLowerCase().replace(/^www\./, "");
|
|
398
|
-
if (domain && domains.has(domain))
|
|
410
|
+
if (domain && domains.has(domain)) {
|
|
411
|
+
accountIds.add(account.id);
|
|
412
|
+
viaDomain = true;
|
|
413
|
+
}
|
|
399
414
|
}
|
|
400
415
|
for (const contact of snapshot.contacts) {
|
|
401
416
|
const email = contact.email?.trim().toLowerCase();
|
|
402
417
|
const at = email ? email.indexOf("@") : -1;
|
|
403
418
|
if (email && at > 0 && domains.has(email.slice(at + 1)) && contact.accountId) {
|
|
404
419
|
accountIds.add(contact.accountId);
|
|
420
|
+
viaEmail = true;
|
|
421
|
+
matchedContacts.push({ id: contact.id, ...(contact.email ? { email: contact.email } : {}), accountId: contact.accountId });
|
|
405
422
|
}
|
|
406
423
|
}
|
|
424
|
+
const resolvedVia = viaDomain && viaEmail ? "both" : viaDomain ? "account_domain" : "contact_email";
|
|
425
|
+
const via = { resolvedVia, ...(matchedContacts.length ? { matchedContacts } : {}) } as const;
|
|
407
426
|
if (accountIds.size === 0) {
|
|
408
427
|
return {
|
|
409
428
|
dealId: null,
|
|
@@ -432,8 +451,9 @@ export function suggestCallDeal(
|
|
|
432
451
|
dealName: top.name,
|
|
433
452
|
accountId: top.accountId,
|
|
434
453
|
accountName: account?.name,
|
|
454
|
+
...via,
|
|
435
455
|
confidence: "high",
|
|
436
|
-
reason: `"${top.name}" is the only open deal on matched account "${account?.name ?? top.accountId}".`,
|
|
456
|
+
reason: `"${top.name}" is the only open deal on matched account "${account?.name ?? top.accountId}" (via ${resolvedVia}).`,
|
|
437
457
|
};
|
|
438
458
|
}
|
|
439
459
|
return {
|
|
@@ -441,8 +461,9 @@ export function suggestCallDeal(
|
|
|
441
461
|
dealName: top.name,
|
|
442
462
|
accountId: top.accountId,
|
|
443
463
|
accountName: account?.name,
|
|
464
|
+
...via,
|
|
444
465
|
confidence: "low",
|
|
445
|
-
reason: `${openDeals.length} open deals on matched account(s); "${top.name}" has the most recent activity. Confirm before writing.`,
|
|
466
|
+
reason: `${openDeals.length} open deals on matched account(s); "${top.name}" has the most recent activity (matched via ${resolvedVia}). Confirm before writing.`,
|
|
446
467
|
};
|
|
447
468
|
}
|
|
448
469
|
|
package/src/cli.ts
CHANGED
|
@@ -125,6 +125,11 @@ import {
|
|
|
125
125
|
remaining,
|
|
126
126
|
type AcquireRemaining,
|
|
127
127
|
} from "./acquireMeter.ts";
|
|
128
|
+
import {
|
|
129
|
+
scaffoldWorkspace,
|
|
130
|
+
type InitProvider,
|
|
131
|
+
type InitSource,
|
|
132
|
+
} from "./init.ts";
|
|
128
133
|
import {
|
|
129
134
|
crmContactKeys,
|
|
130
135
|
fetchExploriumProspects,
|
|
@@ -246,6 +251,10 @@ Usage:
|
|
|
246
251
|
the process list and shell history. Pipe them on stdin or enter them at the
|
|
247
252
|
interactive prompt:
|
|
248
253
|
echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot
|
|
254
|
+
fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
|
|
255
|
+
cold start: scaffold icp.json + enrich.config.json + a
|
|
256
|
+
PLAYBOOK wired for this workspace (the CLI ships primitives,
|
|
257
|
+
your agent is the orchestrator — see docs/recipes.md)
|
|
249
258
|
fullstackgtm snapshot [source options] [--since <iso>] [--out <path> | --archive <dir>]
|
|
250
259
|
fullstackgtm audit [source options] [audit options] [--save]
|
|
251
260
|
fullstackgtm report [source options] [audit options] [report options]
|
|
@@ -466,6 +475,17 @@ type HelpEntry = {
|
|
|
466
475
|
};
|
|
467
476
|
|
|
468
477
|
const HELP: Record<string, HelpEntry> = {
|
|
478
|
+
// Get started
|
|
479
|
+
init: {
|
|
480
|
+
summary: "scaffold a workspace (icp.json + enrich.config.json + PLAYBOOK)",
|
|
481
|
+
phase: "Setup",
|
|
482
|
+
synopsis: [
|
|
483
|
+
"fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]",
|
|
484
|
+
],
|
|
485
|
+
detail:
|
|
486
|
+
"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).",
|
|
487
|
+
seeAlso: ["icp", "enrich", "signals"],
|
|
488
|
+
},
|
|
469
489
|
// Setup & health
|
|
470
490
|
login: {
|
|
471
491
|
summary: "connect a provider or LLM key (secrets via stdin/env, never argv)",
|
|
@@ -726,12 +746,13 @@ const HELP: Record<string, HelpEntry> = {
|
|
|
726
746
|
|
|
727
747
|
// Verbs that print their own richer multi-subcommand help; runCli routes their
|
|
728
748
|
// `--help` to themselves, so commandHelp() only renders these via `help <verb>`.
|
|
729
|
-
const BESPOKE_HELP = ["call", "market", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
|
|
749
|
+
const BESPOKE_HELP = ["init", "call", "market", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
|
|
730
750
|
|
|
731
751
|
// Lifecycle-grouped front door. One line per verb, organized by the
|
|
732
752
|
// Prevent→Detect→Remediate→Verify loop so a new user sees ~6 jobs, not 22 verbs.
|
|
733
753
|
function shortUsage() {
|
|
734
754
|
const groups: Array<[string, string[]]> = [
|
|
755
|
+
["Get started", ["init"]],
|
|
735
756
|
["Setup & health", ["login", "logout", "doctor", "profiles", "health"]],
|
|
736
757
|
["Detect — read-only", ["audit", "report", "snapshot", "diff", "rules"]],
|
|
737
758
|
["Prevent — gate writes", ["resolve"]],
|
|
@@ -2659,7 +2680,8 @@ NEVER emits a patch plan; --save persists only the local signal ledger (used by
|
|
|
2659
2680
|
throw new Error(`signals outcome: --result must be one of ${valid.join(", ")}.`);
|
|
2660
2681
|
}
|
|
2661
2682
|
const touchId = option(rest, "--touch") ?? undefined;
|
|
2662
|
-
const
|
|
2683
|
+
const contactId = option(rest, "--contact") ?? undefined;
|
|
2684
|
+
const outcome = makeOutcome({ accountDomain: accountArg, touchId, contactId, result: result as SignalOutcomeResult });
|
|
2663
2685
|
await createFileSignalStore().appendOutcome(outcome);
|
|
2664
2686
|
console.error(
|
|
2665
2687
|
`Recorded outcome "${outcome.result}" for ${outcome.accountDomain} (${outcome.id}). ` +
|
|
@@ -2840,6 +2862,63 @@ LLM key it emits a clearly-labeled stub rather than fake authored copy.`);
|
|
|
2840
2862
|
}
|
|
2841
2863
|
}
|
|
2842
2864
|
|
|
2865
|
+
/**
|
|
2866
|
+
* `init` — scaffold a GTM workspace from cold scratch: a starter icp.json, an
|
|
2867
|
+
* enrich.config.json (acquire preset + a visible assign seam), and a PLAYBOOK
|
|
2868
|
+
* wired with the chosen source/provider that points at the recipes. Pure
|
|
2869
|
+
* file-writer: no network, never overwrites without --force.
|
|
2870
|
+
*/
|
|
2871
|
+
function initCommand(args: string[]) {
|
|
2872
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
2873
|
+
console.log(`Usage:
|
|
2874
|
+
fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
|
|
2875
|
+
|
|
2876
|
+
Scaffolds a workspace so the first acquire/signals/judge/draft commands work:
|
|
2877
|
+
icp.json starter ICP (edit, or rebuild with \`icp interview\`)
|
|
2878
|
+
enrich.config.json acquire preset for --source + a placeholder assign policy
|
|
2879
|
+
PLAYBOOK.md the cold-start + outbound-loop recipes wired for this workspace
|
|
2880
|
+
|
|
2881
|
+
The CLI ships governed primitives; your coding agent is the orchestrator. See
|
|
2882
|
+
docs/recipes.md for the full play set. Existing files are kept unless --force.`);
|
|
2883
|
+
return;
|
|
2884
|
+
}
|
|
2885
|
+
const source = (option(args, "--source") ?? "pipe0") as InitSource;
|
|
2886
|
+
if (!["pipe0", "explorium", "linkedin"].includes(source)) {
|
|
2887
|
+
throw new Error(`init: --source must be pipe0|explorium|linkedin (got "${source}")`);
|
|
2888
|
+
}
|
|
2889
|
+
const provider = (option(args, "--provider") ?? "hubspot") as InitProvider;
|
|
2890
|
+
if (!["hubspot", "salesforce"].includes(provider)) {
|
|
2891
|
+
throw new Error(`init: --provider must be hubspot|salesforce (got "${provider}")`);
|
|
2892
|
+
}
|
|
2893
|
+
const outDir = resolve(process.cwd(), option(args, "--out") ?? ".");
|
|
2894
|
+
const force = args.includes("--force");
|
|
2895
|
+
const current = activeProfile();
|
|
2896
|
+
const profile = current === DEFAULT_PROFILE ? undefined : current;
|
|
2897
|
+
|
|
2898
|
+
const files = scaffoldWorkspace({ source, provider, profile });
|
|
2899
|
+
const wrote: string[] = [];
|
|
2900
|
+
const kept: string[] = [];
|
|
2901
|
+
for (const file of files) {
|
|
2902
|
+
const path = resolve(outDir, file.path);
|
|
2903
|
+
if (existsSync(path) && !force) {
|
|
2904
|
+
kept.push(file.path);
|
|
2905
|
+
continue;
|
|
2906
|
+
}
|
|
2907
|
+
writeFileSync(path, file.content);
|
|
2908
|
+
wrote.push(file.path);
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
console.log(
|
|
2912
|
+
`Scaffolded workspace (${provider}, discovery via ${source}${profile ? `, profile ${profile}` : ""}).`,
|
|
2913
|
+
);
|
|
2914
|
+
if (wrote.length) console.log(` wrote: ${wrote.join(", ")}`);
|
|
2915
|
+
if (kept.length) console.log(` kept (use --force to overwrite): ${kept.join(", ")}`);
|
|
2916
|
+
console.log(
|
|
2917
|
+
"\nNext: edit icp.json + set acquire.assign.ownerId in enrich.config.json, then follow PLAYBOOK.md\n" +
|
|
2918
|
+
"(full recipe set: docs/recipes.md).",
|
|
2919
|
+
);
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2843
2922
|
/**
|
|
2844
2923
|
* `icp` — develop and inspect the Ideal Customer Profile that targets acquire.
|
|
2845
2924
|
* The CLI can't run AskUserQuestion itself; `icp interview` emits the question
|
|
@@ -2929,12 +3008,20 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
|
|
|
2929
3008
|
const unjudged = signalRun.signals.filter((s) => !s.judgedBy);
|
|
2930
3009
|
if (unjudged.length === 0) throw new Error(`Signal run "${signalRun.runLabel}" has no unjudged signals.`);
|
|
2931
3010
|
|
|
2932
|
-
// 2) Optional inputs: ICP (may be undefined), snapshot
|
|
2933
|
-
//
|
|
3011
|
+
// 2) Optional inputs: ICP (may be undefined), snapshot, outcomes + config.
|
|
3012
|
+
// The snapshot is loaded whenever a source is given (--provider/--input/
|
|
3013
|
+
// --demo/--sample) — it resolves each decision's CRM target (accountId +
|
|
3014
|
+
// best contact) so `draft` can write against a real record. --with-history
|
|
3015
|
+
// additionally enables the memory + fit scoring inputs.
|
|
2934
3016
|
const icp = loadIcp(rest);
|
|
2935
3017
|
const config: SignalsConfig = DEFAULT_SIGNALS_CONFIG;
|
|
2936
3018
|
const outcomes = await signalStore.listOutcomes();
|
|
2937
|
-
const
|
|
3019
|
+
const hasSnapshotSource =
|
|
3020
|
+
Boolean(option(rest, "--provider")) ||
|
|
3021
|
+
Boolean(option(rest, "--input")) ||
|
|
3022
|
+
rest.includes("--demo") ||
|
|
3023
|
+
rest.includes("--sample");
|
|
3024
|
+
const snapshot = withHistory || hasSnapshotSource ? await readSnapshot(rest) : undefined;
|
|
2938
3025
|
|
|
2939
3026
|
// 3) Resolve the LLM seam ONLY if a key already exists (deterministic baseline
|
|
2940
3027
|
// otherwise — never PROMPT here; judge must run key-free).
|
|
@@ -5206,6 +5293,10 @@ export async function runCli(argv: string[]) {
|
|
|
5206
5293
|
await enrichCommand(args);
|
|
5207
5294
|
return;
|
|
5208
5295
|
}
|
|
5296
|
+
if (command === "init") {
|
|
5297
|
+
initCommand(args);
|
|
5298
|
+
return;
|
|
5299
|
+
}
|
|
5209
5300
|
if (command === "icp") {
|
|
5210
5301
|
await icpCommand(args);
|
|
5211
5302
|
return;
|
|
@@ -506,18 +506,34 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
506
506
|
};
|
|
507
507
|
}
|
|
508
508
|
|
|
509
|
-
/** Exact-
|
|
510
|
-
async function
|
|
509
|
+
/** Exact-value company lookup (by `name` or `domain`) for resolve-first creates. */
|
|
510
|
+
async function searchCompaniesBy(property: string, value: string): Promise<string[]> {
|
|
511
511
|
const data = await request(`/crm/v3/objects/companies/search`, {
|
|
512
512
|
method: "POST",
|
|
513
513
|
body: JSON.stringify({
|
|
514
|
-
filterGroups: [{ filters: [{ propertyName:
|
|
515
|
-
properties: [
|
|
514
|
+
filterGroups: [{ filters: [{ propertyName: property, operator: "EQ", value }] }],
|
|
515
|
+
properties: [property],
|
|
516
516
|
limit: 3,
|
|
517
517
|
}),
|
|
518
518
|
});
|
|
519
519
|
return ((data?.results ?? []) as Array<{ id: string }>).map((row) => String(row.id));
|
|
520
520
|
}
|
|
521
|
+
const searchCompaniesByName = (name: string) => searchCompaniesBy("name", name);
|
|
522
|
+
|
|
523
|
+
/** Stamp `domain` on a company only when it has none (fill-blank, never clobber). */
|
|
524
|
+
async function fillCompanyDomainIfEmpty(companyId: string, domain: string): Promise<void> {
|
|
525
|
+
try {
|
|
526
|
+
const data = await request(`/crm/v3/objects/companies/${encodeURIComponent(companyId)}?properties=domain`);
|
|
527
|
+
const current = data?.properties?.domain;
|
|
528
|
+
if (typeof current === "string" && current.trim()) return; // already has one — leave it
|
|
529
|
+
await request(`/crm/v3/objects/companies/${encodeURIComponent(companyId)}`, {
|
|
530
|
+
method: "PATCH",
|
|
531
|
+
body: JSON.stringify({ properties: { domain } }),
|
|
532
|
+
});
|
|
533
|
+
} catch {
|
|
534
|
+
// Best-effort: a failed domain fill must not sink the contact create.
|
|
535
|
+
}
|
|
536
|
+
}
|
|
521
537
|
|
|
522
538
|
/** Exact-value contact lookup (by any searchable property) for resolve-first creates. */
|
|
523
539
|
async function searchContactsBy(property: string, value: string): Promise<string[]> {
|
|
@@ -533,36 +549,51 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
533
549
|
}
|
|
534
550
|
|
|
535
551
|
/**
|
|
536
|
-
* Resolve a company
|
|
537
|
-
*
|
|
538
|
-
*
|
|
552
|
+
* Resolve a company to a real account record, creating it on a confirmed miss.
|
|
553
|
+
* Matches by DOMAIN first (the accurate key) and falls back to exact name;
|
|
554
|
+
* creates with the domain stamped, and fills the domain on a name-matched
|
|
555
|
+
* account that lacks one — so the account is signal-watchable. Returns null on
|
|
556
|
+
* ambiguity (≥2 matches) so the caller skips the association rather than
|
|
557
|
+
* guessing. Same-run safe via createdCompaniesByName.
|
|
539
558
|
*/
|
|
540
|
-
async function
|
|
541
|
-
const
|
|
542
|
-
const already = createdCompaniesByName.get(
|
|
559
|
+
async function resolveOrCreateCompany(name: string, domain: string | undefined, opId: string): Promise<string | null> {
|
|
560
|
+
const cacheKey = domain ? `d:${domain.toLowerCase()}` : `n:${name.toLowerCase()}`;
|
|
561
|
+
const already = createdCompaniesByName.get(cacheKey);
|
|
543
562
|
if (already) return already;
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
if (
|
|
547
|
-
|
|
548
|
-
return
|
|
563
|
+
|
|
564
|
+
// 1. Domain-first match (accurate; an account matched here already has it).
|
|
565
|
+
if (domain) {
|
|
566
|
+
const byDomain = await searchCompaniesBy("domain", domain);
|
|
567
|
+
if (byDomain.length > 1) return null;
|
|
568
|
+
if (byDomain.length === 1) {
|
|
569
|
+
createdCompaniesByName.set(cacheKey, byDomain[0]);
|
|
570
|
+
return byDomain[0];
|
|
571
|
+
}
|
|
549
572
|
}
|
|
573
|
+
// 2. Exact-name match; stamp the domain if the account has none.
|
|
574
|
+
const byName = await searchCompaniesByName(name);
|
|
575
|
+
if (byName.length > 1) return null;
|
|
576
|
+
if (byName.length === 1) {
|
|
577
|
+
if (domain) await fillCompanyDomainIfEmpty(byName[0], domain);
|
|
578
|
+
createdCompaniesByName.set(cacheKey, byName[0]);
|
|
579
|
+
return byName[0];
|
|
580
|
+
}
|
|
581
|
+
// 3. Create with name + domain (provenance is best-effort).
|
|
582
|
+
const props: Record<string, string> = { name, ...(domain ? { domain } : {}) };
|
|
550
583
|
let created;
|
|
551
584
|
try {
|
|
552
585
|
created = await request(`/crm/v3/objects/companies`, {
|
|
553
586
|
method: "POST",
|
|
554
|
-
body: JSON.stringify({
|
|
555
|
-
properties: { name, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` },
|
|
556
|
-
}),
|
|
587
|
+
body: JSON.stringify({ properties: { ...props, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` } }),
|
|
557
588
|
});
|
|
558
589
|
} catch {
|
|
559
590
|
created = await request(`/crm/v3/objects/companies`, {
|
|
560
591
|
method: "POST",
|
|
561
|
-
body: JSON.stringify({ properties:
|
|
592
|
+
body: JSON.stringify({ properties: props }),
|
|
562
593
|
});
|
|
563
594
|
}
|
|
564
595
|
const id = String(created.id);
|
|
565
|
-
createdCompaniesByName.set(
|
|
596
|
+
createdCompaniesByName.set(cacheKey, id);
|
|
566
597
|
return id;
|
|
567
598
|
}
|
|
568
599
|
|
|
@@ -588,7 +619,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
588
619
|
}
|
|
589
620
|
|
|
590
621
|
if (operation.objectType === "account") {
|
|
591
|
-
const id = await
|
|
622
|
+
const id = await resolveOrCreateCompany(matchValue, stringOrUndefined(payload.properties?.domain), operation.id);
|
|
592
623
|
if (id === null) {
|
|
593
624
|
return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — multiple companies named "${matchValue}". Not creating.` };
|
|
594
625
|
}
|
|
@@ -637,13 +668,14 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
637
668
|
let companyNote = "";
|
|
638
669
|
if (payload.associateCompanyName) {
|
|
639
670
|
try {
|
|
640
|
-
const companyId = await
|
|
671
|
+
const companyId = await resolveOrCreateCompany(payload.associateCompanyName, payload.associateCompanyDomain, operation.id);
|
|
641
672
|
if (companyId) {
|
|
642
673
|
await request(
|
|
643
674
|
`/crm/v4/objects/contacts/${encodeURIComponent(contactId)}/associations/default/companies/${encodeURIComponent(companyId)}`,
|
|
644
675
|
{ method: "PUT" },
|
|
645
676
|
);
|
|
646
|
-
|
|
677
|
+
const domainNote = payload.associateCompanyDomain ? ` (${payload.associateCompanyDomain})` : "";
|
|
678
|
+
companyNote = ` Linked to company "${payload.associateCompanyName}"${domainNote} (${companyId}).`;
|
|
647
679
|
} else {
|
|
648
680
|
companyNote = ` Company "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
|
|
649
681
|
}
|