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
package/CHANGELOG.md CHANGED
@@ -7,6 +7,279 @@ The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md)
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.44.0] — 2026-07-01
11
+
12
+ ### Added
13
+
14
+ - **Paired CLI auto-links HubSpot to the hosted deployment.** When a broker
15
+ credential exists (a paired CLI), `audit --provider hubspot` hands the local
16
+ HubSpot private-app token to the deployment over the broker channel
17
+ (best-effort, with a printed notice), so the hosted Integrations page shows
18
+ HubSpot connected and syncing without a separate OAuth setup. Unpaired CLIs
19
+ are unaffected — pairing remains opt-in via `login --via`.
20
+
21
+ ### Added
22
+
23
+ - **`tam accounts` credit-cost preview + spend guard.** Pulling the list costs ~3
24
+ TheirStack credits/company, so `tam accounts` now prints the cost up front and
25
+ never spends by surprise: `--dry-run [--usd-per-credit <rate>]` prices the pull
26
+ AND the full TAM (e.g. "up to 100 ≈ 300 credits; full TAM ~43,517 ≈ 130,551
27
+ credits (~$5,483)") for **0 credits**, and a pull above `--max-credits` (default
28
+ 150 ≈ 50 companies) requires `--confirm`. The dollar figure appears only with an
29
+ explicit rate (no fabricated default; TheirStack is ~$0.04–$0.11/credit by tier).
30
+ `tam populate` also notes where spend lives — the acquire meter governs
31
+ population, separate from the TheirStack list pull. Library:
32
+ `theirStackPullCost` + `THEIRSTACK_CREDITS_PER_COMPANY`.
33
+
34
+ ### Changed
35
+
36
+ - **`tam status` coverage is now classified against the TAM ICP — not a raw
37
+ account count.** Previously it counted *every* domain-bearing CRM account over
38
+ the universe, so accounts loaded from any source (off-ICP or not) inflated
39
+ coverage and could read >100%. Now `tam estimate` stores the ICP filter
40
+ (`model.targeting`), and `status` classifies each account into **in-TAM /
41
+ out-of-TAM / unknown** (checked on size + industry; geo and "uses-CRM" aren't on
42
+ a `CanonicalAccount`, so they need re-enrichment — a `--reverify` pass is
43
+ planned, and the classifier labels what it checked). Only in-TAM counts toward
44
+ coverage; off-ICP junk is bucketed out. **Bottom-up vs top-down reconciliation:**
45
+ the in-TAM count is a floor on the real universe, so when it meets/exceeds the
46
+ estimate, `status` stops reporting a fake 100% and flips to "the estimate was a
47
+ floor — your real market is at least N (`reconciledUniverse`); re-estimate for
48
+ the headroom." ETA now burns on in-TAM accounts/day. New library:
49
+ `classifyAccount`, `classifyCoverage`, `crmCheckableCriteria`, `coveredAccounts`,
50
+ `TamTargeting`/`TamClassified` (and `TamModel.targeting`). Legacy models without
51
+ targeting fall back to counting all accounts, with a "re-estimate to classify"
52
+ note.
53
+
54
+ ### Added
55
+
56
+ - **`tam` technographic sourcing via TheirStack — target by CRM-usage, get a real
57
+ list.** The Explorium firmographic count (NAICS/size/geo) is a weak proxy for a
58
+ RevOps/CRM-hygiene tool and can't return the list (it 403s on pull). New
59
+ `--source theirstack` counts companies that actually **use** a CRM/MAP — set
60
+ `icp.firmographics.technologies` (e.g. `["salesforce","hubspot","pipedrive"]`),
61
+ mapped to TheirStack `company_technology_slug_or` + employee bounds + country —
62
+ which is the real buying signal, labeled `provider:theirstack (uses-CRM)`. And a
63
+ new **`tam accounts --source theirstack`** pulls the actual company list (real
64
+ names + domains, `--out <csv>` or `--json`, `--max` caps the credit spend), so the
65
+ TAM is a list you can review and acquire, not just a number. Counting is cheap
66
+ but not free (TheirStack rejects `limit:0`, so a count returns 1 row ≈ 3 credits;
67
+ a list pull is ~3 credits/company). New connector
68
+ `connectors/theirstack.ts` (`theirStackCountCompanies` / `theirStackSearchCompanies`),
69
+ `icpToTheirStackFilters` + `employeeBandsToRange`, and `login theirstack`.
70
+ (Verified live: filters + `metadata.total_results`; `limit` must be ≥ 1.)
71
+
72
+ ### Changed
73
+
74
+ - **`tam estimate` requires a confirmed ANNUAL ACV — no fabricated defaults, and
75
+ the CRM is not silently the ACV source.** A TAM dollar figure built on a guessed
76
+ ACV is a false signal, so the `--acv-band smb|mid|enterprise` presets (hardcoded
77
+ $6k/$24k/$90k) are removed and `estimate` refuses to run without a real ACV. Two
78
+ confirmed paths, always labeled on the model (`acv.source`): `--acv <annual-usd>`
79
+ (`"explicit (annual)"`), or `--acv-from-crm --deal-period monthly|quarterly|annual`
80
+ — the median closed-won deal amount **annualized** by the stated period
81
+ (`"crm:closed-won (N deals, median $X/monthly ×12 = annual)"`). The period is
82
+ **required**: a deal amount can be MRR/quarterly/annual and guessing it is a
83
+ 4–12× error (a $15k/mo deal is a $180k ACV). A bare `--provider` is the COVERAGE
84
+ source and **no longer auto-sets ACV**. **Buyers/account** likewise:
85
+ `--buyers-per-account <n>`, else the CRM's **average contacts per account**
86
+ (`"crm:avg-contacts/account (N)"`), else a labeled `"assumption:1-buyer"` — never
87
+ a silent default of 3. New library helpers `deriveAcvFromClosedWon` /
88
+ `deriveBuyersPerAccount`; `TamModel.acv.source` and `universe.buyersSource` are
89
+ new fields (and `acv.band` is gone).
90
+
91
+ ### Fixed
92
+
93
+ - **`icp eval --golden default` no longer rots with the calendar.** The default
94
+ golden set's `firstSeen` stamps are relative to a pinned instant
95
+ (`DEFAULT_GOLDEN_NOW_ISO`, exported), but grading used wall-clock `new Date()`
96
+ — so freshness decay silently degraded the "fresh → send" rows until the gate
97
+ started failing (~8 days after the set was authored) on every fresh install
98
+ and in CI, with no code change. The default set is now graded on its own
99
+ pinned clock; file-based golden sets still grade against wall time (their
100
+ timestamps are the caller's).
101
+
102
+ - **`enrich acquire` (pipe0/Crustdata) over-narrow ICP filter → zero discovery.**
103
+ Live testing surfaced that a RevOps ICP returned 0 prospects from pipe0/Crustdata
104
+ even though a title-only search returned plenty — the prime suspect is the
105
+ industry vocabulary: LinkedIn renamed its industry taxonomy (v1→v2) and the map
106
+ sent only one generation, so a vendor on the other generation matches nothing.
107
+ `CRUSTDATA_INDUSTRY` now sends BOTH generations per cluster (e.g. "Software
108
+ Development" *and* "Computer Software"), OR-matched within the field. (pipe0
109
+ credits were exhausted mid-investigation, so this is a reasoned fix not yet
110
+ re-confirmed end-to-end — re-run `enrich acquire --source pipe0` once credits
111
+ refill; if still zero, the next suspects are the `current_seniority_levels`
112
+ shape and `locations`. Note `scoreProspectAgainstIcp` backstops persona but NOT
113
+ industry, so the industry filter is load-bearing.)
114
+ - **`enrich acquire` now surfaces a zero-discovery result** instead of emitting a
115
+ silent empty plan: when a provider returns 0 prospects it warns that this is
116
+ usually an over-narrow filter (not an empty market) and points at `icp show`;
117
+ and when all discovered prospects score below the fit threshold it says so.
118
+
119
+ ### Added
120
+
121
+ - **`tam` — Total Addressable Market mapping.** Size the reachable market FROM
122
+ the ICP, then iteratively fill it and track coverage to an ETA. `tam estimate`
123
+ computes a transparent model (account universe × ACV = TAM, account/per-logo
124
+ basis by default or `--acv-basis buyer`; contacts = accounts × buyers/account
125
+ is the population target). The account count is `--accounts <n>` (assumption)
126
+ or `--source explorium` — a live count of matching **companies** via Explorium
127
+ `/v1/businesses`, which is the account universe directly. (Verified against the
128
+ live APIs: people/lead endpoints can't size a market — Explorium `/v1/prospects`
129
+ `total_results` equals the page size and pipe0/Crustdata returns only a cursor —
130
+ so `--source pipe0` for estimate is rejected; pipe0 remains a discovery/
131
+ population source. The businesses count omits `size`, which otherwise caps the
132
+ total.) Explorium caps the count at 60,000; a saturated reading is surfaced as a
133
+ floor (`provider:explorium (≥60k cap — floor)` + a warning to narrow the ICP).
134
+ The model always labels `accountsSource` provider-vs-assumption. Optional citable
135
+ cross-checks sit beside the bottom-up number. `tam populate --cron` schedules
136
+ governed `enrich acquire --save` (plan-only; see below). `tam status --save`
137
+ stamps a coverage timeline (CRM accounts-with-domain + contacts vs. the
138
+ universe, `$ covered` proportional, what the campaign added since baseline);
139
+ `tam report` renders a markdown burn-up. `status`/`report` project a linear
140
+ accounts/day ETA and refuse to project (honest "not enough history") below two
141
+ readings or a flat rate. Coverage + ETA are deterministic (no LLM). Library:
142
+ `estimateTam`, `computeCoverage`, `projectEta`, `tamReportToMarkdown`, the
143
+ store, and the count probe (`probeExploriumBusinessCount` +
144
+ `EXPLORIUM_BUSINESS_COUNT_CAP`). See [docs/tam.md](./docs/tam.md).
145
+ - **Source connectors for `signals` (Phase 1).** `signals fetch` gains an
146
+ additive, opt-in `--connector <id>[,<id>] [--connector-opt k=v …]` that pulls
147
+ signals from connected platforms instead of only ATS boards and hand-staged
148
+ `--from` files. Three connectors ship: `file` (local JSON/JSONL — also the
149
+ webhook landing-zone spool, no auth), `serpapi-news` (funding/company news via
150
+ a news REST API, key through the credential ladder), and `hubspot-forms`
151
+ (first-party `demand` from recent form submissions, reusing the existing
152
+ HubSpot login). Secrets resolve through the env → login → broker ladder, never
153
+ argv; `--connector-opt` carries non-secret knobs only. **Default behavior is
154
+ unchanged** when no `--connector` is passed. Connectors are read-only re: the
155
+ CRM and run through the same evidence-gate/dedup/weight pipeline as `--from`
156
+ (the `readStagedSignals` row validator is now the shared `stagedRowToSignal`).
157
+ Library: `connectors/signalSources.ts` (`listSignalSources`, `getSignalSource`,
158
+ `fileSource`, `serpapiNewsSource`, `hubspotFormsSource`), `stagedRowToSignal` +
159
+ `StagedSignalRow` from `signals.ts`. Design:
160
+ [docs/spec-connectors-signals-outbound.md](../../docs/spec-connectors-signals-outbound.md)
161
+ (the connector taxonomy + the Phase 2/3 webhook-spool and governed-channel plan).
162
+ - **Webhook spool format + conventional landing zone (signals Phase 2).**
163
+ `--connector file` with no path now reads the conventional spool directory
164
+ (`<profile home>/signals/spool`, via `signalsSpoolDir`) — every `*.jsonl` in
165
+ it, name-sorted, so a webhook receiver can append one row per event (per
166
+ source: `rb2b.jsonl`, `hubspot.jsonl`, …) and they all land in one fetch. The
167
+ `file` connector reads a directory as well as a single file. New
168
+ [docs/signal-spool-format.md](./docs/signal-spool-format.md) documents the row
169
+ format, the landing zone, re-read/retention semantics, and per-platform
170
+ payload mappings (RB2B, Trigify, HubSpot form webhook). Per the open-core
171
+ boundary the format + reader are open; the always-on receiver is a hosted
172
+ concern — the package ships no receiver. Also fixes a latent filter bug:
173
+ explicitly-provided rows (`--from`, connectors) whose bucket has no configured
174
+ `sources` (notably `demand`) were silently dropped unless `--bucket` was given;
175
+ now only an explicit `--bucket` narrows them, so spool/`hubspot-forms` `demand`
176
+ signals flow by default.
177
+ - **Outbox channel — governed send terminus (signals Phase 3).** `apply
178
+ --channel outbox` renders each APPROVED drafted opener to a local outbox
179
+ (`<profile home>/signals/outbox/<channel>.jsonl`, via `signalsOutboxDir`) for a
180
+ downstream sender to drain — and **transmits nothing**, preserving the "drafts
181
+ everything, transmits nothing" invariant (the send-side mirror of the spool).
182
+ The outbox channel is a `GtmConnector` whose `applyOperation` renders instead
183
+ of writing a CRM, so it reuses the entire governed apply path (approval set,
184
+ integrity verification, idempotency, run recording) with no change to the apply
185
+ engine; `apply` now takes `--provider <crm>` OR `--channel <id>`. It only
186
+ renders `draft:`-policy `create_task` ops (any other op is skipped) and is
187
+ idempotent on the operation id. New
188
+ [docs/outbox-format.md](./docs/outbox-format.md). Per the open-core boundary the
189
+ governed artifact + format are open; the always-on sender that actually
190
+ transmits (ESP/LinkedIn) is a hosted concern — no sender ships in the package.
191
+ Library: `connectors/outboxChannel.ts` (`createOutboxChannelConnector`,
192
+ `createChannelConnector`, `listOutbox`, `OutboxEntry`), `signalsOutboxDir`.
193
+
194
+ ### Changed
195
+
196
+ - **`enrich acquire --save` is now schedulable** (for `tam populate`). It joins
197
+ the read/plan-side schedule allowlist in its plan-producing form ONLY — a
198
+ scheduled `enrich acquire` must include `--save` (rejected otherwise), so each
199
+ firing queues a `needs_approval` lead plan and writes nothing. The acquire
200
+ meter is still charged only at apply, and apply stays a separate human gate
201
+ (`apply --plan-id`, re-checked approved). Scheduling never auto-approves.
202
+
203
+ ## [0.43.0] — 2026-06-25
204
+
205
+ ### Added
206
+
207
+ - **`init` — scaffold a GTM workspace from cold scratch.** `fullstackgtm init
208
+ [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce]` writes
209
+ three files so the first acquire/signals/judge/draft commands work: a valid
210
+ starter `icp.json`, an acquire-ready `enrich.config.json` (the source preset
211
+ plus an explicit `acquire.assign` seam, placeholder owner id, so leads are
212
+ never silently ownerless), and a `PLAYBOOK.md` wired with the cold-start and
213
+ outbound-loop recipes for this workspace's source/provider/profile. Pure
214
+ file-writer: no network, keeps existing files unless `--force`. Library:
215
+ `scaffoldWorkspace`, `starterIcp`, `starterEnrichConfig`, `starterPlaybook`.
216
+ - **`docs/recipes.md` — five composable GTM plays.** Documents how the governed
217
+ primitives compose into real plays (cold-start lead-fill, the
218
+ trigger→judge→draft outbound loop, scheduled-continuous, ABM-from-companies,
219
+ hygiene-gated outbound) — making explicit that the CLI ships primitives, the
220
+ coding agent is the orchestrator (no `outbound` mega-verb), and the package
221
+ never sends. Surfaced from the agent skill and `llms.txt`, which also now
222
+ carry the previously-undocumented GTM-brain layer (`signals`/`icp`/`judge`/
223
+ `draft`) and the `init`/`enrich acquire` account-stamping behavior.
224
+ - **Account-level acquire for ABM (seam I).** `enrich acquire` creates net-new
225
+ **accounts**, not just contacts: configure `acquire.create.company` (match key
226
+ `domain`) and feed company rows (`enrich ingest companies.csv --objects
227
+ companies`). Each unmatched company becomes a `create_record` of
228
+ `objectType: account` with its domain stamped — so the acquired account is
229
+ immediately signal-watchable. (The spine already routed by object type; this
230
+ validates + documents the account path so it's discoverable.)
231
+ - **`health` breaks down by object type.** `HealthEntry.byObjectType` reports a
232
+ separate record-normalized score (same 100/(1+weighted-per-record) curve) plus
233
+ finding/record counts for `account`, `contact`, and `deal` — so "is my contact
234
+ data clean but my pipeline messy?" is answerable from one audit, and `health`
235
+ speaks each object type instead of a single aggregate. Rendered as a "By object
236
+ type" table in `healthToMarkdown`.
237
+ - **Outbound is contact-granular end to end (seam D).** `icp judge` now surfaces
238
+ `contacts` — all in-CRM contacts at the hot account (primary first, capped) —
239
+ alongside the single `contact`, so an agent can multi-thread instead of being
240
+ limited to one person. `signals outcome --contact <id>` records which contact a
241
+ touch reached (`SignalOutcome.contactId`), so the feedback loop credits the
242
+ person, not just the account domain.
243
+
244
+ ### Changed
245
+
246
+ - **`call link` says HOW it matched (seam H); the findings split is documented
247
+ (seam G).** `CallDealSuggestion` now carries `resolvedVia`
248
+ (`account_domain` | `contact_email` | `both`) and `matchedContacts`, so an
249
+ agent can weight a deal match by whether it came from the company-wide domain
250
+ or a single (possibly stale) attendee email — and the contact↔account hop is
251
+ visible, not just the resolved account. Separately, `PatchPlan.findings` (the
252
+ complete, object-typed list) and `pipelineFindings` (the deliberately
253
+ deal/sales-pipeline subset) are now documented so account/contact findings
254
+ aren't mistaken for "dropped" — they live in `findings`.
255
+ - **`enrich acquire` now gives every lead a signal-watchable account (contact↔
256
+ account seam).** Previously the presets wrote the company as a text field only,
257
+ so an acquired lead had no account record — invisible to `signals`/`icp judge`,
258
+ which key on account domain. Now acquire resolves-or-creates the lead's account
259
+ **by domain**: `AcquireCreateMap.associateCompanyDomainFrom` (preset:
260
+ `companyDomain`) threads the resolved domain — or the work-email's domain as a
261
+ free fallback — onto `CreateRecordPayload.associateCompanyDomain`. The HubSpot
262
+ and Salesforce connectors then match the account by domain first (the accurate
263
+ key), create it **with** the domain (`domain` / `Website`), and fill the domain
264
+ on a name-matched account that lacks one (fill-blank, never clobber). The dry-
265
+ run op reason now names the account + domain instead of resolving it silently.
266
+
267
+ ### Fixed
268
+
269
+ - **Outbound now writes against a real record, not a domain (contact↔account
270
+ seam).** `draft` previously emitted a `create_task` op hardcoded to
271
+ `objectType: "contact"` while carrying the account **domain** as `objectId` —
272
+ an incoherent operation the apply layer had to re-interpret. The bridge is now
273
+ explicit: `icp judge` resolves each decision's CRM target and surfaces it on
274
+ `JudgeDecision` — `accountId` plus the best `contact` (`id`/`email`/`title`) at
275
+ the account — whenever a snapshot source is given (`--provider`/`--input`/etc;
276
+ `--with-history` still gates the memory + fit scoring inputs, so scores are
277
+ unchanged). `draft` writes the task against `contact.id` (who to message), or
278
+ the `accountId` when no contact resolves, and **rejects a domain-only decision**
279
+ ("acquire it first") instead of forging a contact-typed op with a domain. The
280
+ object-type coherence invariant — every op's `objectId` is a real id of its
281
+ declared type — now holds for the outbound path.
282
+
10
283
  ## [0.42.0] — 2026-06-25
11
284
 
12
285
  ### Added
package/README.md CHANGED
@@ -38,13 +38,17 @@ It installs a compact operating guide ([skills/fullstackgtm/SKILL.md](./skills/f
38
38
  ## Five-minute loop
39
39
 
40
40
  ```bash
41
- # 0. No credentials? Try it on a realistic, deliberately messy demo CRM
41
+ # 0. Scaffold a workspace: a starter icp.json, an enrich.config.json acquire
42
+ # preset, and a PLAYBOOK.md wired for your provider + discovery source
43
+ npx fullstackgtm init --provider hubspot --source pipe0
44
+
45
+ # 1. No credentials? Try it on a realistic, deliberately messy demo CRM
42
46
  npx fullstackgtm audit --demo
43
47
 
44
- # 1. Audit your real HubSpot portal (private app token or OAuth access token)
48
+ # 2. Audit your real HubSpot portal (private app token or OAuth access token)
45
49
  HUBSPOT_ACCESS_TOKEN=pat-... npx fullstackgtm audit --provider hubspot --out plan.json
46
50
 
47
- # 2. Review plan.json, then apply ONLY the operations you approve
51
+ # 3. Review plan.json, then apply ONLY the operations you approve
48
52
  HUBSPOT_ACCESS_TOKEN=pat-... npx fullstackgtm apply \
49
53
  --plan plan.json --provider hubspot \
50
54
  --approve op_abc123,op_def456 \
@@ -53,6 +57,8 @@ HUBSPOT_ACCESS_TOKEN=pat-... npx fullstackgtm apply \
53
57
 
54
58
  Nothing is ever written without an explicit `--approve`. Operations whose value is a human decision (`requires_human_*` placeholders, e.g. which owner to assign) are refused unless you supply a concrete `--value` override.
55
59
 
60
+ `init` keeps any files you already have (`--force` overwrites) and works with `--source pipe0|explorium|linkedin` and `--provider hubspot|salesforce`. The PLAYBOOK.md it writes wires the cold-start and outbound-loop recipes to your workspace; the full play set — cold start, trigger-based outbound, scheduling, ABM, hygiene-gating, TAM — is [docs/recipes.md](./docs/recipes.md).
61
+
56
62
  ## Call workflows: calls become governed evidence
57
63
 
58
64
  Calls are where pipeline truth lives. `call parse` normalizes any transcript dialect — `Speaker: text` lines (Fathom, Gong exports), `[Speaker]:` labels, or raw Granola utterance JSON — into canonical segments, insights, and `GtmEvidence` records.
@@ -211,8 +217,11 @@ LinkedIn is just another discovery source on the same scored → deduped → met
211
217
  Cleaning and filling the CRM tells you *who* to reach; it never tells you *when*. The **signal → judge → draft** loop adds timing — and, like everything else, it stays on the dry-run → approve → apply spine and sends nothing.
212
218
 
213
219
  ```bash
214
- # 1. Watch for movement. Free, no-auth public job boards in the box; funding/company/social via staged ingest.
220
+ # 1. Watch for movement. Free, no-auth public job boards in the box; pull from
221
+ # connected platforms via source connectors; webhook platforms via the spool.
215
222
  fullstackgtm signals fetch --bucket job --source greenhouse,lever,ashby --keywords "revops,growth" --save
223
+ fullstackgtm signals fetch --connector serpapi-news,hubspot-forms --save # news + first-party form demand
224
+ fullstackgtm signals fetch --connector file --save # webhook landing zone (see docs/signal-spool-format.md)
216
225
  fullstackgtm signals list --since 7d # ranked triggers, each with a verbatim source quote
217
226
 
218
227
  # 2. Decide who's worth a touch — and who isn't. Scores timing × fit × memory into send/nurture/skip.
@@ -220,8 +229,10 @@ fullstackgtm icp judge --signals-from latest --with-history --save
220
229
  fullstackgtm icp eval --golden default # gate: prove the judge is calibrated before any send (exits 2 if not)
221
230
 
222
231
  # 3. Draft the opener from the trigger. A create_task plan — proposed, never transmitted.
223
- fullstackgtm draft --from-judge latest --min-score 80 --save
224
- fullstackgtm plans approve <id> --operations all && fullstackgtm apply --plan-id <id> --provider hubspot
232
+ fullstackgtm draft --from-judge latest --min-score 80 --channel email --save
233
+ fullstackgtm plans approve <id> --operations all
234
+ fullstackgtm apply --plan-id <id> --provider hubspot # log the touch as a CRM task
235
+ fullstackgtm apply --plan-id <id> --channel outbox # OR render to the outbox for a sender — transmits nothing (docs/outbox-format.md)
225
236
 
226
237
  # 4. Close the loop. Outcomes re-weight which signals earn a touch.
227
238
  fullstackgtm signals outcome --account acme.com --result replied
@@ -289,7 +300,7 @@ fullstackgtm diff --before old.json --after new.json --fail-on-new-findings
289
300
  - `--demo` (with `--seed`) generates a realistic mid-market CRM with injected real-world failure modes — departed owners, unlinked deals, orphan accounts, stale pipeline — so agents and CI can exercise the full snapshot → audit → apply pipeline with zero credentials.
290
301
  - Exit codes: `0` success, `1` error, `2` findings at/above `--fail-on`.
291
302
 
292
- "Built for agents" is measured, not asserted: a 1,088-run benchmark (17 scenarios = 14 synthetic + 3 seeded from an anonymized real portal, × 3 tool-surface arms × 4 trials, across six models from three vendors, deterministic graders over final CRM state, τ-bench-style pass^k) shows the gated CLI surface beating raw CRM-API access on completion-under-policy for every model tested — and the tool-surface effect is monotonic and vendor-independent. Full matrix and methodology: [the leaderboard](./evals/crm/leaderboard/RESULTS.md).
303
+ "Built for agents" is measured, not asserted: a 1,892-run benchmark (20 scenarios = 17 synthetic + 3 seeded from an anonymized real portal, × 3 tool-surface arms × up to 4 trials, across nine models from six vendors, deterministic graders over final CRM state, τ-bench-style pass^k) shows the gated CLI surface beating raw CRM-API access on completion-under-policy for every model tested — and the tool-surface effect is monotonic and vendor-independent. Full matrix and methodology: [the leaderboard](./evals/crm/leaderboard/RESULTS.md).
293
304
 
294
305
  The design is **deterministic apply, governed suggest**: the parts that touch your CRM — the audit rules, the plan/apply contract, compare-and-set, the survivor/merge logic — are deterministic and replayable; the parts that read free text (`call parse`/`score`, `market classify`) are LLM-powered but bounded, with every quoted span mechanically verified against the source before it can drive a writeback. Nondeterministic suggestion, deterministic governance.
295
306
 
package/dist/calls.d.ts CHANGED
@@ -63,6 +63,19 @@ export type CallDealSuggestion = {
63
63
  dealName?: string;
64
64
  accountId?: string;
65
65
  accountName?: string;
66
+ /**
67
+ * HOW the deal's account was matched — `account_domain` (the company-wide,
68
+ * reliable signal) vs `contact_email` (a specific person, possibly stale) vs
69
+ * `both`. An agent needs this to weight the match; the bare confidence hides it.
70
+ */
71
+ resolvedVia?: "account_domain" | "contact_email" | "both";
72
+ /** The attendee contacts that matched (the email path), so the contact↔account
73
+ * hop is visible, not just the resolved account. */
74
+ matchedContacts?: {
75
+ id: string;
76
+ email?: string;
77
+ accountId?: string;
78
+ }[];
66
79
  confidence: "high" | "low" | "none";
67
80
  reason: string;
68
81
  };
package/dist/calls.js CHANGED
@@ -295,18 +295,27 @@ export function suggestCallDeal(snapshot, options) {
295
295
  return { dealId: null, confidence: "none", reason: "No attendee emails or domain supplied to match on." };
296
296
  }
297
297
  const accountIds = new Set();
298
+ let viaDomain = false;
299
+ let viaEmail = false;
300
+ const matchedContacts = [];
298
301
  for (const account of snapshot.accounts) {
299
302
  const domain = account.domain?.trim().toLowerCase().replace(/^www\./, "");
300
- if (domain && domains.has(domain))
303
+ if (domain && domains.has(domain)) {
301
304
  accountIds.add(account.id);
305
+ viaDomain = true;
306
+ }
302
307
  }
303
308
  for (const contact of snapshot.contacts) {
304
309
  const email = contact.email?.trim().toLowerCase();
305
310
  const at = email ? email.indexOf("@") : -1;
306
311
  if (email && at > 0 && domains.has(email.slice(at + 1)) && contact.accountId) {
307
312
  accountIds.add(contact.accountId);
313
+ viaEmail = true;
314
+ matchedContacts.push({ id: contact.id, ...(contact.email ? { email: contact.email } : {}), accountId: contact.accountId });
308
315
  }
309
316
  }
317
+ const resolvedVia = viaDomain && viaEmail ? "both" : viaDomain ? "account_domain" : "contact_email";
318
+ const via = { resolvedVia, ...(matchedContacts.length ? { matchedContacts } : {}) };
310
319
  if (accountIds.size === 0) {
311
320
  return {
312
321
  dealId: null,
@@ -335,8 +344,9 @@ export function suggestCallDeal(snapshot, options) {
335
344
  dealName: top.name,
336
345
  accountId: top.accountId,
337
346
  accountName: account?.name,
347
+ ...via,
338
348
  confidence: "high",
339
- reason: `"${top.name}" is the only open deal on matched account "${account?.name ?? top.accountId}".`,
349
+ reason: `"${top.name}" is the only open deal on matched account "${account?.name ?? top.accountId}" (via ${resolvedVia}).`,
340
350
  };
341
351
  }
342
352
  return {
@@ -344,8 +354,9 @@ export function suggestCallDeal(snapshot, options) {
344
354
  dealName: top.name,
345
355
  accountId: top.accountId,
346
356
  accountName: account?.name,
357
+ ...via,
347
358
  confidence: "low",
348
- reason: `${openDeals.length} open deals on matched account(s); "${top.name}" has the most recent activity. Confirm before writing.`,
359
+ reason: `${openDeals.length} open deals on matched account(s); "${top.name}" has the most recent activity (matched via ${resolvedVia}). Confirm before writing.`,
349
360
  };
350
361
  }
351
362
  function callHash(value) {