fullstackgtm 0.42.0 → 0.43.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.
@@ -0,0 +1,158 @@
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
+ ## The boundary, restated
153
+
154
+ - **Read freely. Write only through `plans approve → apply`.** Never bypass it.
155
+ - **The package never sends.** `draft` is the last governed step; the send is the
156
+ agent calling the operator's channel tool with the operator's credentials.
157
+ - **You (the agent) are the orchestrator.** These recipes are starting points —
158
+ compose, branch, and loop them into the play the operator actually wants.
package/llms.txt CHANGED
@@ -15,6 +15,7 @@ at/above `--fail-on`.
15
15
  - [README](https://github.com/fullstackgtm/core/blob/main/README.md): install, five-minute loop, auth ladder, MCP setup, programmatic use
16
16
  - [INSTALL_FOR_AGENTS](https://github.com/fullstackgtm/core/blob/main/INSTALL_FOR_AGENTS.md): deterministic install-and-verify steps with expected outputs
17
17
  - [Agent skill](https://github.com/fullstackgtm/core/blob/main/skills/fullstackgtm/SKILL.md): compact operating guide, installable via `npx skills add fullstackgtm/core`
18
+ - [Recipes](https://github.com/fullstackgtm/core/blob/main/packages/fullstackgtm/docs/recipes.md): five composable GTM plays over the primitives (cold-start lead-fill, the trigger→judge→draft outbound loop, scheduled-continuous, ABM-from-companies, hygiene-gated outbound) — the CLI ships primitives, your agent is the orchestrator, the package never sends
18
19
  - [Architecture](https://github.com/fullstackgtm/core/blob/main/docs/architecture.md): module map + snapshot → audit → plan → apply data flow; "where do I add a rule/connector/operation"
19
20
  - [Contributing](https://github.com/fullstackgtm/core/blob/main/CONTRIBUTING.md): dev setup, the open-core mirror model, the release ritual
20
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
@@ -117,6 +118,30 @@ injectable `LinkedInProvider` (default HeyReach; `FakeLinkedInProvider` for test
117
118
  LinkedIn is read-only — Phase 1 has no `linkedin_connect`/`linkedin_message` ops
118
119
  and no webhook ingestion (Phase 2). Never auto-writes.
119
120
 
121
+ ## Key invariants (GTM brain — signals / icp / judge / draft)
122
+
123
+ The timing/outbound layer turns "who changed" into one grounded, governed
124
+ opener — and **never sends**. `signals fetch` captures fresh buying triggers
125
+ 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`
128
+ builds `icp.json`; `icp judge` ranks fresh signals into send/nurture/skip — pass
129
+ a snapshot (`--provider`/`--input`/`--demo`) and each decision resolves its CRM
130
+ target: `accountId`, the best `contact {id,email,title}`, and `contacts[]` (all
131
+ in-CRM contacts at the account, titled-first, cap 10, for multi-threading);
132
+ `--with-history` additionally gates memory (don't re-touch a recently-touched
133
+ account) and fit-scoring. `icp eval` is the calibration gate (exit 2 below the
134
+ bar). `draft` emits ONE trigger-grounded opener per hot account as a governed
135
+ `create_task` targeting the resolved `contact.id` (or `accountId`) — a
136
+ domain-only decision (account not yet in the CRM) is REJECTED with "acquire it
137
+ 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>
140
+ --contact <id> --result replied|...` credits the contact reached so weights
141
+ re-learn. The contact↔account join (`contact.accountId` + `account.domain`) is
142
+ surfaced at every hop, so the loop is contact-coherent end to end — see the
143
+ [recipes](https://github.com/fullstackgtm/core/blob/main/packages/fullstackgtm/docs/recipes.md).
144
+
120
145
  ## Key invariants (schedule)
121
146
 
122
147
  `fullstackgtm schedule` is the horizontal scheduler — no feature namespace
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.42.0",
3
+ "version": "0.43.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)",
@@ -62,7 +62,10 @@ credentials AND stored plans per client org.
62
62
  | `fix --rule <id>` | audit one rule → suggest → approve at the confidence bar → apply only with `--yes` |
63
63
  | `call parse\|score\|link\|plan` | Transcripts → evidence-quoted insights, rubric scorecards, deal linking, governed next-step writes |
64
64
  | `enrich append\|refresh\|ingest\|status` | Governed enrichment (Apollo pull / Clay ingest), fill-blanks-only plans |
65
- | `enrich acquire [--source explorium\|pipe0\|linkedin]` | Net-new ICP-targeted lead gen: resolve-first deduped `create_record` plans, capped by a per-profile windowed meter (records + spend); owner-stamped via `acquire.assign`/`--assign-owner` (never born ownerless); `--source linkedin --list <id>` reads a HeyReach lead list (Phase 1 discovery, read-only — never sends); never auto-writes |
65
+ | `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 |
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 |
66
69
  | `market init\|capture\|classify\|worksheet\|observe\|fronts\|axes\|overlay\|scale\|report\|refresh` | Competitive category map; evidence quotes verified verbatim against stored captures |
67
70
  | `schedule add\|list\|remove\|enable\|disable\|run\|install\|uninstall\|status` | Horizontal cron; read/plan-side allowlist only — scheduling NEVER auto-approves |
68
71
  | `report` | Client-ready audit deliverable (markdown or self-contained HTML) |
@@ -84,8 +87,20 @@ Tools over stdio: `fullstackgtm_audit` (read-only), `fullstackgtm_rules`,
84
87
  `fullstackgtm_resolve`, `fullstackgtm_market_worksheet`,
85
88
  `fullstackgtm_market_observe`.
86
89
 
90
+ ## Composing the primitives into plays
91
+
92
+ This CLI ships governed **primitives** — there is no `outbound` mega-command.
93
+ **You (the agent) are the orchestrator:** chain these verbs into the play the
94
+ operator wants, surface the one approve gate, and bridge the last mile to the
95
+ sender (**the package never sends**). [docs/recipes.md](https://github.com/fullstackgtm/core/blob/main/packages/fullstackgtm/docs/recipes.md)
96
+ has five worked plays — cold-start lead-fill, the trigger→judge→draft outbound
97
+ loop, scheduled-continuous, ABM-from-companies, and hygiene-gated outbound.
98
+ `fullstackgtm init` scaffolds a workspace (icp.json + enrich config + a PLAYBOOK
99
+ pointing at those recipes) to start from.
100
+
87
101
  ## Going deeper
88
102
 
103
+ - [docs/recipes.md](https://github.com/fullstackgtm/core/blob/main/packages/fullstackgtm/docs/recipes.md) — five composable GTM plays over the primitives (cold-start, outbound loop, scheduled, ABM, hygiene-gated)
89
104
  - [llms.txt](https://github.com/fullstackgtm/core/blob/main/llms.txt) — the full invariant map per layer (calls, market, write verbs, enrich, schedule, engagement/health)
90
105
  - [INSTALL_FOR_AGENTS.md](https://github.com/fullstackgtm/core/blob/main/INSTALL_FOR_AGENTS.md) — deterministic install-and-verify with expected outputs
91
106
  - [docs/api.md](https://github.com/fullstackgtm/core/blob/main/docs/api.md) — semver-covered surfaces: canonical model, rule interface, plan/apply contract, connectors, config, CLI, MCP
package/src/calls.ts CHANGED
@@ -369,6 +369,15 @@ export type CallDealSuggestion = {
369
369
  dealName?: string;
370
370
  accountId?: string;
371
371
  accountName?: string;
372
+ /**
373
+ * HOW the deal's account was matched — `account_domain` (the company-wide,
374
+ * reliable signal) vs `contact_email` (a specific person, possibly stale) vs
375
+ * `both`. An agent needs this to weight the match; the bare confidence hides it.
376
+ */
377
+ resolvedVia?: "account_domain" | "contact_email" | "both";
378
+ /** The attendee contacts that matched (the email path), so the contact↔account
379
+ * hop is visible, not just the resolved account. */
380
+ matchedContacts?: { id: string; email?: string; accountId?: string }[];
372
381
  confidence: "high" | "low" | "none";
373
382
  reason: string;
374
383
  };
@@ -393,17 +402,27 @@ export function suggestCallDeal(
393
402
  }
394
403
 
395
404
  const accountIds = new Set<string>();
405
+ let viaDomain = false;
406
+ let viaEmail = false;
407
+ const matchedContacts: { id: string; email?: string; accountId?: string }[] = [];
396
408
  for (const account of snapshot.accounts) {
397
409
  const domain = account.domain?.trim().toLowerCase().replace(/^www\./, "");
398
- if (domain && domains.has(domain)) accountIds.add(account.id);
410
+ if (domain && domains.has(domain)) {
411
+ accountIds.add(account.id);
412
+ viaDomain = true;
413
+ }
399
414
  }
400
415
  for (const contact of snapshot.contacts) {
401
416
  const email = contact.email?.trim().toLowerCase();
402
417
  const at = email ? email.indexOf("@") : -1;
403
418
  if (email && at > 0 && domains.has(email.slice(at + 1)) && contact.accountId) {
404
419
  accountIds.add(contact.accountId);
420
+ viaEmail = true;
421
+ matchedContacts.push({ id: contact.id, ...(contact.email ? { email: contact.email } : {}), accountId: contact.accountId });
405
422
  }
406
423
  }
424
+ const resolvedVia = viaDomain && viaEmail ? "both" : viaDomain ? "account_domain" : "contact_email";
425
+ const via = { resolvedVia, ...(matchedContacts.length ? { matchedContacts } : {}) } as const;
407
426
  if (accountIds.size === 0) {
408
427
  return {
409
428
  dealId: null,
@@ -432,8 +451,9 @@ export function suggestCallDeal(
432
451
  dealName: top.name,
433
452
  accountId: top.accountId,
434
453
  accountName: account?.name,
454
+ ...via,
435
455
  confidence: "high",
436
- reason: `"${top.name}" is the only open deal on matched account "${account?.name ?? top.accountId}".`,
456
+ reason: `"${top.name}" is the only open deal on matched account "${account?.name ?? top.accountId}" (via ${resolvedVia}).`,
437
457
  };
438
458
  }
439
459
  return {
@@ -441,8 +461,9 @@ export function suggestCallDeal(
441
461
  dealName: top.name,
442
462
  accountId: top.accountId,
443
463
  accountName: account?.name,
464
+ ...via,
444
465
  confidence: "low",
445
- reason: `${openDeals.length} open deals on matched account(s); "${top.name}" has the most recent activity. Confirm before writing.`,
466
+ reason: `${openDeals.length} open deals on matched account(s); "${top.name}" has the most recent activity (matched via ${resolvedVia}). Confirm before writing.`,
446
467
  };
447
468
  }
448
469
 
package/src/cli.ts CHANGED
@@ -125,6 +125,11 @@ import {
125
125
  remaining,
126
126
  type AcquireRemaining,
127
127
  } from "./acquireMeter.ts";
128
+ import {
129
+ scaffoldWorkspace,
130
+ type InitProvider,
131
+ type InitSource,
132
+ } from "./init.ts";
128
133
  import {
129
134
  crmContactKeys,
130
135
  fetchExploriumProspects,
@@ -246,6 +251,10 @@ Usage:
246
251
  the process list and shell history. Pipe them on stdin or enter them at the
247
252
  interactive prompt:
248
253
  echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot
254
+ fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
255
+ cold start: scaffold icp.json + enrich.config.json + a
256
+ PLAYBOOK wired for this workspace (the CLI ships primitives,
257
+ your agent is the orchestrator — see docs/recipes.md)
249
258
  fullstackgtm snapshot [source options] [--since <iso>] [--out <path> | --archive <dir>]
250
259
  fullstackgtm audit [source options] [audit options] [--save]
251
260
  fullstackgtm report [source options] [audit options] [report options]
@@ -466,6 +475,17 @@ type HelpEntry = {
466
475
  };
467
476
 
468
477
  const HELP: Record<string, HelpEntry> = {
478
+ // Get started
479
+ init: {
480
+ summary: "scaffold a workspace (icp.json + enrich.config.json + PLAYBOOK)",
481
+ phase: "Setup",
482
+ synopsis: [
483
+ "fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]",
484
+ ],
485
+ detail:
486
+ "Cold start: writes a starter ICP, an acquire-ready enrich.config.json (with a visible assign seam so leads are never ownerless), and a PLAYBOOK wired with the cold-start + outbound-loop recipes for this workspace. Pure file-writer — no network, keeps existing files unless --force. The CLI ships governed primitives; your coding agent is the orchestrator (see docs/recipes.md).",
487
+ seeAlso: ["icp", "enrich", "signals"],
488
+ },
469
489
  // Setup & health
470
490
  login: {
471
491
  summary: "connect a provider or LLM key (secrets via stdin/env, never argv)",
@@ -726,12 +746,13 @@ const HELP: Record<string, HelpEntry> = {
726
746
 
727
747
  // Verbs that print their own richer multi-subcommand help; runCli routes their
728
748
  // `--help` to themselves, so commandHelp() only renders these via `help <verb>`.
729
- const BESPOKE_HELP = ["call", "market", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
749
+ const BESPOKE_HELP = ["init", "call", "market", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
730
750
 
731
751
  // Lifecycle-grouped front door. One line per verb, organized by the
732
752
  // Prevent→Detect→Remediate→Verify loop so a new user sees ~6 jobs, not 22 verbs.
733
753
  function shortUsage() {
734
754
  const groups: Array<[string, string[]]> = [
755
+ ["Get started", ["init"]],
735
756
  ["Setup & health", ["login", "logout", "doctor", "profiles", "health"]],
736
757
  ["Detect — read-only", ["audit", "report", "snapshot", "diff", "rules"]],
737
758
  ["Prevent — gate writes", ["resolve"]],
@@ -2659,7 +2680,8 @@ NEVER emits a patch plan; --save persists only the local signal ledger (used by
2659
2680
  throw new Error(`signals outcome: --result must be one of ${valid.join(", ")}.`);
2660
2681
  }
2661
2682
  const touchId = option(rest, "--touch") ?? undefined;
2662
- const outcome = makeOutcome({ accountDomain: accountArg, touchId, result: result as SignalOutcomeResult });
2683
+ const contactId = option(rest, "--contact") ?? undefined;
2684
+ const outcome = makeOutcome({ accountDomain: accountArg, touchId, contactId, result: result as SignalOutcomeResult });
2663
2685
  await createFileSignalStore().appendOutcome(outcome);
2664
2686
  console.error(
2665
2687
  `Recorded outcome "${outcome.result}" for ${outcome.accountDomain} (${outcome.id}). ` +
@@ -2840,6 +2862,63 @@ LLM key it emits a clearly-labeled stub rather than fake authored copy.`);
2840
2862
  }
2841
2863
  }
2842
2864
 
2865
+ /**
2866
+ * `init` — scaffold a GTM workspace from cold scratch: a starter icp.json, an
2867
+ * enrich.config.json (acquire preset + a visible assign seam), and a PLAYBOOK
2868
+ * wired with the chosen source/provider that points at the recipes. Pure
2869
+ * file-writer: no network, never overwrites without --force.
2870
+ */
2871
+ function initCommand(args: string[]) {
2872
+ if (args.includes("--help") || args.includes("-h")) {
2873
+ console.log(`Usage:
2874
+ fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
2875
+
2876
+ Scaffolds a workspace so the first acquire/signals/judge/draft commands work:
2877
+ icp.json starter ICP (edit, or rebuild with \`icp interview\`)
2878
+ enrich.config.json acquire preset for --source + a placeholder assign policy
2879
+ PLAYBOOK.md the cold-start + outbound-loop recipes wired for this workspace
2880
+
2881
+ The CLI ships governed primitives; your coding agent is the orchestrator. See
2882
+ docs/recipes.md for the full play set. Existing files are kept unless --force.`);
2883
+ return;
2884
+ }
2885
+ const source = (option(args, "--source") ?? "pipe0") as InitSource;
2886
+ if (!["pipe0", "explorium", "linkedin"].includes(source)) {
2887
+ throw new Error(`init: --source must be pipe0|explorium|linkedin (got "${source}")`);
2888
+ }
2889
+ const provider = (option(args, "--provider") ?? "hubspot") as InitProvider;
2890
+ if (!["hubspot", "salesforce"].includes(provider)) {
2891
+ throw new Error(`init: --provider must be hubspot|salesforce (got "${provider}")`);
2892
+ }
2893
+ const outDir = resolve(process.cwd(), option(args, "--out") ?? ".");
2894
+ const force = args.includes("--force");
2895
+ const current = activeProfile();
2896
+ const profile = current === DEFAULT_PROFILE ? undefined : current;
2897
+
2898
+ const files = scaffoldWorkspace({ source, provider, profile });
2899
+ const wrote: string[] = [];
2900
+ const kept: string[] = [];
2901
+ for (const file of files) {
2902
+ const path = resolve(outDir, file.path);
2903
+ if (existsSync(path) && !force) {
2904
+ kept.push(file.path);
2905
+ continue;
2906
+ }
2907
+ writeFileSync(path, file.content);
2908
+ wrote.push(file.path);
2909
+ }
2910
+
2911
+ console.log(
2912
+ `Scaffolded workspace (${provider}, discovery via ${source}${profile ? `, profile ${profile}` : ""}).`,
2913
+ );
2914
+ if (wrote.length) console.log(` wrote: ${wrote.join(", ")}`);
2915
+ if (kept.length) console.log(` kept (use --force to overwrite): ${kept.join(", ")}`);
2916
+ console.log(
2917
+ "\nNext: edit icp.json + set acquire.assign.ownerId in enrich.config.json, then follow PLAYBOOK.md\n" +
2918
+ "(full recipe set: docs/recipes.md).",
2919
+ );
2920
+ }
2921
+
2843
2922
  /**
2844
2923
  * `icp` — develop and inspect the Ideal Customer Profile that targets acquire.
2845
2924
  * The CLI can't run AskUserQuestion itself; `icp interview` emits the question
@@ -2929,12 +3008,20 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
2929
3008
  const unjudged = signalRun.signals.filter((s) => !s.judgedBy);
2930
3009
  if (unjudged.length === 0) throw new Error(`Signal run "${signalRun.runLabel}" has no unjudged signals.`);
2931
3010
 
2932
- // 2) Optional inputs: ICP (may be undefined), snapshot (only --with-history),
2933
- // outcomes + config from the signal store / DEFAULT_SIGNALS_CONFIG.
3011
+ // 2) Optional inputs: ICP (may be undefined), snapshot, outcomes + config.
3012
+ // The snapshot is loaded whenever a source is given (--provider/--input/
3013
+ // --demo/--sample) — it resolves each decision's CRM target (accountId +
3014
+ // best contact) so `draft` can write against a real record. --with-history
3015
+ // additionally enables the memory + fit scoring inputs.
2934
3016
  const icp = loadIcp(rest);
2935
3017
  const config: SignalsConfig = DEFAULT_SIGNALS_CONFIG;
2936
3018
  const outcomes = await signalStore.listOutcomes();
2937
- const snapshot = withHistory ? await readSnapshot(rest) : undefined;
3019
+ const hasSnapshotSource =
3020
+ Boolean(option(rest, "--provider")) ||
3021
+ Boolean(option(rest, "--input")) ||
3022
+ rest.includes("--demo") ||
3023
+ rest.includes("--sample");
3024
+ const snapshot = withHistory || hasSnapshotSource ? await readSnapshot(rest) : undefined;
2938
3025
 
2939
3026
  // 3) Resolve the LLM seam ONLY if a key already exists (deterministic baseline
2940
3027
  // otherwise — never PROMPT here; judge must run key-free).
@@ -5206,6 +5293,10 @@ export async function runCli(argv: string[]) {
5206
5293
  await enrichCommand(args);
5207
5294
  return;
5208
5295
  }
5296
+ if (command === "init") {
5297
+ initCommand(args);
5298
+ return;
5299
+ }
5209
5300
  if (command === "icp") {
5210
5301
  await icpCommand(args);
5211
5302
  return;
@@ -506,18 +506,34 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
506
506
  };
507
507
  }
508
508
 
509
- /** Exact-name company lookup for resolve-first creates. Returns matching ids (max 3 fetched). */
510
- async function searchCompaniesByName(name: string): Promise<string[]> {
509
+ /** Exact-value company lookup (by `name` or `domain`) for resolve-first creates. */
510
+ async function searchCompaniesBy(property: string, value: string): Promise<string[]> {
511
511
  const data = await request(`/crm/v3/objects/companies/search`, {
512
512
  method: "POST",
513
513
  body: JSON.stringify({
514
- filterGroups: [{ filters: [{ propertyName: "name", operator: "EQ", value: name }] }],
515
- properties: ["name"],
514
+ filterGroups: [{ filters: [{ propertyName: property, operator: "EQ", value }] }],
515
+ properties: [property],
516
516
  limit: 3,
517
517
  }),
518
518
  });
519
519
  return ((data?.results ?? []) as Array<{ id: string }>).map((row) => String(row.id));
520
520
  }
521
+ const searchCompaniesByName = (name: string) => searchCompaniesBy("name", name);
522
+
523
+ /** Stamp `domain` on a company only when it has none (fill-blank, never clobber). */
524
+ async function fillCompanyDomainIfEmpty(companyId: string, domain: string): Promise<void> {
525
+ try {
526
+ const data = await request(`/crm/v3/objects/companies/${encodeURIComponent(companyId)}?properties=domain`);
527
+ const current = data?.properties?.domain;
528
+ if (typeof current === "string" && current.trim()) return; // already has one — leave it
529
+ await request(`/crm/v3/objects/companies/${encodeURIComponent(companyId)}`, {
530
+ method: "PATCH",
531
+ body: JSON.stringify({ properties: { domain } }),
532
+ });
533
+ } catch {
534
+ // Best-effort: a failed domain fill must not sink the contact create.
535
+ }
536
+ }
521
537
 
522
538
  /** Exact-value contact lookup (by any searchable property) for resolve-first creates. */
523
539
  async function searchContactsBy(property: string, value: string): Promise<string[]> {
@@ -533,36 +549,51 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
533
549
  }
534
550
 
535
551
  /**
536
- * Resolve a company by exact name, creating it on a confirmed miss. Returns
537
- * the company id, or null on ambiguity (≥2 existing) so the caller skips the
538
- * association rather than guessing. Same-run safe via createdCompaniesByName.
552
+ * Resolve a company to a real account record, creating it on a confirmed miss.
553
+ * Matches by DOMAIN first (the accurate key) and falls back to exact name;
554
+ * creates with the domain stamped, and fills the domain on a name-matched
555
+ * account that lacks one — so the account is signal-watchable. Returns null on
556
+ * ambiguity (≥2 matches) so the caller skips the association rather than
557
+ * guessing. Same-run safe via createdCompaniesByName.
539
558
  */
540
- async function resolveOrCreateCompanyByName(name: string, opId: string): Promise<string | null> {
541
- const nameKey = name.toLowerCase();
542
- const already = createdCompaniesByName.get(nameKey);
559
+ async function resolveOrCreateCompany(name: string, domain: string | undefined, opId: string): Promise<string | null> {
560
+ const cacheKey = domain ? `d:${domain.toLowerCase()}` : `n:${name.toLowerCase()}`;
561
+ const already = createdCompaniesByName.get(cacheKey);
543
562
  if (already) return already;
544
- const matches = await searchCompaniesByName(name);
545
- if (matches.length > 1) return null;
546
- if (matches.length === 1) {
547
- createdCompaniesByName.set(nameKey, matches[0]);
548
- return matches[0];
563
+
564
+ // 1. Domain-first match (accurate; an account matched here already has it).
565
+ if (domain) {
566
+ const byDomain = await searchCompaniesBy("domain", domain);
567
+ if (byDomain.length > 1) return null;
568
+ if (byDomain.length === 1) {
569
+ createdCompaniesByName.set(cacheKey, byDomain[0]);
570
+ return byDomain[0];
571
+ }
549
572
  }
573
+ // 2. Exact-name match; stamp the domain if the account has none.
574
+ const byName = await searchCompaniesByName(name);
575
+ if (byName.length > 1) return null;
576
+ if (byName.length === 1) {
577
+ if (domain) await fillCompanyDomainIfEmpty(byName[0], domain);
578
+ createdCompaniesByName.set(cacheKey, byName[0]);
579
+ return byName[0];
580
+ }
581
+ // 3. Create with name + domain (provenance is best-effort).
582
+ const props: Record<string, string> = { name, ...(domain ? { domain } : {}) };
550
583
  let created;
551
584
  try {
552
585
  created = await request(`/crm/v3/objects/companies`, {
553
586
  method: "POST",
554
- body: JSON.stringify({
555
- properties: { name, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` },
556
- }),
587
+ body: JSON.stringify({ properties: { ...props, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` } }),
557
588
  });
558
589
  } catch {
559
590
  created = await request(`/crm/v3/objects/companies`, {
560
591
  method: "POST",
561
- body: JSON.stringify({ properties: { name } }),
592
+ body: JSON.stringify({ properties: props }),
562
593
  });
563
594
  }
564
595
  const id = String(created.id);
565
- createdCompaniesByName.set(nameKey, id);
596
+ createdCompaniesByName.set(cacheKey, id);
566
597
  return id;
567
598
  }
568
599
 
@@ -588,7 +619,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
588
619
  }
589
620
 
590
621
  if (operation.objectType === "account") {
591
- const id = await resolveOrCreateCompanyByName(matchValue, operation.id);
622
+ const id = await resolveOrCreateCompany(matchValue, stringOrUndefined(payload.properties?.domain), operation.id);
592
623
  if (id === null) {
593
624
  return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — multiple companies named "${matchValue}". Not creating.` };
594
625
  }
@@ -637,13 +668,14 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
637
668
  let companyNote = "";
638
669
  if (payload.associateCompanyName) {
639
670
  try {
640
- const companyId = await resolveOrCreateCompanyByName(payload.associateCompanyName, operation.id);
671
+ const companyId = await resolveOrCreateCompany(payload.associateCompanyName, payload.associateCompanyDomain, operation.id);
641
672
  if (companyId) {
642
673
  await request(
643
674
  `/crm/v4/objects/contacts/${encodeURIComponent(contactId)}/associations/default/companies/${encodeURIComponent(companyId)}`,
644
675
  { method: "PUT" },
645
676
  );
646
- companyNote = ` Linked to company "${payload.associateCompanyName}" (${companyId}).`;
677
+ const domainNote = payload.associateCompanyDomain ? ` (${payload.associateCompanyDomain})` : "";
678
+ companyNote = ` Linked to company "${payload.associateCompanyName}"${domainNote} (${companyId}).`;
647
679
  } else {
648
680
  companyNote = ` Company "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
649
681
  }