fullstackgtm 0.34.0 → 0.38.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 +141 -0
- package/INSTALL_FOR_AGENTS.md +8 -0
- package/README.md +39 -0
- package/dist/acquireLinkedIn.d.ts +41 -0
- package/dist/acquireLinkedIn.js +57 -0
- package/dist/acquireMeter.d.ts +67 -0
- package/dist/acquireMeter.js +145 -0
- package/dist/acquireSeen.d.ts +5 -0
- package/dist/acquireSeen.js +54 -0
- package/dist/assign.d.ts +83 -0
- package/dist/assign.js +146 -0
- package/dist/bin.js +14 -2
- package/dist/cli.js +817 -26
- package/dist/connectors/hubspot.js +140 -0
- package/dist/connectors/linkedin.d.ts +78 -0
- package/dist/connectors/linkedin.js +199 -0
- package/dist/connectors/prospectSources.d.ts +91 -0
- package/dist/connectors/prospectSources.js +227 -0
- package/dist/enrich.d.ts +107 -0
- package/dist/enrich.js +315 -5
- package/dist/format.d.ts +3 -1
- package/dist/format.js +14 -2
- package/dist/health.d.ts +71 -0
- package/dist/health.js +172 -0
- package/dist/icp.d.ts +96 -0
- package/dist/icp.js +256 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mappings.js +3 -0
- package/dist/reassign.d.ts +11 -2
- package/dist/reassign.js +13 -6
- package/dist/runReport.d.ts +9 -0
- package/dist/runReport.js +66 -0
- package/dist/types.d.ts +25 -1
- package/docs/api.md +53 -3
- package/docs/architecture.md +11 -1
- package/docs/dx-punch-list.md +87 -0
- package/docs/linkedin-connector-spec.md +87 -0
- package/llms.txt +38 -1
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +5 -3
- package/src/acquireLinkedIn.ts +83 -0
- package/src/acquireMeter.ts +186 -0
- package/src/acquireSeen.ts +57 -0
- package/src/assign.ts +193 -0
- package/src/bin.ts +17 -4
- package/src/cli.ts +965 -25
- package/src/connectors/hubspot.ts +145 -0
- package/src/connectors/linkedin.ts +272 -0
- package/src/connectors/prospectSources.ts +324 -0
- package/src/enrich.ts +411 -5
- package/src/format.ts +20 -2
- package/src/health.ts +238 -0
- package/src/icp.ts +313 -0
- package/src/index.ts +18 -0
- package/src/mappings.ts +3 -0
- package/src/reassign.ts +24 -8
- package/src/runReport.ts +76 -0
- package/src/types.ts +32 -0
package/docs/architecture.md
CHANGED
|
@@ -72,7 +72,17 @@ provider ──fetchSnapshot()──► CanonicalGtmSnapshot
|
|
|
72
72
|
- `market*.ts` (`market.ts`, `marketClassify.ts`, `marketAxes.ts`,
|
|
73
73
|
`marketOverlay.ts`, `marketScale.ts`, `marketReport.ts`) — the competitive
|
|
74
74
|
market-map layer; classifications are verbatim-verified against captures.
|
|
75
|
-
- `enrich.ts` + `enrichApollo.ts` — third-party data enrichment (fill-blanks)
|
|
75
|
+
- `enrich.ts` + `enrichApollo.ts` — third-party data enrichment (fill-blanks),
|
|
76
|
+
plus `buildAcquirePlan` / `builtinAcquirePreset` for net-new lead generation.
|
|
77
|
+
- `icp.ts` — the Ideal Customer Profile artifact: per-provider discovery-filter
|
|
78
|
+
translation, prospect fit scoring, and the agent-driven interview spec.
|
|
79
|
+
- `acquireMeter.ts` — per-profile windowed budget (records + spend) for acquire.
|
|
80
|
+
- `acquireSeen.ts` — cross-run "seen" cache so re-runs don't re-pay for dupes.
|
|
81
|
+
- `assign.ts` — `AssignmentPolicy` (fixed / round-robin / territory /
|
|
82
|
+
account-owner): the pure owner-routing rule `buildAcquirePlan` stamps onto new
|
|
83
|
+
leads (never born ownerless) and `reassign --assign-unowned` reuses to backfill.
|
|
84
|
+
- `connectors/prospectSources.ts` — API prospect sources (Explorium discovery,
|
|
85
|
+
pipe0 work-email waterfall, pipe0/Crustdata search) + the pre-email dedup keys.
|
|
76
86
|
|
|
77
87
|
**Surfaces & infra**
|
|
78
88
|
- `cli.ts` — one async function per command, flat dispatch in `runCli`;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# DX / adoption punch list
|
|
2
|
+
|
|
3
|
+
Audit date: 2026-06-19. Method: ran the CLI as a new adopter would (bare
|
|
4
|
+
invoke → `doctor` → `audit --demo` → per-command `--help`) and measured the
|
|
5
|
+
friction, rather than reading the source. Companion to the security/strategic
|
|
6
|
+
findings that drove the 0.25.1–0.28.0 hardening releases — same format, same
|
|
7
|
+
intent: a tracked list that drives a release.
|
|
8
|
+
|
|
9
|
+
## Framing
|
|
10
|
+
|
|
11
|
+
The **agent path** (SKILL.md verb map, MCP tools, `--json` everywhere,
|
|
12
|
+
gate-shaped exit codes 0/1/2) is cohesive and adopts cleanly. The **human
|
|
13
|
+
path** is the weak side — and the human is a primary user (the consultant-CLI,
|
|
14
|
+
dogfood-on-client-work direction). Every fix below either *deepens an existing
|
|
15
|
+
verb* or *connects two existing verbs*; none adds a new top-level noun. The
|
|
16
|
+
adoption work **is** the "deeper not broader" work.
|
|
17
|
+
|
|
18
|
+
`doctor` (ends with "Next step") and `fix` (chains audit→suggest→approve→apply)
|
|
19
|
+
already do the right thing. Cohesion here = make every verb behave like
|
|
20
|
+
`doctor` and `fix` already do.
|
|
21
|
+
|
|
22
|
+
## Findings (evidence-backed)
|
|
23
|
+
|
|
24
|
+
| # | Finding | Evidence | Impact |
|
|
25
|
+
| --- | --- | --- | --- |
|
|
26
|
+
| F1 | **Front door is a firehose** | bare invoke = 194 lines; 22 verbs / 87 distinct flags; `cli.ts` is 3,615 lines | New user can't find the ~6 things they actually do |
|
|
27
|
+
| F2 | **Credential-free entry point dead-ends** | `audit --demo` (the README's "try it first") dumps **1,467 lines** to stdout and ends on a blank line — no "next step," unlike `doctor` | The single most-run first command strands you |
|
|
28
|
+
| F3 | **No per-command help** | `dedupe --help`, `audit --help` fall through to the same 194-line global wall | A stuck user gets the firehose, not the answer |
|
|
29
|
+
| F4 | **No altitude control on reads** | `audit` has only `--json` / `--out` / `--format`; no `--summary` / `--top` / `--quiet`. Human mode *is* the 1,467-line dump | Default human output is unreadable yet not machine-parseable |
|
|
30
|
+
| F5 | **Tone undercuts positioning** | `audit --demo` footer: "This prototype is dry-run only" | Reads as toy vs. the beta/production-safety-invariant framing |
|
|
31
|
+
| F6 | **No guided mode** | `readline` is used only for the login secret prompt | Every workflow is hand-assembled from flags; nothing scaffolds the loop |
|
|
32
|
+
|
|
33
|
+
## Fix sequence (highest leverage first)
|
|
34
|
+
|
|
35
|
+
### 1. Progressive-disclosure help — IN PROGRESS
|
|
36
|
+
Fixes **F1, F3**. The full `usage()` becomes the explicit full reference
|
|
37
|
+
(`fullstackgtm help --full`). Add:
|
|
38
|
+
- `shortUsage()` — a ~30-line map of the verbs grouped by lifecycle phase
|
|
39
|
+
(Setup · Detect · Prevent · Remediate · Calls · Govern · Market · Schedule),
|
|
40
|
+
one line each, ending with `<command> --help` and `help --full` pointers.
|
|
41
|
+
Becomes the default for bare invoke and `--help`.
|
|
42
|
+
- `commandHelp(command)` — focused per-verb help (summary, synopsis, where it
|
|
43
|
+
sits in the loop, key options, see-also). `<verb> --help` zooms in instead
|
|
44
|
+
of dumping the surface. `call`/`market`/`enrich`/`bulk-update`/`schedule`
|
|
45
|
+
keep their existing bespoke help.
|
|
46
|
+
- `help [command] [--full]` command.
|
|
47
|
+
|
|
48
|
+
### 2. Every verb hands you the next step — DONE (audit keystone)
|
|
49
|
+
Fixes **F2**. `auditNextStep()` gives `audit` context-aware forward guidance on
|
|
50
|
+
stderr (stdout stays clean for pipes/`--out`; suppressed under `--json`):
|
|
51
|
+
- **demo/sample** → "Client-ready writeup: `report --demo`" + "Run on your CRM:
|
|
52
|
+
`login hubspot` → `audit --provider hubspot --save`"
|
|
53
|
+
- **live + `--save`** → the governed spine: `suggest` → `plans approve
|
|
54
|
+
--values-from` → `apply`, with the real plan id
|
|
55
|
+
- **0 findings** → "clean — gate new records with `resolve`, schedule a check"
|
|
56
|
+
|
|
57
|
+
The lifecycle spine made *experiential* — each command knows its phase and
|
|
58
|
+
points forward (`doctor`/`fix` were the templates). `audit` is the keystone
|
|
59
|
+
(the documented dead-end); the helper pattern now generalizes to the other read
|
|
60
|
+
verbs (`report`, `resolve`, `dedupe` previews) in a follow-up.
|
|
61
|
+
|
|
62
|
+
### 3. Altitude control on the firehose — DONE
|
|
63
|
+
Fixes **F4**. `patchPlanToMarkdown(plan, { summary })` gains a summary view
|
|
64
|
+
(header + Findings-by-Rule table + operation count + pointers to `--full` and
|
|
65
|
+
`report`). `audit` defaults to it — **1,467 → 24 lines** on the demo — with
|
|
66
|
+
`--full` to opt back into the per-operation dump. The default stays *full* for
|
|
67
|
+
every other caller (bulk-update, fix, dedupe, plans show, MCP): write-preview
|
|
68
|
+
verbs keep full operation detail because you approve specific ops there. The
|
|
69
|
+
principled split — audit = "what's wrong" (summary → `report`); write verbs =
|
|
70
|
+
"exactly what I'll change" (full) — is the real win.
|
|
71
|
+
|
|
72
|
+
### 4. Tone pass — DONE
|
|
73
|
+
Fixes **F5**. The "This prototype is dry-run only" footer is replaced
|
|
74
|
+
everywhere with safety-invariant framing matching README/SKILL: "Dry-run plan —
|
|
75
|
+
read-only. No provider write happens until you approve specific operations and
|
|
76
|
+
run `apply`; placeholder values are refused without an explicit override. These
|
|
77
|
+
safety invariants are not beta." No "prototype" string remains in user output.
|
|
78
|
+
|
|
79
|
+
## Deferred (bigger bets, gated on 1–4 landing)
|
|
80
|
+
- **Engagement workspace**: per-client `.fullstackgtm/` state that accumulates
|
|
81
|
+
snapshots, plans, evidence, and a health-over-time score — turns episodic
|
|
82
|
+
audits into a continuously-operated system.
|
|
83
|
+
- **Journey commands**: 2–3 opinionated composites (onboard-a-client,
|
|
84
|
+
health-rollup) sequencing existing verbs into revops jobs.
|
|
85
|
+
|
|
86
|
+
There is no point building the engagement workspace while the front door dumps
|
|
87
|
+
1,467 lines. Land the DX fixes first.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# `linkedin` connector — spec (Phase 1)
|
|
2
|
+
|
|
3
|
+
Status: scoped 2026-06-20. Decisions locked: **first slice = Phase 1 discovery,
|
|
4
|
+
dry-run; default provider = HeyReach.** Rationale + the full white-label
|
|
5
|
+
investigation that motivated owning this: see the LinkedIn connector research.
|
|
6
|
+
|
|
7
|
+
## Why this shape
|
|
8
|
+
|
|
9
|
+
The research verdict (≈85%): Gojiberry runs its **own** LinkedIn session-automation
|
|
10
|
+
stack — it is **not** white-labeling HeyReach/Unipile, so there is no upstream to
|
|
11
|
+
go "direct" to. But Gojiberry proves a small team can build the architecture, and
|
|
12
|
+
the *execution* layer is rentable. So FSGTM's play is: **rent execution
|
|
13
|
+
(HeyReach), own the targeting + scoring + plan/apply governance** — "governed
|
|
14
|
+
LinkedIn outbound," which nobody else has.
|
|
15
|
+
|
|
16
|
+
This does **not** rebuild anything. The in-flight `enrich acquire` pipeline
|
|
17
|
+
already is the spine:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
Icp → discovery source (Explorium/pipe0/Clay) → scoreProspectAgainstIcp
|
|
21
|
+
→ partitionFreshProspects (dedup vs CRM + acquireSeen) → create_record op
|
|
22
|
+
→ acquireMeter budget cap → approve → apply
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
LinkedIn is just a **new discovery source** on that spine.
|
|
26
|
+
|
|
27
|
+
## Phase 1 deliverable: `enrich acquire --source linkedin` (dry-run)
|
|
28
|
+
|
|
29
|
+
ICP → LinkedIn people-search via HeyReach → `Prospect[]` → ICP-scored → deduped →
|
|
30
|
+
**dry-run `create_record` plan** (no `--save` ⇒ nothing written, zero send/ToS
|
|
31
|
+
exposure). Net-new code is small; scoring/dedup/metering/apply are reused.
|
|
32
|
+
|
|
33
|
+
### Components
|
|
34
|
+
- **`src/connectors/linkedin.ts`** (mirrors `prospectSources.ts`):
|
|
35
|
+
- `icpToLinkedInFilters(icp)` — `Icp` → LinkedIn/Sales-Nav search params (mirror
|
|
36
|
+
of `icpToExploriumFilters` / `icpToCrustdataFilters` in `icp.ts`).
|
|
37
|
+
- `discoverProspects(provider, filters, max)` — search mode for Phase 1; returns
|
|
38
|
+
the existing `Prospect` type so everything downstream is free.
|
|
39
|
+
- **`LinkedInProvider` interface** (injectable, like `CrontabIo`): default
|
|
40
|
+
**HeyReach** adapter (`searchLeads` / lead-list pull), plus a **`FakeLinkedInProvider`**
|
|
41
|
+
for tests so the whole pipeline runs with no key and no network.
|
|
42
|
+
- **`builtinAcquirePreset('linkedin')`** — zero-config source preset (match-key
|
|
43
|
+
email/linkedinUrl; cost-per-record for the meter).
|
|
44
|
+
- **Wiring**: register `linkedin` in the `enrich acquire` source switch; add to
|
|
45
|
+
the source allowlist + help; `login heyreach` (or `HEYREACH_API_KEY`) for the key.
|
|
46
|
+
|
|
47
|
+
### Reused as-is (no change)
|
|
48
|
+
`scoreProspectAgainstIcp`, `partitionFreshProspects`, `acquireSeen`,
|
|
49
|
+
`acquireMeter` (records/day+month, cost/record), the `create_record` op + apply
|
|
50
|
+
path, the dry-run→approve→apply gate.
|
|
51
|
+
|
|
52
|
+
## Out of scope for Phase 1 (explicit)
|
|
53
|
+
- **No sending.** `linkedin_connect` / `linkedin_message` operations, the
|
|
54
|
+
per-sender safety meter, and HeyReach webhook ingestion are **Phase 2**.
|
|
55
|
+
- Engagement-signal capture (Gojiberry-style intent from who-engaged-our-posts)
|
|
56
|
+
is a Phase 1.5 add to `discoverProspects` once search mode is trusted.
|
|
57
|
+
- Not our own automation engine; not the official LinkedIn API; not multi-channel.
|
|
58
|
+
|
|
59
|
+
## Provider: HeyReach
|
|
60
|
+
Behind `LinkedInProvider` so we are not locked in (Unipile = documented
|
|
61
|
+
alternative: cheaper raw search/profile, multi-channel). HeyReach chosen for the
|
|
62
|
+
full Campaign API + webhooks + sender rotation + built-in per-account caps (the
|
|
63
|
+
Phase 2 ban-safety primitives we will want). ToS/ban risk sits on the connected
|
|
64
|
+
LinkedIn account — same posture as Gojiberry today.
|
|
65
|
+
|
|
66
|
+
## Build sequencing (IMPORTANT)
|
|
67
|
+
The acquire spine (`icp.ts`, `prospectSources.ts`, `acquireMeter.ts`,
|
|
68
|
+
`acquireSeen.ts`) is **uncommitted, actively-edited working-tree code** (not on
|
|
69
|
+
main). Building the acquire-source wiring on top of it is expansion on an
|
|
70
|
+
unstable base. Therefore:
|
|
71
|
+
|
|
72
|
+
1. **Buildable now (self-contained, no dep on the uncommitted acquire internals):**
|
|
73
|
+
`LinkedInProvider` interface + HeyReach adapter (coded to HeyReach's documented
|
|
74
|
+
API) + `FakeLinkedInProvider` + `icpToLinkedInFilters` + unit tests against the
|
|
75
|
+
fake.
|
|
76
|
+
2. **After the acquire workstream commits/lands:** wire `enrich acquire --source
|
|
77
|
+
linkedin` end-to-end and add the dry-run integration test.
|
|
78
|
+
3. **Live validation** needs a HeyReach trial API key (Ryan provides) — the fake
|
|
79
|
+
covers everything up to the real network call.
|
|
80
|
+
|
|
81
|
+
## Phase 2 (future, recorded for continuity)
|
|
82
|
+
Governed outbound: `linkedin_connect`/`linkedin_message` as `PatchOperation`s
|
|
83
|
+
(objectId = contact, afterValue = body, approvalRequired, `groupId` = sequence
|
|
84
|
+
all-or-nothing, `preconditions` = not-already-connected); `applyOperation`
|
|
85
|
+
dispatches `linkedin_*` to the provider; per-sender-account daily caps via the
|
|
86
|
+
`acquireMeter` pattern (ban-safety encoded as governance); HeyReach webhooks →
|
|
87
|
+
CRM activity + intent signal. This is the differentiated surface.
|
package/llms.txt
CHANGED
|
@@ -76,7 +76,10 @@ identity key — merge with `dedupe` instead. `dedupe <object> --key
|
|
|
76
76
|
deterministic survivor (`--keep richest|oldest`); merges are irreversible and
|
|
77
77
|
stay low-confidence-capped at approval. `reassign --from <owner> --to
|
|
78
78
|
<owner>` = ownership handoff plans per object type; `--except-deal-stage`
|
|
79
|
-
also excludes records whose account has an open deal in that stage
|
|
79
|
+
also excludes records whose account has an open deal in that stage;
|
|
80
|
+
`reassign --assign-unowned --to <id>` instead claims every ownerless record
|
|
81
|
+
(`ownerId:empty`) for an owner — the backfill twin of acquire's create-time
|
|
82
|
+
assignment. `fix
|
|
80
83
|
--rule <id>` = audit one rule → suggest → approve at the confidence bar →
|
|
81
84
|
apply only with `--yes`. All four produce plans; none writes outside
|
|
82
85
|
approve → apply.
|
|
@@ -95,6 +98,24 @@ where the source value changed (beforeValue = current CRM value → apply-time
|
|
|
95
98
|
CAS). Conflict policy MVP is `never`; `system-only`/`always` are phase 2 and
|
|
96
99
|
refused explicitly. Run store (checkpoint + staleness ledger + `status`) is
|
|
97
100
|
profile-scoped under `<home>/enrich/runs`. No cron — scheduling is horizontal.
|
|
101
|
+
`acquire` is the one enrich verb that creates net-new records instead of
|
|
102
|
+
filling blanks: it discovers ICP-targeted people, resolves work email, and
|
|
103
|
+
proposes governed `create_record` ops through the same dry-run → approve →
|
|
104
|
+
apply gate — resolve-first dedupe re-checks the key at apply time so it never
|
|
105
|
+
double-creates. A per-profile windowed meter (`acquireMeter.ts`) caps both
|
|
106
|
+
record count and provider spend (per-day + per-month, whichever binds first);
|
|
107
|
+
sources come from staged ingest lists. Acquired leads are never born ownerless:
|
|
108
|
+
an `acquire.assign` policy (`fixed`/`round-robin`/`territory`/`account-owner`,
|
|
109
|
+
or `--assign-owner <id>`) stamps an owner into every `create_record`, gated by
|
|
110
|
+
the snapshot's active owners (unknown/inactive owner → unassigned, never a bad
|
|
111
|
+
owner); with multiple owners and no policy it warns and leaves leads unassigned.
|
|
112
|
+
Discovery sources are presets behind `--source`: `explorium`/`pipe0` (ICP-driven
|
|
113
|
+
people search) and `linkedin` (Phase 1, discovery + dry-run) which reads a
|
|
114
|
+
pre-built HeyReach lead list (`--list <id>` or `acquire.discovery.linkedin.listId`,
|
|
115
|
+
match key = LinkedIn URL, $0/record, `login heyreach`/`HEYREACH_API_KEY`) via the
|
|
116
|
+
injectable `LinkedInProvider` (default HeyReach; `FakeLinkedInProvider` for tests).
|
|
117
|
+
LinkedIn is read-only — Phase 1 has no `linkedin_connect`/`linkedin_message` ops
|
|
118
|
+
and no webhook ingestion (Phase 2). Never auto-writes.
|
|
98
119
|
|
|
99
120
|
## Key invariants (schedule)
|
|
100
121
|
|
|
@@ -115,6 +136,22 @@ artifacts: plan ids / enrich run labels, trigger cron|manual) land under
|
|
|
115
136
|
firings (visibility only — local cron has no catch-up). Providers beyond
|
|
116
137
|
`local` are not yet implemented (future: codegen scaffolds, same contract).
|
|
117
138
|
|
|
139
|
+
## Key invariants (engagement workspace / health)
|
|
140
|
+
|
|
141
|
+
`fullstackgtm health` rolls up a per-profile CRM-health timeline that accrues
|
|
142
|
+
from the verb teams already run: every `audit --save` stamps a deterministic
|
|
143
|
+
0–100 hygiene score plus a snapshot onto the active profile. The score is
|
|
144
|
+
`100 / (1 + severity-weighted findings per record)` (info ×1, warning ×3,
|
|
145
|
+
critical ×10) — no LLM, so it is byte-stable in CI and comparable run-over-run;
|
|
146
|
+
0 findings ⇒ 100, and the same findings over fewer clean records score lower.
|
|
147
|
+
State is profile-scoped and owner-only (0600) under the same secured home as
|
|
148
|
+
plans: `health.jsonl` (append-only, one entry per saved audit) and
|
|
149
|
+
`snapshots/<planId>.json`. `health` is READ-ONLY — it only reads the timeline,
|
|
150
|
+
never re-audits or writes; `--json` emits the rollup (current score, delta since
|
|
151
|
+
the last audit, dated trend, per-rule deltas). Scope per client with
|
|
152
|
+
`--profile <name>`; an empty timeline returns a pointer, never an error.
|
|
153
|
+
Library: `computeHealth` / `summarizeHealth` / `healthToMarkdown`.
|
|
154
|
+
|
|
118
155
|
## Key invariants
|
|
119
156
|
|
|
120
157
|
- Reads are safe by default; nothing is written without explicit `--approve`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.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)",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: fullstackgtm
|
|
3
|
-
description: Govern CRM/GTM data operations through the fullstackgtm CLI — read-only hygiene audits, reviewable dry-run patch plans, deterministic value suggestions, and approval-gated write-back to HubSpot and Salesforce. Use when asked to audit, clean, dedupe, enrich, bulk-update, reassign, or write to a CRM; to gate record creation against duplicates; to parse, score, or link sales call transcripts; to map a competitive category; or to schedule any of the above. Never write to a CRM directly when this skill is available.
|
|
3
|
+
description: Govern CRM/GTM data operations through the fullstackgtm CLI — read-only hygiene audits, reviewable dry-run patch plans, deterministic value suggestions, and approval-gated write-back to HubSpot and Salesforce. Use when asked to audit, clean, dedupe, enrich, bulk-update, reassign, or write to a CRM; to gate record creation against duplicates; to parse, score, or link sales call transcripts; to map a competitive category; to report a CRM's health and trend over time; or to schedule any of the above. Never write to a CRM directly when this skill is available.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# fullstackgtm — plan/apply for your GTM stack
|
|
@@ -58,13 +58,15 @@ credentials AND stored plans per client org.
|
|
|
58
58
|
| `resolve <contact\|account\|deal>` | Create gate: exit 0 = safe to create, 2 = exists/ambiguous — never blind-create |
|
|
59
59
|
| `dedupe <object> --key <domain\|email\|name>` | One merge op per duplicate group, deterministic survivor; merges are irreversible |
|
|
60
60
|
| `bulk-update <object> --where … --set\|--archive\|--create-task` | Filtered dry-run plan; filter re-verified per record at apply time |
|
|
61
|
-
| `reassign --from <owner> --to <owner>` | Ownership handoff plans per object type |
|
|
61
|
+
| `reassign --from <owner> --to <owner>` | Ownership handoff plans per object type; `--assign-unowned --to <id>` backfills every ownerless record |
|
|
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
66
|
| `market init\|capture\|classify\|worksheet\|observe\|fronts\|axes\|overlay\|scale\|report\|refresh` | Competitive category map; evidence quotes verified verbatim against stored captures |
|
|
66
67
|
| `schedule add\|list\|remove\|enable\|disable\|run\|install\|uninstall\|status` | Horizontal cron; read/plan-side allowlist only — scheduling NEVER auto-approves |
|
|
67
68
|
| `report` | Client-ready audit deliverable (markdown or self-contained HTML) |
|
|
69
|
+
| `health [--json]` | Per-profile CRM health timeline: deterministic 0–100 score, trend, per-rule deltas — accrues from `audit --save`, read-only |
|
|
68
70
|
| `plans list\|show\|approve\|reject` / `snapshot` / `rules` / `doctor` | Plan lifecycle, raw snapshots, rule registry, machine state |
|
|
69
71
|
|
|
70
72
|
All write-shaped verbs produce plans; none writes outside approve → apply.
|
|
@@ -84,6 +86,6 @@ Tools over stdio: `fullstackgtm_audit` (read-only), `fullstackgtm_rules`,
|
|
|
84
86
|
|
|
85
87
|
## Going deeper
|
|
86
88
|
|
|
87
|
-
- [llms.txt](https://github.com/fullstackgtm/core/blob/main/llms.txt) — the full invariant map per layer (calls, market, write verbs, enrich, schedule)
|
|
89
|
+
- [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)
|
|
88
90
|
- [INSTALL_FOR_AGENTS.md](https://github.com/fullstackgtm/core/blob/main/INSTALL_FOR_AGENTS.md) — deterministic install-and-verify with expected outputs
|
|
89
91
|
- [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
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn → acquire bridge (Phase 1, step 2).
|
|
3
|
+
*
|
|
4
|
+
* Connects the provider-agnostic LinkedIn discovery layer (connectors/linkedin.ts)
|
|
5
|
+
* to the `enrich acquire` spine: pull prospects from a LinkedIn source, map them
|
|
6
|
+
* to the canonical `Prospect`, and hand them to the existing ICP-score → dedup →
|
|
7
|
+
* create_record → meter → dry-run/apply pipeline unchanged.
|
|
8
|
+
*
|
|
9
|
+
* Kept out of cli.ts on purpose: the only remaining wiring is a ~3-line
|
|
10
|
+
* discovery branch in `acquireFromApi` (call `createLinkedInProvider` +
|
|
11
|
+
* `discoverLinkedInProspects`), a `builtinAcquirePreset('linkedin')` entry, the
|
|
12
|
+
* source allowlist/help, and a `login heyreach` credential. That lands once
|
|
13
|
+
* cli.ts is clean of other in-flight work — see docs/linkedin-connector-spec.md.
|
|
14
|
+
*
|
|
15
|
+
* HeyReach reads a pre-populated lead LIST (not an ICP-driven search), and the
|
|
16
|
+
* natural identity key is the LinkedIn profile URL — so acquire's matchKey is
|
|
17
|
+
* `linkedin`, which means the spine skips pipe0 email resolution automatically.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
createHeyReachProvider,
|
|
22
|
+
type HeyReachProviderOptions,
|
|
23
|
+
type LinkedInProspect,
|
|
24
|
+
type LinkedInProvider,
|
|
25
|
+
} from "./connectors/linkedin.ts";
|
|
26
|
+
import type { Prospect } from "./connectors/prospectSources.ts";
|
|
27
|
+
|
|
28
|
+
/** Map a normalized LinkedIn prospect into the canonical acquire `Prospect`. */
|
|
29
|
+
export function linkedInProspectToProspect(lp: LinkedInProspect): Prospect {
|
|
30
|
+
return {
|
|
31
|
+
firstName: lp.firstName || undefined,
|
|
32
|
+
lastName: lp.lastName || undefined,
|
|
33
|
+
fullName: lp.fullName,
|
|
34
|
+
// Title drives ICP scoring; fall back to the headline when no title is set.
|
|
35
|
+
jobTitle: lp.jobTitle ?? lp.headline,
|
|
36
|
+
companyName: lp.company,
|
|
37
|
+
linkedin: lp.profileUrl || undefined,
|
|
38
|
+
email: lp.email,
|
|
39
|
+
// The profile URL is LinkedIn's stable native id — use it for traceability.
|
|
40
|
+
sourceId: lp.profileUrl || undefined,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type DiscoverLinkedInOptions = {
|
|
45
|
+
/** Which source (HeyReach lead-list id) to read; provider may have a default. */
|
|
46
|
+
sourceId?: string;
|
|
47
|
+
/** Hard cap on prospects pulled. */
|
|
48
|
+
max?: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Discovery branch for `acquireFromApi`: read prospects from a LinkedIn source
|
|
53
|
+
* and return them as canonical `Prospect`s. Read-only — never sends. The
|
|
54
|
+
* provider is injected so this is fully testable against the fake (no key).
|
|
55
|
+
*/
|
|
56
|
+
export async function discoverLinkedInProspects(
|
|
57
|
+
provider: LinkedInProvider,
|
|
58
|
+
options: DiscoverLinkedInOptions = {},
|
|
59
|
+
): Promise<Prospect[]> {
|
|
60
|
+
const raw = await provider.fetchProspects({ sourceId: options.sourceId, max: options.max });
|
|
61
|
+
return raw.map(linkedInProspectToProspect);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type CreateLinkedInProviderOptions = Omit<HeyReachProviderOptions, "apiKey">;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the execution provider for a LinkedIn acquire source. HeyReach is the
|
|
68
|
+
* default; `unipile` is the planned alternative (same interface). Called by the
|
|
69
|
+
* cli acquire branch with the stored/env API key.
|
|
70
|
+
*/
|
|
71
|
+
export function createLinkedInProvider(
|
|
72
|
+
source: string,
|
|
73
|
+
apiKey: string,
|
|
74
|
+
options: CreateLinkedInProviderOptions = {},
|
|
75
|
+
): LinkedInProvider {
|
|
76
|
+
switch (source) {
|
|
77
|
+
case "linkedin":
|
|
78
|
+
case "heyreach":
|
|
79
|
+
return createHeyReachProvider({ apiKey, ...options });
|
|
80
|
+
default:
|
|
81
|
+
throw new Error(`Unknown LinkedIn execution provider "${source}" (supported: heyreach).`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The acquire meter — a persistent, windowed budget for net-new lead creation.
|
|
3
|
+
*
|
|
4
|
+
* `enrich acquire` proposes `create_record` operations; `apply` writes them.
|
|
5
|
+
* Both gate on this meter so a profile can never create more leads — or spend
|
|
6
|
+
* more on enrichment — than its declared budget, per day AND per month
|
|
7
|
+
* (whichever limit is hit first). The meter is the volume control; a human
|
|
8
|
+
* still approves the plan (the CLI never auto-writes). State is per-profile,
|
|
9
|
+
* stored under the credential home so one client's budget never bleeds into
|
|
10
|
+
* another's.
|
|
11
|
+
*
|
|
12
|
+
* Pure where it counts: window math and `remaining()` take an explicit `now`
|
|
13
|
+
* and operate on a passed-in state, so they are deterministic and testable.
|
|
14
|
+
*/
|
|
15
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { credentialsDir } from "./credentials.ts";
|
|
18
|
+
|
|
19
|
+
/** Budget declared in enrich.config.json under `acquire.budget`. */
|
|
20
|
+
export type AcquireBudget = {
|
|
21
|
+
records?: { perDay?: number; perMonth?: number };
|
|
22
|
+
spend?: { perDay?: number; perMonth?: number; currency?: string };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type AcquireMeterState = {
|
|
26
|
+
/** UTC day bucket, YYYY-MM-DD. */
|
|
27
|
+
day: string;
|
|
28
|
+
/** UTC month bucket, YYYY-MM. */
|
|
29
|
+
month: string;
|
|
30
|
+
records: { day: number; month: number };
|
|
31
|
+
spendUsd: { day: number; month: number };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type AcquireRemaining = {
|
|
35
|
+
/** Remaining headroom per window; null = no budget set for that dimension. */
|
|
36
|
+
records: { day: number | null; month: number | null };
|
|
37
|
+
spendUsd: { day: number | null; month: number | null };
|
|
38
|
+
/**
|
|
39
|
+
* The single number that matters: how many MORE records may be created right
|
|
40
|
+
* now, given every budget dimension and the per-record cost. null = unlimited
|
|
41
|
+
* (no budget constrains creation). 0 = budget exhausted.
|
|
42
|
+
*/
|
|
43
|
+
maxRecords: number | null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function dayKey(now: Date): string {
|
|
47
|
+
return now.toISOString().slice(0, 10);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function monthKey(now: Date): string {
|
|
51
|
+
return now.toISOString().slice(0, 7);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function acquireMeterPath(baseDir?: string): string {
|
|
55
|
+
return join(baseDir ?? credentialsDir(), "acquire", "meter.json");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function emptyState(now: Date): AcquireMeterState {
|
|
59
|
+
return {
|
|
60
|
+
day: dayKey(now),
|
|
61
|
+
month: monthKey(now),
|
|
62
|
+
records: { day: 0, month: 0 },
|
|
63
|
+
spendUsd: { day: 0, month: 0 },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Roll the window counters forward: a new UTC day zeroes the day buckets, a
|
|
69
|
+
* new UTC month zeroes the month buckets. Returns a fresh object; never
|
|
70
|
+
* mutates the input.
|
|
71
|
+
*/
|
|
72
|
+
export function rollWindows(state: AcquireMeterState, now: Date): AcquireMeterState {
|
|
73
|
+
const today = dayKey(now);
|
|
74
|
+
const thisMonth = monthKey(now);
|
|
75
|
+
return {
|
|
76
|
+
day: today,
|
|
77
|
+
month: thisMonth,
|
|
78
|
+
records: {
|
|
79
|
+
day: state.day === today ? state.records.day : 0,
|
|
80
|
+
month: state.month === thisMonth ? state.records.month : 0,
|
|
81
|
+
},
|
|
82
|
+
spendUsd: {
|
|
83
|
+
day: state.day === today ? state.spendUsd.day : 0,
|
|
84
|
+
month: state.month === thisMonth ? state.spendUsd.month : 0,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Load the meter from disk, rolling stale windows forward. Missing = empty. */
|
|
90
|
+
export function loadMeter(now: Date, baseDir?: string): AcquireMeterState {
|
|
91
|
+
let raw: string;
|
|
92
|
+
try {
|
|
93
|
+
raw = readFileSync(acquireMeterPath(baseDir), "utf8");
|
|
94
|
+
} catch {
|
|
95
|
+
return emptyState(now);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(raw) as Partial<AcquireMeterState>;
|
|
99
|
+
const state: AcquireMeterState = {
|
|
100
|
+
day: typeof parsed.day === "string" ? parsed.day : dayKey(now),
|
|
101
|
+
month: typeof parsed.month === "string" ? parsed.month : monthKey(now),
|
|
102
|
+
records: {
|
|
103
|
+
day: Number(parsed.records?.day) || 0,
|
|
104
|
+
month: Number(parsed.records?.month) || 0,
|
|
105
|
+
},
|
|
106
|
+
spendUsd: {
|
|
107
|
+
day: Number(parsed.spendUsd?.day) || 0,
|
|
108
|
+
month: Number(parsed.spendUsd?.month) || 0,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
return rollWindows(state, now);
|
|
112
|
+
} catch {
|
|
113
|
+
return emptyState(now);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function saveMeter(state: AcquireMeterState, baseDir?: string): void {
|
|
118
|
+
const path = acquireMeterPath(baseDir);
|
|
119
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
120
|
+
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function headroom(limit: number | undefined, used: number): number | null {
|
|
124
|
+
if (limit === undefined) return null;
|
|
125
|
+
return Math.max(0, limit - used);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Compute remaining headroom and the max additional records creatable now.
|
|
130
|
+
* `costPerRecord` (USD) converts the spend budget into a record ceiling; when
|
|
131
|
+
* it is 0 the spend budget cannot constrain record count.
|
|
132
|
+
*/
|
|
133
|
+
export function remaining(
|
|
134
|
+
state: AcquireMeterState,
|
|
135
|
+
budget: AcquireBudget | undefined,
|
|
136
|
+
costPerRecord: number,
|
|
137
|
+
now: Date,
|
|
138
|
+
): AcquireRemaining {
|
|
139
|
+
const rolled = rollWindows(state, now);
|
|
140
|
+
const records = {
|
|
141
|
+
day: headroom(budget?.records?.perDay, rolled.records.day),
|
|
142
|
+
month: headroom(budget?.records?.perMonth, rolled.records.month),
|
|
143
|
+
};
|
|
144
|
+
const spendUsd = {
|
|
145
|
+
day: headroom(budget?.spend?.perDay, rolled.spendUsd.day),
|
|
146
|
+
month: headroom(budget?.spend?.perMonth, rolled.spendUsd.month),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const ceilings: number[] = [];
|
|
150
|
+
if (records.day !== null) ceilings.push(records.day);
|
|
151
|
+
if (records.month !== null) ceilings.push(records.month);
|
|
152
|
+
if (costPerRecord > 0) {
|
|
153
|
+
if (spendUsd.day !== null) ceilings.push(Math.floor(spendUsd.day / costPerRecord));
|
|
154
|
+
if (spendUsd.month !== null) ceilings.push(Math.floor(spendUsd.month / costPerRecord));
|
|
155
|
+
}
|
|
156
|
+
const maxRecords = ceilings.length === 0 ? null : Math.max(0, Math.min(...ceilings));
|
|
157
|
+
return { records, spendUsd, maxRecords };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Record a successful batch of creates against the budget and persist.
|
|
162
|
+
* Returns the updated state. Call AFTER the writes land, so a failed apply
|
|
163
|
+
* never charges the meter.
|
|
164
|
+
*/
|
|
165
|
+
export function recordConsumption(
|
|
166
|
+
now: Date,
|
|
167
|
+
recordsCreated: number,
|
|
168
|
+
spendUsd: number,
|
|
169
|
+
baseDir?: string,
|
|
170
|
+
): AcquireMeterState {
|
|
171
|
+
const state = loadMeter(now, baseDir);
|
|
172
|
+
const next: AcquireMeterState = {
|
|
173
|
+
day: state.day,
|
|
174
|
+
month: state.month,
|
|
175
|
+
records: {
|
|
176
|
+
day: state.records.day + recordsCreated,
|
|
177
|
+
month: state.records.month + recordsCreated,
|
|
178
|
+
},
|
|
179
|
+
spendUsd: {
|
|
180
|
+
day: state.spendUsd.day + spendUsd,
|
|
181
|
+
month: state.spendUsd.month + spendUsd,
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
saveMeter(next, baseDir);
|
|
185
|
+
return next;
|
|
186
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The acquire "seen" cache — cross-run memory of prospects already processed,
|
|
3
|
+
* so re-running the same ICP doesn't re-pay the (expensive) email-resolution
|
|
4
|
+
* step for people we resolved last time. Keyed by stable prospect identity
|
|
5
|
+
* (LinkedIn URL, name+domain, email); a hit on ANY key means "skip before
|
|
6
|
+
* paying". Per-profile, stored under the credential home.
|
|
7
|
+
*
|
|
8
|
+
* Distinct from the meter (spend budget) and from the live-CRM dedup (the
|
|
9
|
+
* snapshot match + apply-time resolve-first): this avoids RE-spending across
|
|
10
|
+
* runs; those avoid double-creating within/against the CRM.
|
|
11
|
+
*
|
|
12
|
+
* Zero deps. Stored as { key: lastSeenISO } so the cache can be pruned by age.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { credentialsDir } from "./credentials.ts";
|
|
17
|
+
|
|
18
|
+
type SeenStore = Record<string, string>;
|
|
19
|
+
|
|
20
|
+
/** Keep the newest N keys; bounds the file for very large/long-running fills. */
|
|
21
|
+
const SEEN_CAP = 50_000;
|
|
22
|
+
|
|
23
|
+
export function acquireSeenPath(baseDir?: string): string {
|
|
24
|
+
return join(baseDir ?? credentialsDir(), "acquire", "seen.json");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Load the set of seen keys (empty if none/corrupt). */
|
|
28
|
+
export function loadSeen(baseDir?: string): Set<string> {
|
|
29
|
+
try {
|
|
30
|
+
const store = JSON.parse(readFileSync(acquireSeenPath(baseDir), "utf8")) as SeenStore;
|
|
31
|
+
return new Set(Object.keys(store));
|
|
32
|
+
} catch {
|
|
33
|
+
return new Set();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Merge keys into the cache with a `now` timestamp, prune to cap, persist. */
|
|
38
|
+
export function recordSeen(keys: string[], now: Date, baseDir?: string): void {
|
|
39
|
+
if (keys.length === 0) return;
|
|
40
|
+
let store: SeenStore = {};
|
|
41
|
+
try {
|
|
42
|
+
store = JSON.parse(readFileSync(acquireSeenPath(baseDir), "utf8")) as SeenStore;
|
|
43
|
+
} catch {
|
|
44
|
+
store = {};
|
|
45
|
+
}
|
|
46
|
+
const iso = now.toISOString();
|
|
47
|
+
for (const key of keys) store[key] = iso;
|
|
48
|
+
|
|
49
|
+
let entries = Object.entries(store);
|
|
50
|
+
if (entries.length > SEEN_CAP) {
|
|
51
|
+
entries = entries.sort((a, b) => (a[1] < b[1] ? 1 : -1)).slice(0, SEEN_CAP);
|
|
52
|
+
store = Object.fromEntries(entries);
|
|
53
|
+
}
|
|
54
|
+
const path = acquireSeenPath(baseDir);
|
|
55
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
56
|
+
writeFileSync(path, `${JSON.stringify(store)}\n`, "utf8");
|
|
57
|
+
}
|