fullstackgtm 0.43.0 → 0.44.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 (47) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +18 -7
  3. package/dist/cli.js +634 -56
  4. package/dist/connectors/outboxChannel.d.ts +64 -0
  5. package/dist/connectors/outboxChannel.js +170 -0
  6. package/dist/connectors/prospectSources.d.ts +22 -0
  7. package/dist/connectors/prospectSources.js +43 -0
  8. package/dist/connectors/signalSources.d.ts +105 -0
  9. package/dist/connectors/signalSources.js +316 -0
  10. package/dist/connectors/theirstack.d.ts +84 -0
  11. package/dist/connectors/theirstack.js +125 -0
  12. package/dist/icp.d.ts +47 -3
  13. package/dist/icp.js +105 -11
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.js +1 -0
  16. package/dist/init.js +3 -0
  17. package/dist/judgeEval.d.ts +7 -0
  18. package/dist/judgeEval.js +8 -1
  19. package/dist/runReport.d.ts +26 -0
  20. package/dist/runReport.js +15 -0
  21. package/dist/schedule.js +18 -4
  22. package/dist/signals.d.ts +54 -0
  23. package/dist/signals.js +64 -0
  24. package/dist/tam.d.ts +225 -0
  25. package/dist/tam.js +470 -0
  26. package/docs/api.md +18 -4
  27. package/docs/outbox-format.md +92 -0
  28. package/docs/recipes.md +37 -0
  29. package/docs/roadmap-to-1.0.md +31 -1
  30. package/docs/signal-spool-format.md +162 -0
  31. package/docs/tam.md +195 -0
  32. package/llms.txt +68 -5
  33. package/package.json +1 -1
  34. package/skills/fullstackgtm/SKILL.md +3 -2
  35. package/src/cli.ts +714 -51
  36. package/src/connectors/outboxChannel.ts +202 -0
  37. package/src/connectors/prospectSources.ts +57 -0
  38. package/src/connectors/signalSources.ts +363 -0
  39. package/src/connectors/theirstack.ts +170 -0
  40. package/src/icp.ts +113 -11
  41. package/src/index.ts +32 -0
  42. package/src/init.ts +3 -0
  43. package/src/judgeEval.ts +8 -1
  44. package/src/runReport.ts +39 -0
  45. package/src/schedule.ts +20 -4
  46. package/src/signals.ts +90 -0
  47. package/src/tam.ts +654 -0
@@ -140,8 +140,38 @@ outward, one layer per release, each consolidating before the next expanded:
140
140
  broker-https enforcement, the build/CI dist-integrity gate (published dist is
141
141
  provably from source), Salesforce-merge capability matrix.
142
142
 
143
+ ## 0.29 → 0.43 — from hygiene to the full outbound loop, as shipped
144
+
145
+ The next train extended the governed spine from *fixing the CRM* to *filling
146
+ it and acting on it*, without ever loosening the write gate:
147
+
148
+ - **0.29–0.34** — market-map maturation (config suggestion + taxonomy types,
149
+ sourcing helpers, competitor discovery, vendor logos/scale in the report)
150
+ plus call-type classification with type-specific coaching rubrics (0.32);
151
+ Salesforce `dedupe`/`merge_records` via SOAP and pipeline-aware closed/won
152
+ detection (0.30).
153
+ - **0.35–0.36** — DX cohesion: progressive-disclosure help, summary-first
154
+ `audit` with next-step guidance; the engagement workspace — a deterministic
155
+ `health` verb over a per-profile timeline.
156
+ - **0.37–0.39** — acquire: `enrich acquire` (net-new, ICP-targeted,
157
+ resolve-first deduped, metered lead creation), lead assignment policies so
158
+ leads are never born ownerless, the LinkedIn (HeyReach) discovery source
159
+ with email resolution, and run observability for broker-paired CLIs.
160
+ - **0.40** — Salesforce reaches full write parity with HubSpot
161
+ (`create_record`); HubSpot date-field writes no longer false-conflict.
162
+ - **0.41** — signal-based outbound: `signals fetch/list/outcome/weights`,
163
+ `icp judge`/`icp eval` (timing × fit × memory, golden-set calibration
164
+ gate), `draft` (trigger-grounded openers as governed plans), and the LLM
165
+ base-URL override.
166
+ - **0.42** — bulk apply for lead creation (HubSpot batch + Salesforce
167
+ composite).
168
+ - **0.43** — the on-ramp and the seams: `init` workspace scaffolding,
169
+ `docs/recipes.md` (five composable plays), and the contact/account seam
170
+ campaign — contact-granular outbound end to end, account-level acquire for
171
+ ABM, `health` by object type.
172
+
143
173
  The known-gaps list below predates these layers and has been re-verified
