fullstackgtm 0.42.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 (68) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/README.md +18 -7
  3. package/dist/calls.d.ts +13 -0
  4. package/dist/calls.js +14 -3
  5. package/dist/cli.js +718 -60
  6. package/dist/connectors/hubspot.js +58 -24
  7. package/dist/connectors/outboxChannel.d.ts +64 -0
  8. package/dist/connectors/outboxChannel.js +170 -0
  9. package/dist/connectors/prospectSources.d.ts +22 -0
  10. package/dist/connectors/prospectSources.js +43 -0
  11. package/dist/connectors/salesforce.js +40 -15
  12. package/dist/connectors/signalSources.d.ts +105 -0
  13. package/dist/connectors/signalSources.js +316 -0
  14. package/dist/connectors/theirstack.d.ts +84 -0
  15. package/dist/connectors/theirstack.js +125 -0
  16. package/dist/draft.js +27 -5
  17. package/dist/enrich.d.ts +6 -0
  18. package/dist/enrich.js +47 -1
  19. package/dist/health.d.ts +12 -0
  20. package/dist/health.js +29 -2
  21. package/dist/icp.d.ts +47 -3
  22. package/dist/icp.js +105 -11
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.js +2 -0
  25. package/dist/init.d.ts +47 -0
  26. package/dist/init.js +143 -0
  27. package/dist/judge.d.ts +36 -3
  28. package/dist/judge.js +46 -10
  29. package/dist/judgeEval.d.ts +7 -0
  30. package/dist/judgeEval.js +8 -1
  31. package/dist/runReport.d.ts +26 -0
  32. package/dist/runReport.js +15 -0
  33. package/dist/schedule.js +18 -4
  34. package/dist/signals.d.ts +58 -0
  35. package/dist/signals.js +65 -0
  36. package/dist/tam.d.ts +225 -0
  37. package/dist/tam.js +470 -0
  38. package/dist/types.d.ts +12 -0
  39. package/docs/api.md +26 -4
  40. package/docs/outbox-format.md +92 -0
  41. package/docs/recipes.md +195 -0
  42. package/docs/roadmap-to-1.0.md +31 -1
  43. package/docs/signal-spool-format.md +162 -0
  44. package/docs/tam.md +195 -0
  45. package/llms.txt +89 -1
  46. package/package.json +1 -1
  47. package/skills/fullstackgtm/SKILL.md +17 -1
  48. package/src/calls.ts +24 -3
  49. package/src/cli.ts +809 -55
  50. package/src/connectors/hubspot.ts +55 -23
  51. package/src/connectors/outboxChannel.ts +202 -0
  52. package/src/connectors/prospectSources.ts +57 -0
  53. package/src/connectors/salesforce.ts +39 -14
  54. package/src/connectors/signalSources.ts +363 -0
  55. package/src/connectors/theirstack.ts +170 -0
  56. package/src/draft.ts +26 -5
  57. package/src/enrich.ts +51 -1
  58. package/src/health.ts +47 -2
  59. package/src/icp.ts +113 -11
  60. package/src/index.ts +42 -0
  61. package/src/init.ts +166 -0
  62. package/src/judge.ts +85 -11
  63. package/src/judgeEval.ts +8 -1
  64. package/src/runReport.ts +39 -0
  65. package/src/schedule.ts +20 -4
  66. package/src/signals.ts +95 -0
  67. package/src/tam.ts +654 -0
  68. package/src/types.ts +12 -0
