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.
Files changed (59) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/INSTALL_FOR_AGENTS.md +8 -0
  3. package/README.md +39 -0
  4. package/dist/acquireLinkedIn.d.ts +41 -0
  5. package/dist/acquireLinkedIn.js +57 -0
  6. package/dist/acquireMeter.d.ts +67 -0
  7. package/dist/acquireMeter.js +145 -0
  8. package/dist/acquireSeen.d.ts +5 -0
  9. package/dist/acquireSeen.js +54 -0
  10. package/dist/assign.d.ts +83 -0
  11. package/dist/assign.js +146 -0
  12. package/dist/bin.js +14 -2
  13. package/dist/cli.js +817 -26
  14. package/dist/connectors/hubspot.js +140 -0
  15. package/dist/connectors/linkedin.d.ts +78 -0
  16. package/dist/connectors/linkedin.js +199 -0
  17. package/dist/connectors/prospectSources.d.ts +91 -0
  18. package/dist/connectors/prospectSources.js +227 -0
  19. package/dist/enrich.d.ts +107 -0
  20. package/dist/enrich.js +315 -5
  21. package/dist/format.d.ts +3 -1
  22. package/dist/format.js +14 -2
  23. package/dist/health.d.ts +71 -0
  24. package/dist/health.js +172 -0
  25. package/dist/icp.d.ts +96 -0
  26. package/dist/icp.js +256 -0
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.js +2 -0
  29. package/dist/mappings.js +3 -0
  30. package/dist/reassign.d.ts +11 -2
  31. package/dist/reassign.js +13 -6
  32. package/dist/runReport.d.ts +9 -0
  33. package/dist/runReport.js +66 -0
  34. package/dist/types.d.ts +25 -1
  35. package/docs/api.md +53 -3
  36. package/docs/architecture.md +11 -1
  37. package/docs/dx-punch-list.md +87 -0
  38. package/docs/linkedin-connector-spec.md +87 -0
  39. package/llms.txt +38 -1
  40. package/package.json +1 -1
  41. package/skills/fullstackgtm/SKILL.md +5 -3
  42. package/src/acquireLinkedIn.ts +83 -0
  43. package/src/acquireMeter.ts +186 -0
  44. package/src/acquireSeen.ts +57 -0
  45. package/src/assign.ts +193 -0
  46. package/src/bin.ts +17 -4
  47. package/src/cli.ts +965 -25
  48. package/src/connectors/hubspot.ts +145 -0
  49. package/src/connectors/linkedin.ts +272 -0
  50. package/src/connectors/prospectSources.ts +324 -0
  51. package/src/enrich.ts +411 -5
  52. package/src/format.ts +20 -2
  53. package/src/health.ts +238 -0
  54. package/src/icp.ts +313 -0
  55. package/src/index.ts +18 -0
  56. package/src/mappings.ts +3 -0
  57. package/src/reassign.ts +24 -8
  58. package/src/runReport.ts +76 -0
  59. package/src/types.ts +32 -0
@@ -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. `fix
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.34.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
+ }