144
- against the 0.28 surface: still accurate, still open.
174
+ against the 0.43 surface (2026-07): still accurate, still open.
145
175
 
146
176
  ## Known real-portal gaps to close before 1.0
147
177
 
@@ -0,0 +1,162 @@
1
+ # Signal spool format (webhook landing zone)
2
+
3
+ The **spool** is how push-based signal platforms — website de-anonymization
4
+ (RB2B), social/job listening (Trigify), form-submission webhooks (HubSpot) —
5
+ reach `fullstackgtm`. The CLI is a deterministic batch tool, not an always-on
6
+ server, so it does not receive webhooks itself. Instead:
7
+
8
+ 1. A **receiver** you run (any HTTP endpoint — 20 lines, or an off-the-shelf
9
+ webhook-to-file tool) appends one JSON row per event to a spool file.
10
+ 2. `fullstackgtm signals fetch --connector file` reads the spool on its next run,
11
+ turning each row into a ranked signal through the same evidence-gate → dedup →
12
+ weight pipeline as every other source.
13
+
14
+ The spool format and the `file` connector that reads it are open source. The
15
+ package ships **no receiver** — operating an always-on endpoint is out of scope
16
+ for the CLI (a managed receiver is part of the hosted product). This doc is the
17
+ contract your receiver writes to.
18
+
19
+ ## The format
20
+
21
+ A spool file is **newline-delimited JSON (JSONL)**: one JSON object per line,
22
+ append-only. (A single `.json` file containing a JSON array is also accepted, for
23
+ convenience when staging by hand.) Each object is a **staged signal row**:
24
+
25
+ | Field | Required | Meaning |
26
+ |---|---|---|
27
+ | `bucket` | yes¹ | One of `demand`, `funding`, `job`, `company`, `social`. |
28
+ | `accountDomain` | yes | The company domain. `domain` is accepted as an alias. Normalized (protocol/`www`/path stripped, lowercased) on read. |
29
+ | `trigger` | yes | Short human label, e.g. `"visited pricing page"`, `"raised Series B"`. |
30
+ | `quote` | yes | **Verbatim evidence** — the exact text a human can verify (a headline, a page title, the submitted form + email). A row with no quote is **dropped**, never faked. |
31
+ | `sourceUrl` | no | Link to the evidence. |
32
+ | `firstSeen` | no | ISO 8601 timestamp. Defaults to the fetch time. |
33
+ | `weight` | no | Explicit weight; defaults to the bucket's configured weight. |
34
+
35
+ ¹ `bucket` may be omitted in a spool file — the `file` connector fills its
36
+ default (`company`). Set it explicitly per row when a source spans buckets.
37
+
38
+ Example spool file (`rb2b.jsonl`):
39
+
40
+ ```jsonl
41
+ {"bucket":"company","accountDomain":"globex.com","trigger":"visited pricing 3x","quote":"globex.com — viewed /pricing and /demo, 3 sessions","sourceUrl":"https://app.rb2b.com/v/abc123"}
42
+ {"bucket":"company","accountDomain":"initech.com","trigger":"visited docs","quote":"initech.com — viewed /docs/api","sourceUrl":"https://app.rb2b.com/v/def456"}
43
+ ```
44
+
45
+ A row that fails validation (unknown bucket, missing domain/trigger/quote) makes
46
+ the fetch error and names the offending row — a corrupt spool is surfaced, not
47
+ silently skipped. Keep your receiver's mapping faithful to this table.
48
+
49
+ ## Where the spool lives
50
+
51
+ `--connector file` with **no path** reads the conventional landing zone:
52
+
53
+ ```
54
+ <profile home>/signals/spool/ (default: ~/.fullstackgtm/signals/spool/)
55
+ ```
56
+
57
+ Every `*.jsonl` (and `*.json`) file in that directory is read and concatenated,
58
+ name-sorted — so each platform can append to its own file and they all land in
59
+ one fetch:
60
+
61
+ ```
62
+ ~/.fullstackgtm/signals/spool/rb2b.jsonl
63
+ ~/.fullstackgtm/signals/spool/trigify.jsonl
64
+ ~/.fullstackgtm/signals/spool/hubspot.jsonl
65
+ ```
66
+
67
+ Point at a different file or directory with `--connector-opt path=<path>`:
68
+
69
+ ```bash
70
+ # the conventional landing zone (zero-arg)
71
+ fullstackgtm signals fetch --connector file --save
72
+
73
+ # a specific file or directory
74
+ fullstackgtm signals fetch --connector file --connector-opt path=./events.jsonl --save
75
+ fullstackgtm signals fetch --connector file --connector-opt path=/var/spool/fsgtm --save
76
+ ```
77
+
78
+ The CLI **never writes** to the spool — your receiver does. The directory is
79
+ profile-scoped, so spools for different client workspaces stay separate.
80
+
81
+ ### Re-reads and retention
82
+
83
+ Re-reading the same spool is safe: signals dedup on `(accountDomain, bucket,
84
+ trigger)` within the configured window (`dedupWindowDays`, default 30), so a row
85
+ that's still in the spool on the next fetch is deduped, not double-counted. The
86
+ CLI does not consume or truncate the spool — **retention is yours**: rotate or
87
+ truncate the files on your own schedule (e.g. nightly, or after a successful
88
+ `--save`). Unbounded spool growth is a self-host concern the CLI doesn't manage.
89
+
90
+ ## Writing a receiver
91
+
92
+ A receiver is whatever turns a platform's webhook POST into spool rows. The
93
+ minimum: verify the platform's signature, map the payload to the fields above,
94
+ append one JSON line. Pseudocode:
95
+
96
+ ```js
97
+ // POST handler for your platform's webhook
98
+ function onWebhook(req) {
99
+ verifySignature(req); // platform's HMAC / shared secret
100
+ const row = mapToStagedRow(req.body); // per-platform mapping (below)
101
+ if (!row.quote) return 204; // no verifiable evidence -> skip
102
+ appendLine("~/.fullstackgtm/signals/spool/<platform>.jsonl", JSON.stringify(row));
103
+ return 204;
104
+ }
105
+ ```
106
+
107
+ Keep the receiver dumb: no ranking, no CRM writes, no judgment. All of that is
108
+ the CLI's job, gated and deterministic, on the next `signals fetch`.
109
+
110
+ ## Per-platform mapping
111
+
112
+ These map each platform's webhook payload to a staged row. Payload shapes drift —
113
+ verify against the platform's current webhook docs before relying on a field.
114
+
115
+ ### RB2B (website visitor de-anonymization)
116
+
117
+ RB2B delivers an identified visitor (person + company) via outbound webhook.
118
+ Use the **company domain** as `accountDomain` and the visited pages as the
119
+ verbatim `quote`.
120
+
121
+ | Spool field | From RB2B payload |
122
+ |---|---|
123
+ | `bucket` | `"company"` (or `"demand"` for high-intent pages) |
124
+ | `accountDomain` | the visitor's company domain |
125
+ | `trigger` | e.g. `"visited pricing"` derived from the top page |
126
+ | `quote` | `"<domain> — <pages viewed>"` (verbatim page paths) |
127
+ | `sourceUrl` | the RB2B visitor permalink |
128
+
129
+ ### Trigify (social / job-change listening)
130
+
131
+ Trigify pushes social and job-change events. Map the monitored account's domain;
132
+ the post/job text is the evidence.
133
+
134
+ | Spool field | From Trigify payload |
135
+ |---|---|
136
+ | `bucket` | `"social"` or `"job"` |
137
+ | `accountDomain` | the account/company domain |
138
+ | `trigger` | e.g. `"new role: VP Sales"` or `"posted about <topic>"` |
139
+ | `quote` | the verbatim post text or job title + snippet |
140
+ | `sourceUrl` | the source post / listing URL |
141
+
142
+ ### HubSpot form-submission webhook
143
+
144
+ The `hubspot-forms` **pull** connector already covers recent submissions via the
145
+ Forms API (Phase 1). For real-time, subscribe to HubSpot's form-submission
146
+ webhook and append a `demand` row per submission — the same shape the pull
147
+ connector produces, so both paths dedup against each other.
148
+
149
+ | Spool field | From HubSpot submission |
150
+ |---|---|
151
+ | `bucket` | `"demand"` |
152
+ | `accountDomain` | corporate domain of the submitted email (drop free-mail) |
153
+ | `trigger` | `"form: <form name>"` |
154
+ | `quote` | `"Submitted \"<form name>\" — <email>"` |
155
+ | `firstSeen` | submission timestamp (ISO 8601) |
156
+
157
+ ## See also
158
+
159
+ - `docs/api.md` — the `signals` verb and the `SignalSourceConnector` contract.
160
+ - The connector taxonomy and the hosted/open-source split for the receiver are
161
+ described in the maintainer design doc (monorepo
162
+ `docs/spec-connectors-signals-outbound.md`).
package/docs/tam.md ADDED
@@ -0,0 +1,195 @@
1
+ # TAM mapping — size the reachable market, then fill it
2
+
3
+ `tam` answers a longer-horizon question than `market` (the competitive map): **how
4
+ big is the reachable market for my ICP, and how far along am I in actually putting
5
+ it in the CRM?** It is deliberately *not* a one-shot "$10B TAM" headline — it pairs
6
+ a defensible estimate with an iterative, governed population loop and a coverage
7
+ timeline so "this will take a while" becomes a quantified ETA.
8
+
9
+ ```
10
+ tam estimate → tam populate → (approve/apply each cycle) → tam status --save → tam report
11
+ model schedule governed acquire coverage point burn-up + ETA
12
+ ```
13
+
14
+ ## The model
15
+
16
+ `tam estimate` derives the universe **from your ICP** (`icp.json`):
17
+
18
+ ```
19
+ universe accounts = a real count for the ICP filter
20
+ contacts (population target) = accounts × buyers/account
21
+ TAM $ = accounts × ACV (account/per-logo basis — the default)
22
+ = contacts × ACV (buyer/per-seat basis, --acv-basis buyer)
23
+ ```
24
+
25
+ The account count comes from one of three places, always **labeled** in the model:
26
+
27
+ - `--source theirstack` — **the real, technographically-targeted universe** (and the
28
+ only one that returns an actual list). TheirStack counts companies that *use* a
29
+ CRM/MAP — set `firmographics.technologies` (e.g. `["salesforce","hubspot",
30
+ "pipedrive"]`) — plus geo + employee bounds. `accountsSource:
31
+ "provider:theirstack (uses-CRM)"`. This is the buying signal for a CRM-hygiene/
32
+ RevOps tool: "company runs a CRM", not "company is a software publisher". **And
33
+ `tam accounts --source theirstack` pulls the actual company list** (real names +
34
+ domains) you can review, save (`--out <csv>`), and acquire into the CRM.
35
+ (Counting is cheap but **not free** — TheirStack rejects `limit:0`, so a count
36
+ returns 1 row ≈ 3 credits; a list pull is ~3 credits/company. `login theirstack`.)
37
+ - `--source explorium` — a firmographic **company count** from Explorium
38
+ `/v1/businesses` (geo + employee bands + NAICS). A count only — **Explorium 403s
39
+ when you try to pull the list**, and it can't target by CRM-usage, so it's a rough
40
+ size estimate, not a list. `accountsSource: "provider:explorium"`.
41
+ - `--accounts <n>` — an explicit assumption (e.g. a number from a provider export).
42
+ `accountsSource: "assumption"`.
43
+
44
+ > **Why technographic, and why a count isn't enough** (verified live, 2026-06-25/26):
45
+ > lead/people endpoints don't report a real universe — Explorium `/v1/prospects`
46
+ > `total_results` equals the requested page size (1→1, 10→10), and pipe0/Crustdata
47
+ > returns only a pagination cursor with no total. Explorium `/v1/businesses` counts
48
+ > companies (e.g. US mid-market software ≈ 19,000) — **as long as you don't send a
49
+ > `size` field, which silently caps the total** — but it can't filter by CRM-usage
50
+ > and **403s when you try to pull the list**. TheirStack is the technographic
51
+ > source that does both (target by tech, return real records). pipe0 stays a
52
+ > discovery/population source (`tam populate`), not a counting one; `--source pipe0`
53
+ > for `estimate` is rejected with that explanation.
54
+
55
+ Explorium caps `total_results` at **60,000** (a `provider:explorium (≥60k cap —
56
+ floor)` label + a ⚠). TheirStack has no such cap but list-pull costs credits, so
57
+ `tam accounts --max <n>` pages it.
58
+
59
+ ### List-pull cost (never spend by surprise)
60
+
61
+ `tam accounts` pulls real records at **~3 TheirStack credits/company**, so it always
62
+ prints the cost up front and guards big pulls:
63
+
64
+ - `tam accounts --dry-run [--usd-per-credit <rate>]` — prices the pull AND the full
65
+ TAM (e.g. *"up to 100 companies ≈ 300 credits. Full TAM (~43,517) ≈ 130,551
66
+ credits (~$5,483) to materialize."*) and spends **0 credits**. The dollar figure
67
+ appears only when you give a rate — TheirStack pricing is ~$0.04–$0.11/credit by
68
+ tier, and the tool won't fabricate a default.
69
+ - A pull above `--max-credits` (default **150** ≈ 50 companies) requires `--confirm`
70
+ — so a large pull is always a deliberate choice, not an accident.
71
+
72
+ So the natural pattern is **count freely, pull in confirmed batches** (`--max N`),
73
+ acquire, measure coverage, repeat — rather than one large up-front spend.
74
+
75
+ **ACV must be a real, ANNUAL figure that you confirm** — never a band default, and
76
+ never silently lifted from the CRM. `--provider` is your **coverage** source, not
77
+ your ACV: the account universe is Explorium, coverage is the CRM, and the TAM's
78
+ economics are yours to confirm. Two confirmed paths, always labeled (`acv.source`):
79
+
80
+ - `--acv <annual-usd>` — your confirmed annualized ACV (`source: "explicit (annual)"`).
81
+ - `--acv-from-crm --deal-period monthly|quarterly|annual` — derive the **median
82
+ closed-won deal amount** and **annualize** it by the period you specify
83
+ (`source: "crm:closed-won (N deals, median $X/monthly ×12 = annual)"`). The period
84
+ is **required** because a deal amount can be MRR, quarterly, or annual — guessing
85
+ it is a 4–12× error (a $15k/mo deal is a $180k ACV). The deal count is in the
86
+ label, so a thin signal (e.g. "1 deal") is visible — don't over-trust it.
87
+
88
+ If you give neither, `estimate` errors rather than invent a number — and a bare
89
+ `--provider` does NOT auto-set ACV.
90
+
91
+ **Buyers/account** (the contact population target = accounts × buyers) gets the same
92
+ treatment: `--buyers-per-account <n>`, else the CRM's **average contacts per account**
93
+ (`crm:avg-contacts/account (N)`), else a clearly-labeled minimal assumption of 1
94
+ (`assumption:1-buyer`) — never a silent guess. `--acv-basis buyer` makes ACV per-seat
95
+ (TAM = contacts × ACV) instead of per-logo.
96
+
97
+ Optional citable **cross-checks** (`--cross-checks <file.json>`, an array of
98
+ `{claim, valueUsd?, sourceUrl, quote, asOf?}`) sit alongside the bottom-up number as a
99
+ top-down sanity check — the market-research flavor, evidence-attached.
100
+
101
+ `estimate` also records a **coverage baseline** (the CRM account/contact counts right
102
+ now, when a `--provider`/`--input`/`--demo` source is given) so `status` can attribute
103
+ what *this campaign* added versus what was already there.
104
+
105
+ ## Populate it (governed, never auto-writes)
106
+
107
+ `tam populate --cron "<expr>"` wires a recurring **`enrich acquire --source <s> --save`**
108
+ into the scheduler. Each firing queues a `needs_approval` lead plan — it does **not**
109
+ write to the CRM, and the acquire meter is charged only at apply. Apply stays a separate
110
+ human gate (`apply --plan-id <id>`, re-checked approved at every firing). So a scheduled
111
+ TAM populates by *accumulating proposals you approve*, never by surprise leads.
112
+
113
+ ```bash
114
+ fullstackgtm tam populate --cron "0 7 * * 1-5" --source pipe0 --provider hubspot
115
+ fullstackgtm schedule install # activate the crontab entry
116
+ # each cycle:
117
+ fullstackgtm plans list # the morning's lead plan
118
+ fullstackgtm plans approve <id> --operations all
119
+ fullstackgtm apply --plan-id <id> --provider hubspot
120
+ ```
121
+
122
+ This is the one place the schedule allowlist permits a create-side verb, and only in
123
+ its plan-producing `--save` form (scheduling `enrich acquire` without `--save` is
124
+ rejected — it would queue nothing).
125
+
126
+ ## Track coverage (where you're getting to)
127
+
128
+ Coverage is measured on the **intersection** of your CRM and the TAM's ICP — not a
129
+ raw account count. `tam estimate` stores the ICP filter on the model, and `tam
130
+ status` classifies every domain-bearing CRM account against it:
131
+
132
+ - **in-TAM** — matches every CRM-checkable criterion (size, industry) with no
133
+ contradiction. *Only these count toward coverage.*
134
+ - **out-of-TAM** — contradicts a criterion (wrong size/industry). Reported
135
+ separately; never inflates coverage. So 50k accounts loaded from other sources
136
+ that aren't your target market don't fake a high number.
137
+ - **unknown** — the record lacks the fields to judge. Surfaced, never silently
138
+ counted — it tells you what to enrich.
139
+
140
+ ```bash
141
+ fullstackgtm tam status --provider hubspot --save # classified coverage + a timeline reading
142
+ fullstackgtm tam report # markdown: model + in/out/unknown + burn-up + ETA
143
+ ```
144
+
145
+ > **Honest limit:** geo and "uses a CRM" aren't on a CRM account record, so an
146
+ > in-TAM classification means "matches what the CRM lets us check" (size + industry),
147
+ > not a full technographic match. A `--reverify` pass (re-confirm geo/tech per
148
+ > domain via TheirStack) is planned; until then, status labels what it checked.
149
+
150
+ **Bottom-up vs top-down — squaring "more in CRM than estimated."** The estimate is a
151
+ *lower-bound prior*, not a ceiling — TheirStack only knows ~740k US firms, so your
152
+ multi-source CRM can legitimately hold more valid in-TAM accounts than it predicted.
153
+ Every in-TAM account is a *proven* member of the real market, so the in-TAM count is
154
+ the strongest floor on the true universe. When `in-TAM ≥ estimate`, `status` stops
155
+ reporting a fake "100%" and flips the signal: **"the estimate was a floor — your real
156
+ market is at least N; re-estimate (broader ICP / better source) to find the
157
+ headroom."** `reconciledUniverse = max(estimate, in-TAM)`.
158
+
159
+ Once two-plus readings show movement, `report`/`status` project an **ETA** on the
160
+ **in-TAM** burn rate (off-ICP accounts loaded elsewhere don't count as progress) →
161
+ days remaining → a projected fill date. Fewer than two readings, a flat rate, or no
162
+ elapsed time returns an honest "not enough history yet," never a fabricated date.
163
+
164
+ ## Storage
165
+
166
+ Profile-scoped under `<home>/tam/<name>/`:
167
+
168
+ - `model.json` — the TAM model (universe, ACV, cross-checks, baseline). 0600.
169
+ - `coverage.jsonl` — append-only coverage readings (one per `status --save`). 0600.
170
+
171
+ Scope per client with `--profile <name>`; name multiple TAMs with `--name <n>`
172
+ (default `default`).
173
+
174
+ ## Invariants
175
+
176
+ - **Estimate is honest about its source.** Every model says whether the account count
177
+ is a `provider:*` probe or an `assumption`; the probe errors rather than silently
178
+ guessing when the provider returns no count.
179
+ - **No fabricated economics.** ACV must be explicit or CRM-derived (closed-won) — there
180
+ are no band defaults, and `estimate` refuses without a real ACV. Buyers/account and
181
+ ACV both carry a `source` label so a thin signal is visible, never hidden.
182
+ - **Population never auto-writes.** `tam populate` schedules plan-only acquire; apply
183
+ is a separate gate, the meter is charged only at apply, and the allowlist requires
184
+ `--save`.
185
+ - **Coverage is deterministic** from the CRM snapshot + the model baseline — no LLM,
186
+ byte-stable, comparable run-over-run.
187
+ - **ETA is refused when unprojectable** rather than faked.
188
+
189
+ ## Library
190
+
191
+ `estimateTam`, `computeCoverage`, `coverageCountsFromSnapshot`, `projectEta`,
192
+ `tamReportToMarkdown`, the store (`saveTamModel`/`loadTamModel`/`appendCoverage`/
193
+ `readCoverageTimeline`), the count probe (`probeExploriumBusinessCount` +
194
+ `EXPLORIUM_BUSINESS_COUNT_CAP`), and the ICP→firmographic filter
195
+ (`icpToExploriumBusinessFilters`) are all exported for programmatic use.
package/llms.txt CHANGED
@@ -20,6 +20,7 @@ at/above `--fail-on`.
20
20
  - [Contributing](https://github.com/fullstackgtm/core/blob/main/CONTRIBUTING.md): dev setup, the open-core mirror model, the release ritual
21
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
22
22
  - [CRM-health lifecycle](https://github.com/fullstackgtm/core/blob/main/docs/crm-health-lifecycle.md): the Prevent → Detect → Remediate → Verify/Attribute model; no-new-dupes design
23
+ - [TAM mapping](https://github.com/fullstackgtm/core/blob/main/docs/tam.md): size the reachable market from the ICP, then populate it with scheduled governed acquire and track coverage to an ETA
23
24
  - [CHANGELOG](https://github.com/fullstackgtm/core/blob/main/CHANGELOG.md): release history
24
25
 
25
26
  `fullstackgtm resolve <type>` is the create gate: exit 0 = safe to create,
@@ -58,6 +59,60 @@ uncertainty; report bubbles ∝ estimated share only when signals exist.
58
59
  Storage is profile-scoped under `<home>/market/<category>`. MCP:
59
60
  `fullstackgtm_market_worksheet`, `fullstackgtm_market_observe`.
60
61
 
62
+ ## Key invariants (TAM mapping)
63
+
64
+ `fullstackgtm tam` sizes the reachable market FROM the ICP and then iteratively
65
+ fills it — not a one-shot headline. `tam estimate` computes a transparent model:
66
+ account universe × ACV = TAM (account/per-logo basis by default; `--acv-basis
67
+ buyer` for per-seat = contacts × ACV), where contacts = accounts ×
68
+ buyers/account is the population target. The account count is `--accounts <n>`
69
+ (assumption), OR `--source theirstack` — the TECHNOGRAPHIC universe: counts
70
+ companies that USE a CRM/MAP (`firmographics.technologies` → TheirStack
71
+ `company_technology_slug_or`) + geo + employee bounds; the real RevOps buying signal
72
+ AND the only source that returns a LIST (`tam accounts --source theirstack` pulls
73
+ real companies name/domain, `--out csv`/`--json`, `--max` caps credit spend; a
74
+ count is cheap but NOT free — limit:0 is rejected so a count returns 1 row ≈ 3
75
+ credits, a list pull ≈ 3/company; `login theirstack`). OR `--source explorium` — a firmographic
76
+ count via `/v1/businesses` (`total_results`), a count ONLY (Explorium 403s on
77
+ list-pull, can't target by CRM-usage; must OMIT `size` or it caps the total; capped
78
+ at 60,000 `EXPLORIUM_BUSINESS_COUNT_CAP` → `provider:explorium (≥60k cap — floor)` +
79
+ ⚠). Verified live: people endpoints can't size a market (Explorium `/v1/prospects`
80
+ total == page size; pipe0/Crustdata returns only a cursor — `--source pipe0` for
81
+ estimate is REJECTED, it's a population source). The model always LABELS
82
+ `accountsSource` (provider:theirstack/explorium vs assumption). ACV must be a
83
+ real ANNUAL figure the operator CONFIRMS — `--acv <annual-usd>`, OR `--acv-from-crm
84
+ --deal-period monthly|quarterly|annual` which takes the median closed-won amount and
85
+ ANNUALIZES it (the period is required — a deal can be MRR/quarterly/annual and
86
+ guessing is a 4–12× error; $15k/mo → $180k). NO band defaults, and a bare
87
+ `--provider` does NOT auto-set ACV (HubSpot is the COVERAGE source, not the ACV) —
88
+ `estimate` REFUSES without a confirmed ACV. Buyers/account is `--buyers-per-account`
89
+ OR the CRM's avg-contacts-per-account OR a labeled `assumption:1-buyer`. Both
90
+ `acv.source` and `universe.buyersSource` are labeled (incl. deal count + the
91
+ period×factor, so a thin signal like "1 deal" is visible). Optional
92
+ citable cross-checks (`{claim,valueUsd?,sourceUrl,quote}`) sit beside the
93
+ bottom-up number as a top-down sanity check. `estimate`
94
+ records a coverage BASELINE (CRM counts now) so `status` attributes what the
95
+ campaign added. `tam populate --cron` schedules `enrich acquire --source <s>
96
+ --save` — the ONE create-side verb the schedule allowlist permits, and only in
97
+ its plan-producing `--save` form: each firing queues a needs_approval lead plan,
98
+ the acquire meter is charged only at apply, and apply stays `apply --plan-id`
99
+ (re-checked approved). `tam status` measures coverage on the INTERSECTION of CRM ×
100
+ TAM-ICP, not a raw count: `tam estimate` stores the ICP filter (`model.targeting`),
101
+ and status CLASSIFIES each domain-bearing account into in-TAM / out-of-TAM / unknown
102
+ (checked on size+industry — geo + "uses-CRM" aren't on a CanonicalAccount, so a
103
+ `--reverify` re-enrich is future; the classifier labels what it checked). ONLY
104
+ in-TAM counts toward coverage, so off-ICP accounts loaded from elsewhere can't
105
+ inflate it. Bottom-up vs top-down reconciliation: the in-TAM count is a FLOOR on the
106
+ real universe (those are verified members), so when in-TAM ≥ the estimate, status
107
+ stops reporting a fake 100% and flips to "estimate was a floor — real market is at
108
+ least N (`reconciledUniverse = max(estimate, inTam)`); re-estimate for the headroom".
109
+ `--save` stamps an append-only timeline. `tam report` (markdown) and `status` project
110
+ an ETA on the IN-TAM accounts/day burn across the timeline → days-remaining → fill
111
+ date — and REFUSE to project (honest "not enough history") below two readings, a flat
112
+ rate, or zero elapsed time.
113
+ Coverage + ETA are deterministic (no LLM, byte-stable). Storage profile-scoped
114
+ under `<home>/tam/<name>/` (`model.json` + `coverage.jsonl`, 0600).
115
+
61
116
  ## Key invariants (governed write verbs)
62
117
 
63
118
  `bulk-update <object> --where … (--set|--archive|--create-task)` filters the
@@ -123,8 +178,12 @@ and no webhook ingestion (Phase 2). Never auto-writes.
123
178
  The timing/outbound layer turns "who changed" into one grounded, governed
124
179
  opener — and **never sends**. `signals fetch` captures fresh buying triggers
125
180
  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`
181
+ box; funding/social via `--from`; connected platforms via `--connector`
182
+ file/serpapi-news/hubspot-forms with secrets through the credential ladder; and
183
+ push platforms via the webhook spool — `--connector file` reads the conventional
184
+ landing zone `<home>/signals/spool/*.jsonl`, see docs/signal-spool-format.md),
185
+ keyed by **account domain**, ranked by learned `weights`; it writes NOTHING to
186
+ the CRM. `icp interview|set|show`
128
187
  builds `icp.json`; `icp judge` ranks fresh signals into send/nurture/skip — pass
129
188
  a snapshot (`--provider`/`--input`/`--demo`) and each decision resolves its CRM
130
189
  target: `accountId`, the best `contact {id,email,title}`, and `contacts[]` (all
@@ -135,8 +194,11 @@ bar). `draft` emits ONE trigger-grounded opener per hot account as a governed
135
194
  `create_task` targeting the resolved `contact.id` (or `accountId`) — a
136
195
  domain-only decision (account not yet in the CRM) is REJECTED with "acquire it
137
196
  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>
197
+ email|linkedin|task` shapes the opener. After `plans approve`, applying the draft
198
+ plan logs the touch as a CRM task (`apply --provider <crm>`) OR renders it to the
199
+ governed outbox (`apply --channel outbox` → `<home>/signals/outbox/<channel>.jsonl`
200
+ for a downstream sender to drain). Either way the CLI **transmits nothing** — the
201
+ outbox is the send-side mirror of the spool; the always-on sender is hosted/opt-in. `signals outcome --account <domain>
140
202
  --contact <id> --result replied|...` credits the contact reached so weights
141
203
  re-learn. The contact↔account join (`contact.accountId` + `account.domain`) is
142
204
  surfaced at every hop, so the loop is contact-coherent end to end — see the
@@ -147,7 +209,8 @@ surfaced at every hop, so the loop is contact-coherent end to end — see the
147
209
  `fullstackgtm schedule` is the horizontal scheduler — no feature namespace
148
210
  owns cron logic. `add "<command>" --cron "<expr>"` validates the command
149
211
  against the read/plan-side allowlist (audit, snapshot, enrich append|refresh,
150
- market capture|refresh, suggest, report, doctor) and the 5-field cron
212
+ enrich acquire --save, market capture|refresh, signals fetch, icp judge|eval,
213
+ draft, suggest, report, doctor) and the 5-field cron
151
214
  expression, but touches no crontab; `install` materializes enabled entries
152
215
  into a sentinel-delimited managed block (`# >>> fullstackgtm <profile> >>>`)
153
216
  replaced wholesale on re-install, lines outside it untouched; `uninstall`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.43.0",
3
+ "version": "0.44.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)",
@@ -63,10 +63,11 @@ credentials AND stored plans per client org.
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
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 |
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; `--from` staged files; `--connector file\|serpapi-news\|hubspot-forms` to pull connected platforms / read the webhook spool `<home>/signals/spool`, secrets via the credential ladder), ranked by learned weights. Writes NOTHING to the CRM; `outcome --account <domain> --contact <id> --result …` re-weights which triggers earn a touch |
67
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 |
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** — after `plans approve`, `apply --provider <crm>` logs it as a CRM task, or `apply --channel outbox` renders it to `<home>/signals/outbox/<channel>.jsonl` for a downstream sender to drain; the CLI transmits nothing |
69
69
  | `market init\|capture\|classify\|worksheet\|observe\|fronts\|axes\|overlay\|scale\|report\|refresh` | Competitive category map; evidence quotes verified verbatim against stored captures |
70
+ | `tam estimate\|accounts\|status\|report\|populate` | Size the reachable market FROM the ICP (real account count × real ACV; buyers/account → contact target), then iteratively fill it: `populate` schedules plan-only `enrich acquire --save` (apply stays gated), `status` classifies CRM accounts vs the TAM ICP into **in-TAM / out-of-TAM / unknown** (only in-TAM counts — junk loaded from elsewhere doesn't inflate coverage; checked on size+industry, geo/tech need re-enrich) and flips to "estimate was a FLOOR, revise up" when bottom-up in-CRM exceeds the top-down estimate; `--save` stamps a timeline, `report` projects burn-up + ETA on the in-TAM rate. **`estimate --source theirstack`** counts companies that USE a CRM/MAP (`firmographics.technologies`) — the real RevOps universe — and **`tam accounts --source theirstack`** pulls the actual company LIST (names+domains, `--out csv`) at ~3 credits/company — `--dry-run [--usd-per-credit <r>]` prices the pull + full TAM for 0 credits, `--confirm` gates pulls above `--max-credits` (default 150). `--source explorium` = firmographic count only (no list, 60k cap = floor); else `--accounts`. **ACV must be a real ANNUAL figure you confirm** — `--acv <annual-usd>`, or `--acv-from-crm --deal-period monthly\|quarterly\|annual` (median closed-won, annualized); NO band defaults, a bare `--provider` does NOT set ACV (HubSpot=coverage), refuses without one. Buyers/account = CRM avg-contacts or explicit. Every number labeled with its source |
70
71
  | `schedule add\|list\|remove\|enable\|disable\|run\|install\|uninstall\|status` | Horizontal cron; read/plan-side allowlist only — scheduling NEVER auto-approves |
71
72
  | `report` | Client-ready audit deliverable (markdown or self-contained HTML) |
72
73
  | `health [--json]` | Per-profile CRM health timeline: deterministic 0–100 score, trend, per-rule deltas — accrues from `audit --save`, read-only |