@@ -0,0 +1,195 @@
1
+ # Recipes — composing the primitives into GTM plays
2
+
3
+ fullstackgtm ships **governed primitives**, not a workflow engine. The
4
+ orchestrator is **your coding agent**: it composes these verbs into the play the
5
+ operator wants, surfaces the one human approval gate, and bridges the last mile
6
+ to the sender. There is deliberately no `outbound` mega-command — that would
7
+ hard-code one play when the value is that you assemble the play you need.
8
+
9
+ Every verb emits stable `--json`, names the object type it operates on, and
10
+ surfaces the join it traversed (`accountId` / `domain` / `contactId`), so chains
11
+ compose without hand-gluing the contact↔account relationship. Every write goes
12
+ through the same `dry-run → plans approve → apply` gate. **The package never
13
+ sends** — `draft` produces an approved *task/opener*; the actual send happens in
14
+ the operator's channel tool (HeyReach for LinkedIn, an email tool for email),
15
+ which the agent drives with the operator's credentials.
16
+
17
+ The data spine: a **contact** belongs to an **account** (`contact.accountId`); an
18
+ account is keyed by its **domain**. `signals`/`icp judge` watch account domains;
19
+ `acquire` creates contacts *and* their domain-stamped accounts, so an acquired
20
+ lead is immediately watchable. Keep that join in mind when chaining.
21
+
22
+ ---
23
+
24
+ ## Recipe 1 — Cold start: fill the CRM with targeted, owned, emailed leads
25
+
26
+ **Goal:** go from nothing to a CRM seeded with on-ICP contacts (and their
27
+ signal-watchable accounts).
28
+
29
+ ```bash
30
+ # 1. Connect (secrets via stdin/env, never argv)
31
+ echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot
32
+ echo "$PIPE0_KEY" | fullstackgtm login pipe0 # work-email + company-domain resolution
33
+ echo "$EXPLORIUM_KEY" | fullstackgtm login explorium # net-new discovery (optional)
34
+
35
+ # 2. Build the ICP (the agent drives the interview — the CLI can't call AskUserQuestion itself)
36
+ fullstackgtm icp interview # emits INTERVIEW_SPEC; agent asks the questions
37
+ fullstackgtm icp set answers.json # writes ./icp.json
38
+ fullstackgtm icp show # renders the ICP + the per-provider discovery filters
39
+
40
+ # 3. Acquire — dry-run first (writes NOTHING), then approve + apply
41
+ fullstackgtm enrich acquire --source pipe0 --provider hubspot --json # scored, deduped, metered create_record plan
42
+ fullstackgtm enrich acquire --source pipe0 --provider hubspot --save # persist as needs_approval
43
+ fullstackgtm plans approve <plan-id> --operations all
44
+ fullstackgtm apply --plan-id <plan-id> --provider hubspot
45
+ ```
46
+
47
+ **What lands:** net-new contacts, owner-stamped (never born ownerless), each
48
+ linked to a **domain-stamped account** (so `signals`/`judge` can watch it). The
49
+ run is **metered** (records + spend, per profile) and **resolve-first** (never
50
+ creates over a possible dup). The dry-run op reason names the account it will
51
+ create/link — review it before `--save`.
52
+
53
+ ---
54
+
55
+ ## Recipe 2 — Trigger-based outbound loop (the core play)
56
+
57
+ **Goal:** reach an account the week something changes, with a grounded opener
58
+ that lands on a real contact.
59
+
60
+ ```bash
61
+ # 1. Detect — fresh buying triggers (free ATS boards in the box; ingest for funding/social)
62
+ fullstackgtm signals fetch --bucket job,funding --watchlist crm:<segment> --save
63
+
64
+ # 2. Judge — rank into send/nurture/skip. PASS THE SNAPSHOT so each decision
65
+ # resolves its CRM target (accountId + the contact[s] to reach).
66
+ fullstackgtm icp judge --signals-from latest --provider hubspot --save --json
67
+ # → each decision carries: accountDomain, accountId, contact {id,email,title},
68
+ # contacts[] (all in-CRM contacts at the account — for multi-threading)
69
+
70
+ # 3. Draft — one trigger-grounded opener per hot account, as a governed create_task
71
+ fullstackgtm draft --from-judge latest --channel email --save --json
72
+ # → the task targets the resolved contact.id (or accountId). A domain-only
73
+ # decision (account not in the CRM yet) is REJECTED with "acquire it first"
74
+ # — run Recipe 1 for that account, then re-judge with the snapshot.
75
+
76
+ # 4. Approve + apply (the ONE human gate)
77
+ fullstackgtm plans approve <plan-id> --operations all
78
+ fullstackgtm apply --plan-id <plan-id> --provider hubspot
79
+
80
+ # 5. Send — OUTSIDE the package. The agent pushes the approved opener to the
81
+ # operator's sender (e.g. HeyReach for LinkedIn, an email tool for email)
82
+ # using the operator's credentials. The package never sends.
83
+
84
+ # 6. Record the outcome — credit the contact you reached, so weights re-learn
85
+ fullstackgtm signals outcome --account <domain> --contact <contactId> --result replied
86
+ ```
87
+
88
+ **Why the snapshot in step 2 matters:** without it, decisions are domain-only and
89
+ `draft` cannot target a real record — it will reject them. With it, the loop is
90
+ contact-coherent end to end. `--with-history` additionally enables the memory
91
+ (don't re-touch a recently-touched account) and fit-scoring inputs.
92
+
93
+ ---
94
+
95
+ ## Recipe 3 — Continuous: schedule the detect/plan side, approve daily
96
+
97
+ **Goal:** run steps 1–3 of Recipe 2 on a cadence; keep the write gated.
98
+
99
+ ```bash
100
+ fullstackgtm schedule add "signals fetch --bucket job,funding --watchlist crm:<segment> --save" --cron "0 7 * * 1-5"
101
+ fullstackgtm schedule add "icp judge --signals-from latest --provider hubspot --save" --cron "15 7 * * 1-5"
102
+ fullstackgtm schedule add "draft --from-judge latest --save" --cron "30 7 * * 1-5"
103
+ ```
104
+
105
+ `schedule` is **read/plan-side only and NEVER auto-approves** — each morning a
106
+ queue of `needs_approval` plans is waiting; the agent presents them, the operator
107
+ approves, and `apply` runs (only `apply --plan-id <id>` is schedulable, and the
108
+ plan's `approved` status is re-checked at every firing).
109
+
110
+ ---
111
+
112
+ ## Recipe 4 — ABM: bring your target accounts, watch them, draft when they move
113
+
114
+ **Goal:** start from a list of target *companies* (not people).
115
+
116
+ ```bash
117
+ # 1. Acquire the ACCOUNTS (acquire is keyed by object type — company rows → account create_record)
118
+ fullstackgtm enrich ingest target-companies.csv --source clay --objects companies
119
+ fullstackgtm enrich acquire --source clay --provider hubspot --save # create.company → accounts WITH domains
120
+ fullstackgtm plans approve <id> --operations all && fullstackgtm apply --plan-id <id> --provider hubspot
121
+
122
+ # 2. From here it's Recipe 2 — the accounts now carry domains, so signals/judge watch them,
123
+ # and (optionally) Recipe 1's acquire fills in contacts at each.
124
+ ```
125
+
126
+ The `acquire.create.company` mapping (match key `domain`, properties →
127
+ `name`/`domain`) makes each company a **signal-watchable account**. See
128
+ [api.md → Acquire](./api.md).
129
+
130
+ ---
131
+
132
+ ## Recipe 5 — Hygiene-gate the outbound (don't outbound into a dirty CRM)
133
+
134
+ **Goal:** clean first, so signals/judge reason over correct ownership and
135
+ de-duplicated accounts; measure the lift.
136
+
137
+ ```bash
138
+ fullstackgtm health --json # per-object-type score + trend (read-only)
139
+ fullstackgtm audit --provider hubspot --save # → plan id
140
+ fullstackgtm dedupe account --key domain --save # collapse duplicate accounts (signals key on domain)
141
+ fullstackgtm reassign --assign-unowned --to <ownerId> --save # no ownerless leads
142
+ # approve + apply each, then run Recipe 2. Re-check `health` after to attribute the lift.
143
+ ```
144
+
145
+ `health --json.byObjectType` tells you *which* object type is messy (clean
146
+ contacts but a messy pipeline read differently). Clean the accounts before
147
+ outbound — `signals`/`judge` key on account domain, so duplicate/owner-less
148
+ accounts distort the queue.
149
+
150
+ ---
151
+
152
+ ## Recipe 6 — Size your TAM, then fill it on a schedule
153
+
154
+ **Goal:** turn the ICP into a defensible market size, then close the gap to it
155
+ over weeks — with a number for "how far along am I."
156
+
157
+ ```bash
158
+ # 1. Estimate the universe FROM the ICP. Either probe a provider for the
159
+ # ICP-match count, or pass --accounts from a known segment. Record a baseline.
160
+ fullstackgtm tam estimate --source explorium --provider hubspot --acv 180000
161
+ # → accounts: matching COMPANIES (Explorium /v1/businesses) = the account universe.
162
+ # → ACV: a real ANNUAL figure you confirm (--acv), or --acv-from-crm
163
+ # --deal-period monthly|quarterly|annual to derive+annualize from closed-won.
164
+ # (--provider is your coverage source, not your ACV — it won't auto-set it.)
165
+ # → buyers/account: CRM avg-contacts/account (or --buyers-per-account); labeled.
166
+ # → TAM model: accounts × ACV = $; contacts = accounts × buyers (population target)
167
+
168
+ # 2. Schedule the population — plan-only acquire, apply stays a human gate.
169
+ fullstackgtm tam populate --cron "0 7 * * 1-5" --source pipe0 --provider hubspot
170
+ fullstackgtm schedule install
171
+
172
+ # 3. Each cycle: approve the morning's lead plan (Recipe 1's tail), then apply.
173
+ fullstackgtm plans list && fullstackgtm plans approve <id> --operations all
174
+ fullstackgtm apply --plan-id <id> --provider hubspot
175
+
176
+ # 4. Track where you're getting to — stamp a coverage reading; once a couple
177
+ # accumulate, report projects a burn-up + ETA to full coverage.
178
+ fullstackgtm tam status --provider hubspot --save
179
+ fullstackgtm tam report > tam-report.md
180
+ ```
181
+
182
+ The estimate is honest about its source (provider count vs. assumption), the
183
+ population never auto-writes (the meter is charged only at apply), and the ETA is
184
+ refused rather than faked until the timeline shows real movement. See
185
+ [tam.md](./tam.md).
186
+
187
+ ---
188
+
189
+ ## The boundary, restated
190
+
191
+ - **Read freely. Write only through `plans approve → apply`.** Never bypass it.
192
+ - **The package never sends.** `draft` is the last governed step; the send is the
193
+ agent calling the operator's channel tool with the operator's credentials.
194
+ - **You (the agent) are the orchestrator.** These recipes are starting points —
195
+ compose, branch, and loop them into the play the operator actually wants.
@@ -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.