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/src/cli.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  } from "./connectors/hubspotAuth.ts";
14
14
  import { createSalesforceConnector } from "./connectors/salesforce.ts";
15
15
  import { createStripeConnector } from "./connectors/stripe.ts";
16
+ import { createChannelConnector } from "./connectors/outboxChannel.ts";
16
17
  import {
17
18
  pollSalesforceDeviceLogin,
18
19
  startSalesforceDeviceLogin,
@@ -125,6 +126,28 @@ import {
125
126
  remaining,
126
127
  type AcquireRemaining,
127
128
  } from "./acquireMeter.ts";
129
+ import {
130
+ scaffoldWorkspace,
131
+ type InitProvider,
132
+ type InitSource,
133
+ } from "./init.ts";
134
+ import {
135
+ appendCoverage,
136
+ computeCoverage,
137
+ coverageCountsFromSnapshot,
138
+ classifyCoverage,
139
+ coverageToText,
140
+ deriveAcvFromClosedWon,
141
+ deriveBuyersPerAccount,
142
+ estimateTam,
143
+ loadTamModel,
144
+ projectEta,
145
+ readCoverageTimeline,
146
+ saveTamModel,
147
+ tamReportToMarkdown,
148
+ type AcvBasis,
149
+ type TamCrossCheck,
150
+ } from "./tam.ts";
128
151
  import {
129
152
  crmContactKeys,
130
153
  fetchExploriumProspects,
@@ -132,17 +155,25 @@ import {
132
155
  partitionFreshProspects,
133
156
  pipe0ResolveCompanyDomains,
134
157
  pipe0ResolveWorkEmails,
158
+ probeExploriumBusinessCount,
135
159
  prospectIdentityKeys,
136
160
  type Prospect,
137
161
  } from "./connectors/prospectSources.ts";
162
+ import {
163
+ theirStackCountCompanies,
164
+ theirStackPullCost,
165
+ theirStackSearchCompanies,
166
+ } from "./connectors/theirstack.ts";
138
167
  import { loadSeen, recordSeen } from "./acquireSeen.ts";
139
- import { reportCounts, reportEvent } from "./runReport.ts";
168
+ import { reportCounts, reportCrm, reportEvent, reportFindings } from "./runReport.ts";
140
169
  import { createLinkedInProvider, discoverLinkedInProspects } from "./acquireLinkedIn.ts";
141
170
  import {
142
171
  fitThreshold,
143
172
  icpFromAnswers,
144
173
  icpToCrustdataFilters,
174
+ icpToExploriumBusinessFilters,
145
175
  icpToExploriumFilters,
176
+ icpToTheirStackFilters,
146
177
  parseIcp,
147
178
  scoreProspectAgainstIcp,
148
179
  INTERVIEW_SPEC,
@@ -159,13 +190,15 @@ import {
159
190
  normalizeAccountDomain,
160
191
  SIGNAL_BUCKETS,
161
192
  signalRunId,
162
- signalId,
193
+ signalsSpoolDir,
194
+ stagedRowToSignal,
163
195
  type Signal,
164
196
  type SignalBucket,
165
197
  type SignalOutcomeResult,
166
198
  type SignalsConfig,
167
199
  } from "./signals.ts";
168
200
  import { fetchAtsJobs, type AtsBoardSource, type AtsJob } from "./connectors/atsBoards.ts";
201
+ import { getSignalSource, listSignalSources, type SignalSourceContext } from "./connectors/signalSources.ts";
169
202
  import {
170
203
  createFileJudgeStore,
171
204
  DEFAULT_JUDGE_PROMPT,
@@ -180,6 +213,7 @@ import {
180
213
  type DraftChannel,
181
214
  } from "./draft.ts";
182
215
  import {
216
+ DEFAULT_GOLDEN_NOW_ISO,
183
217
  DEFAULT_GOLDEN_SET,
184
218
  DEFAULT_MIN_ACCURACY,
185
219
  defaultJudgeFn,
@@ -240,12 +274,16 @@ Usage:
240
274
  fullstackgtm login salesforce --instance-url <url> [--no-validate]
241
275
  fullstackgtm login stripe [--no-validate]
242
276
  fullstackgtm login anthropic | openai store an LLM API key for call parse/score
243
- fullstackgtm login apollo store an Apollo API key for enrich pulls\n fullstackgtm login pipe0 | explorium store a discovery-provider key for enrich acquire\n fullstackgtm login heyreach store a HeyReach key for enrich acquire --source linkedin\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|heyreach|broker>
277
+ fullstackgtm login apollo store an Apollo API key for enrich pulls\n fullstackgtm login pipe0 | explorium | theirstack store a discovery-provider key (theirstack = technographic TAM)\n fullstackgtm login heyreach store a HeyReach key for enrich acquire --source linkedin\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|heyreach|broker>
244
278
 
245
279
  Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
246
280
  the process list and shell history. Pipe them on stdin or enter them at the
247
281
  interactive prompt:
248
282
  echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot
283
+ fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
284
+ cold start: scaffold icp.json + enrich.config.json + a
285
+ PLAYBOOK wired for this workspace (the CLI ships primitives,
286
+ your agent is the orchestrator — see docs/recipes.md)
249
287
  fullstackgtm snapshot [source options] [--since <iso>] [--out <path> | --archive <dir>]
250
288
  fullstackgtm audit [source options] [audit options] [--save]
251
289
  fullstackgtm report [source options] [audit options] [report options]
@@ -281,6 +319,16 @@ Usage:
281
319
  against the stored capture it cites before it's accepted — then
282
320
  compute deterministic front states and drift, render the field
283
321
  report. refresh = capture → classify → drift → report in one step
322
+ fullstackgtm tam estimate [--name <n>] [--icp <path>] (--accounts <n> | --source theirstack|explorium) (--acv <annual-usd> | --acv-from-crm --deal-period monthly|quarterly|annual) [--acv-basis account|buyer] [--buyers-per-account <n>] [--cross-checks <file.json>] [source options] [--json]
323
+ fullstackgtm tam status [--name <n>] <source options> [--save] [--json]
324
+ fullstackgtm tam report [--name <n>] [--out <path>]
325
+ fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--label <l>]
326
+ size the reachable market FROM the ICP (a real account count ×
327
+ ACV; buyers/account = the contact population target), then fill
328
+ it: populate schedules plan-only enrich acquire --save (apply
329
+ stays gated), status --save stamps a coverage timeline, report
330
+ projects a burn-up + ETA. estimate --source probes the provider's
331
+ ICP-match count (else --accounts), always labeled provider-vs-assumption
284
332
  fullstackgtm enrich append [--source apollo] [--objects companies,contacts] [--save] [--config <path>] [source options]
285
333
  fullstackgtm enrich refresh [--source apollo] [--stale-days <n>] [--save] [--config <path>] [source options]
286
334
  fullstackgtm enrich ingest <file.csv|payload.json> --source clay [--run-label <label>]
@@ -372,6 +420,7 @@ Usage:
372
420
  fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]
373
421
  fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
374
422
  fullstackgtm apply --plan-id <id> --provider <name>
423
+ fullstackgtm apply --plan-id <id> --channel outbox (render approved openers; transmits nothing)
375
424
  fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [options]
376
425
  fullstackgtm audit-log export [--out <path>] | verify --in <path> tamper-evident apply-run record
377
426
  fullstackgtm rules [--json]
@@ -466,6 +515,17 @@ type HelpEntry = {
466
515
  };
467
516
 
468
517
  const HELP: Record<string, HelpEntry> = {
518
+ // Get started
519
+ init: {
520
+ summary: "scaffold a workspace (icp.json + enrich.config.json + PLAYBOOK)",
521
+ phase: "Setup",
522
+ synopsis: [
523
+ "fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]",
524
+ ],
525
+ detail:
526
+ "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).",
527
+ seeAlso: ["icp", "enrich", "signals"],
528
+ },
469
529
  // Setup & health
470
530
  login: {
471
531
  summary: "connect a provider or LLM key (secrets via stdin/env, never argv)",
@@ -646,6 +706,7 @@ const HELP: Record<string, HelpEntry> = {
646
706
  phase: "Govern / Verify",
647
707
  synopsis: [
648
708
  "fullstackgtm apply --plan-id <id> --provider <name>",
709
+ "fullstackgtm apply --plan-id <id> --channel outbox (render approved drafted openers to the outbox; transmits nothing)",
649
710
  "fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [--value <opId>=<v>]",
650
711
  ],
651
712
  detail:
@@ -683,21 +744,29 @@ const HELP: Record<string, HelpEntry> = {
683
744
  synopsis: ["fullstackgtm market init|capture|classify|worksheet|observe|fronts|axes|overlay|scale|report|refresh … (run `market --help` for full options)"],
684
745
  detail:
685
746
  "Capture vendor pages (content-addressed), classify intensity per claim (LLM bring-your-own-key, or fill the worksheet with any agent), then compute deterministic front states and drift. Every quoted span is verified verbatim against the stored capture before it's accepted.",
686
- seeAlso: [],
747
+ seeAlso: ["tam"],
748
+ },
749
+ tam: {
750
+ summary: "size the reachable market from your ICP, then populate + track coverage",
751
+ phase: "Intelligence",
752
+ synopsis: ["fullstackgtm tam estimate|status|report|populate … (run `tam --help` for full options)"],
753
+ detail:
754
+ "Estimate a defensible TAM from the ICP: a real account count (Explorium company count or --accounts) × a confirmed ANNUAL ACV (explicit --acv, or --acv-from-crm --deal-period to derive+annualize from closed-won — no band defaults, a bare --provider does NOT set ACV, refuses without one); buyers/account (explicit or CRM-derived) gives the contact target. Then iteratively fill it with scheduled `enrich acquire --save` runs (plan-only; apply stays gated). `status --save` stamps a coverage timeline so `report` projects how long full coverage will take.",
755
+ seeAlso: ["icp", "enrich", "schedule", "market"],
687
756
  },
688
757
 
689
758
  // Outbound intelligence — signals → judge → draft (the GTM brain)
690
759
  signals: {
691
- summary: "detect fresh buying triggers (ATS hiring + staged ingest), ranked",
760
+ summary: "detect fresh buying triggers (ATS hiring + source connectors), ranked",
692
761
  phase: "Detect",
693
762
  synopsis: [
694
- "fullstackgtm signals fetch [--bucket job,…] [--source greenhouse,lever,ashby] [--watchlist <path|crm:seg>] [--keywords …] [--from <file.json>] [--save]",
763
+ "fullstackgtm signals fetch [--bucket job,…] [--source greenhouse,lever,ashby] [--connector file,serpapi-news,hubspot-forms] [--connector-opt k=v] [--watchlist <path|crm:seg>] [--keywords …] [--from <file.json>] [--save]",
695
764
  "fullstackgtm signals list [--since 7d] [--bucket b] [--account d] [--unjudged]",
696
765
  "fullstackgtm signals outcome --account <d> [--touch <id>] --result replied|meeting|bounced|no_reply",
697
766
  "fullstackgtm signals weights [--explain]",
698
767
  ],
699
768
  detail:
700
- "Read-only re: CRM — `fetch` NEVER emits a patch plan; `--save` persists only the local signal ledger. ATS adapters are no-auth. `outcome` feeds the learned per-bucket `weights`.",
769
+ "Read-only re: CRM — `fetch` NEVER emits a patch plan; `--save` persists only the local signal ledger. ATS adapters are no-auth; source connectors (`--connector`) pull from connected platforms with secrets via the credential ladder, never argv. `outcome` feeds the learned per-bucket `weights`.",
701
770
  seeAlso: ["icp", "draft"],
702
771
  },
703
772
  icp: {
@@ -726,19 +795,20 @@ const HELP: Record<string, HelpEntry> = {
726
795
 
727
796
  // Verbs that print their own richer multi-subcommand help; runCli routes their
728
797
  // `--help` to themselves, so commandHelp() only renders these via `help <verb>`.
729
- const BESPOKE_HELP = ["call", "market", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
798
+ const BESPOKE_HELP = ["init", "call", "market", "tam", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
730
799
 
731
800
  // Lifecycle-grouped front door. One line per verb, organized by the
732
801
  // Prevent→Detect→Remediate→Verify loop so a new user sees ~6 jobs, not 22 verbs.
733
802
  function shortUsage() {
734
803
  const groups: Array<[string, string[]]> = [
804
+ ["Get started", ["init"]],
735
805
  ["Setup & health", ["login", "logout", "doctor", "profiles", "health"]],
736
806
  ["Detect — read-only", ["audit", "report", "snapshot", "diff", "rules"]],
737
807
  ["Prevent — gate writes", ["resolve"]],
738
808
  ["Remediate — governed writes", ["fix", "bulk-update", "dedupe", "reassign", "enrich"]],
739
809
  ["Calls → evidence", ["call"]],
740
810
  ["Govern — the plan/apply spine", ["suggest", "plans", "apply", "audit-log", "merge"]],
741
- ["Market intelligence", ["market"]],
811
+ ["Market intelligence", ["market", "tam"]],
742
812
  ["Outbound — signals → judge → draft", ["signals", "icp", "draft"]],
743
813
  ["Schedule — make it continuous", ["schedule"]],
744
814
  ];
@@ -1021,6 +1091,63 @@ function auditNextStep(args: string[], plan: PatchPlan): string {
1021
1091
  ].join("\n");
1022
1092
  }
1023
1093
 
1094
+ /**
1095
+ * Resolve the live HubSpot account's record-URL base (e.g.
1096
+ * `https://app-na2.hubspot.com/contacts/<portalId>/record`) and report it on the
1097
+ * run so the dashboard can deep-link findings. Best-effort: any failure is
1098
+ * swallowed — deep-links are a nicety, never a reason to fail the audit.
1099
+ */
1100
+ async function reportHubspotDeepLinkBase(): Promise<void> {
1101
+ try {
1102
+ const connection = await resolveHubspotConnection();
1103
+ if (!connection?.accessToken) return;
1104
+ const response = await fetch("https://api.hubapi.com/account-info/v3/details", {
1105
+ headers: { Authorization: `Bearer ${connection.accessToken}` },
1106
+ });
1107
+ if (!response.ok) return;
1108
+ const info = (await response.json()) as { portalId?: number; uiDomain?: string };
1109
+ if (!info.portalId || !info.uiDomain) return;
1110
+ reportCrm({
1111
+ provider: "hubspot",
1112
+ recordUrlBase: `https://${info.uiDomain}/contacts/${info.portalId}/record`,
1113
+ });
1114
+ } catch {
1115
+ // deep-link base is best-effort
1116
+ }
1117
+ }
1118
+
1119
+ /**
1120
+ * When paired (a broker credential exists), hand the local HubSpot token to the
1121
+ * hosted deployment so its Integrations page shows HubSpot connected and the
1122
+ * backend can sync on its own. The token lands in the same place a manual
1123
+ * in-app connect would (encrypted on the org's integration). Best-effort:
1124
+ * never blocks or fails the audit.
1125
+ */
1126
+ async function registerHubspotWithBroker(): Promise<void> {
1127
+ try {
1128
+ const broker = getCredential("broker");
1129
+ if (!broker?.baseUrl || !broker.accessToken) return; // only when paired
1130
+ const connection = await resolveHubspotConnection();
1131
+ if (!connection?.accessToken) return;
1132
+ const response = await fetch(`${broker.baseUrl.replace(/\/+$/, "")}/api/cli/integration`, {
1133
+ method: "POST",
1134
+ headers: {
1135
+ Authorization: `Bearer ${broker.accessToken}`,
1136
+ "Content-Type": "application/json",
1137
+ },
1138
+ body: JSON.stringify({ provider: "hubspot", token: connection.accessToken }),
1139
+ signal: AbortSignal.timeout(8000),
1140
+ });
1141
+ if (response.ok) {
1142
+ console.error(
1143
+ `Registered HubSpot with ${broker.baseUrl} — it now shows on the dashboard's Integrations page.`,
1144
+ );
1145
+ }
1146
+ } catch {
1147
+ // registering the connection is best-effort; never affect the audit
1148
+ }
1149
+ }
1150
+
1024
1151
  async function audit(args: string[]) {
1025
1152
  const threshold = failOnThreshold(args);
1026
1153
  const loaded = loadConfig(option(args, "--config") ?? undefined);
@@ -1039,6 +1166,33 @@ async function audit(args: string[]) {
1039
1166
  critical: plan.findings.filter((f) => f.severity === "critical").length,
1040
1167
  warning: plan.findings.filter((f) => f.severity === "warning").length,
1041
1168
  });
1169
+ // Row-level detail for the hosted dashboard: IDs + issue type only (no field
1170
+ // values). Enrich each finding with its proposed op's field/operation when one
1171
+ // is linked, so the dashboard can show "what" and "where" without "the value".
1172
+ const opByFinding = new Map<string, { field?: string; operation?: string }>();
1173
+ for (const op of plan.operations ?? []) {
1174
+ for (const findingId of op.findingIds ?? []) {
1175
+ if (!opByFinding.has(findingId)) {
1176
+ opByFinding.set(findingId, { field: op.field, operation: op.operation });
1177
+ }
1178
+ }
1179
+ }
1180
+ reportFindings(
1181
+ plan.findings.map((f) => ({
1182
+ objectType: f.objectType,
1183
+ objectId: f.objectId,
1184
+ severity: f.severity,
1185
+ ruleId: f.ruleId,
1186
+ ...opByFinding.get(f.id),
1187
+ })),
1188
+ );
1189
+ // When auditing a live HubSpot, emit the account's record-URL base so the
1190
+ // hosted dashboard can deep-link each finding to the real CRM record. Best
1191
+ // effort and HubSpot-only for now; never blocks or fails the audit.
1192
+ if (option(args, "--provider") === "hubspot") {
1193
+ await reportHubspotDeepLinkBase();
1194
+ await registerHubspotWithBroker();
1195
+ }
1042
1196
  const out = option(args, "--out");
1043
1197
  if (out) {
1044
1198
  writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(plan, null, 2)}\n`);
@@ -2419,9 +2573,15 @@ function formatEnrichCounts(counts: EnrichCounts, ambiguities: number) {
2419
2573
 
2420
2574
  /** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
2421
2575
  /** Provider API key: env override first, then the credential store (`login`). */
2422
- function providerKey(provider: "explorium" | "pipe0" | "heyreach"): string {
2576
+ function providerKey(provider: "explorium" | "pipe0" | "heyreach" | "theirstack"): string {
2423
2577
  const envName =
2424
- provider === "explorium" ? "EXPLORIUM_API_KEY" : provider === "pipe0" ? "PIPE0_API_KEY" : "HEYREACH_API_KEY";
2578
+ provider === "explorium"
2579
+ ? "EXPLORIUM_API_KEY"
2580
+ : provider === "pipe0"
2581
+ ? "PIPE0_API_KEY"
2582
+ : provider === "theirstack"
2583
+ ? "THEIRSTACK_API_KEY"
2584
+ : "HEYREACH_API_KEY";
2425
2585
  if (process.env[envName]) return process.env[envName] as string;
2426
2586
  const stored = getCredential(provider);
2427
2587
  if (stored) return stored.accessToken;
@@ -2524,14 +2684,22 @@ async function signalsCommand(args: string[]) {
2524
2684
  // anywhere in argv (`signals --help` and `signals fetch --help` both land here).
2525
2685
  if (!sub || args.includes("--help") || args.includes("-h")) {
2526
2686
  console.log(`Usage:
2527
- fullstackgtm signals fetch [--bucket job,funding,...] [--source greenhouse,lever,ashby] [--watchlist <path|crm:segment>] [--keywords "growth,revops"] [--from <file.json>] [--config <path>] [--save]
2687
+ fullstackgtm signals fetch [--bucket job,funding,...] [--source greenhouse,lever,ashby] [--connector file,serpapi-news,hubspot-forms] [--connector-opt k=v ...] [--watchlist <path|crm:segment>] [--keywords "growth,revops"] [--from <file.json>] [--config <path>] [--save]
2528
2688
  fullstackgtm signals list [--since 7d] [--bucket b] [--account d] [--unjudged]
2529
2689
  fullstackgtm signals outcome --account <domain> [--touch <id>] --result replied|meeting|bounced|no_reply
2530
2690
  fullstackgtm signals weights [--explain]
2531
2691
 
2532
2692
  Detect fresh buying triggers and rank them. \`fetch\` is read-only re: CRM — it
2533
2693
  NEVER emits a patch plan; --save persists only the local signal ledger (used by
2534
- \`icp judge\`). ATS adapters are no-auth, so no credential flags exist.`);
2694
+ \`icp judge\`). ATS adapters are no-auth. Source connectors (--connector) pull
2695
+ from connected platforms: file (local JSON/JSONL spool, no auth), serpapi-news
2696
+ (API key via env/login), hubspot-forms (reuses the HubSpot login). Secrets come
2697
+ from the credential ladder, never argv; --connector-opt carries non-secret knobs
2698
+ (e.g. path=… for file, bucket=company for serpapi-news).
2699
+
2700
+ \`--connector file\` with no path reads the conventional webhook landing zone
2701
+ (${signalsSpoolDir()}) — every *.jsonl in it. Point a webhook receiver there
2702
+ (one row per event); see docs/signal-spool-format.md.`);
2535
2703
  return;
2536
2704
  }
2537
2705
 
@@ -2544,12 +2712,21 @@ NEVER emits a patch plan; --save persists only the local signal ledger (used by
2544
2712
  config.buckets.job = { ...config.buckets.job, keywords: merged };
2545
2713
  }
2546
2714
  const bucketFilter = option(rest, "--bucket");
2547
- const buckets: SignalBucket[] = bucketFilter
2715
+ // Explicit --bucket narrows EVERYTHING (job scan, --from, connectors). With
2716
+ // no --bucket, the JOB SCAN defaults to buckets that have configured sources
2717
+ // (its legacy behavior), but staged/connector rows are NOT narrowed — they
2718
+ // carry their own bucket and must flow even for buckets with no `sources`
2719
+ // (e.g. `demand`, which `hubspot-forms` and form-spool rows produce).
2720
+ const explicitBuckets: SignalBucket[] | null = bucketFilter
2548
2721
  ? (bucketFilter.split(",").map((b) => b.trim()).filter(Boolean) as SignalBucket[])
2549
- : SIGNAL_BUCKETS.filter((b) => (config.buckets[b]?.sources.length ?? 0) > 0);
2722
+ : null;
2723
+ const buckets: SignalBucket[] =
2724
+ explicitBuckets ?? SIGNAL_BUCKETS.filter((b) => (config.buckets[b]?.sources.length ?? 0) > 0);
2550
2725
  for (const b of buckets) {
2551
2726
  if (!SIGNAL_BUCKETS.includes(b)) throw new Error(`Unknown bucket: ${b} (one of ${SIGNAL_BUCKETS.join(", ")})`);
2552
2727
  }
2728
+ // Bucket filter for explicitly-provided rows: the --bucket list, else none.
2729
+ const rowBuckets: SignalBucket[] = explicitBuckets ?? [];
2553
2730
  const sourceFilter = option(rest, "--source");
2554
2731
  const allJobSources: AtsBoardSource[] = ["greenhouse", "lever", "ashby"];
2555
2732
  const jobSources = sourceFilter
@@ -2586,13 +2763,41 @@ NEVER emits a patch plan; --save persists only the local signal ledger (used by
2586
2763
  }
2587
2764
  }
2588
2765
 
2589
- // Staged ingest (funding/company/social): --from <file.json>.
2766
+ // Staged ingest (any bucket): --from <file.json>. Narrowed only by --bucket.
2590
2767
  const fromFile = option(rest, "--from");
2591
2768
  if (fromFile) {
2592
- const ingested = readStagedSignals(resolve(process.cwd(), fromFile), buckets, now);
2769
+ const ingested = readStagedSignals(resolve(process.cwd(), fromFile), rowBuckets, now);
2593
2770
  candidates.push(...ingested);
2594
2771
  }
2595
2772
 
2773
+ // Source connectors (opt-in, additive): --connector id[,id] [--connector-opt k=v ...].
2774
+ // Default behavior is unchanged when no --connector is passed.
2775
+ const connectorArg = option(rest, "--connector");
2776
+ if (connectorArg) {
2777
+ const ids = connectorArg.split(",").map((s) => s.trim()).filter(Boolean);
2778
+ const options: Record<string, string> = {};
2779
+ for (const pair of repeatedOption(rest, "--connector-opt")) {
2780
+ const eq = pair.indexOf("=");
2781
+ if (eq === -1) throw new Error(`--connector-opt "${pair}" must be key=value.`);
2782
+ options[pair.slice(0, eq).trim()] = pair.slice(eq + 1);
2783
+ }
2784
+ // The `file` connector defaults to the conventional spool dir (the webhook
2785
+ // landing zone) when no explicit path is given, so `--connector file`
2786
+ // works with zero args against `<profile home>/signals/spool`.
2787
+ if (ids.includes("file") && !options.path && !options.file) {
2788
+ options.path = signalsSpoolDir();
2789
+ }
2790
+ const watchlist = await resolveWatchlist(rest, config);
2791
+ const ctx: SignalSourceContext = {
2792
+ watchlist: watchlist.map((a) => ({ domain: a.domain })),
2793
+ keywords: config.buckets.job.keywords ?? [],
2794
+ now,
2795
+ getApiKey: resolveSignalSourceKey,
2796
+ options,
2797
+ };
2798
+ candidates.push(...(await runSignalConnectors(ids, ctx, rowBuckets)));
2799
+ }
2800
+
2596
2801
  const fetched = candidates.length;
2597
2802
  const { fresh, deduped } = dedupeSignals(candidates, priorSignals, config.dedupWindowDays, now);
2598
2803
  const ranked = [...fresh].sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
@@ -2659,7 +2864,8 @@ NEVER emits a patch plan; --save persists only the local signal ledger (used by
2659
2864
  throw new Error(`signals outcome: --result must be one of ${valid.join(", ")}.`);
2660
2865
  }
2661
2866
  const touchId = option(rest, "--touch") ?? undefined;
2662
- const outcome = makeOutcome({ accountDomain: accountArg, touchId, result: result as SignalOutcomeResult });
2867
+ const contactId = option(rest, "--contact") ?? undefined;
2868
+ const outcome = makeOutcome({ accountDomain: accountArg, touchId, contactId, result: result as SignalOutcomeResult });
2663
2869
  await createFileSignalStore().appendOutcome(outcome);
2664
2870
  console.error(
2665
2871
  `Recorded outcome "${outcome.result}" for ${outcome.accountDomain} (${outcome.id}). ` +
@@ -2719,40 +2925,77 @@ function parseSinceWindowMs(value: string): number {
2719
2925
  function readStagedSignals(path: string, buckets: SignalBucket[], now: Date): Signal[] {
2720
2926
  const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
2721
2927
  if (!Array.isArray(raw)) throw new Error(`--from ${path}: expected a JSON array of staged signals.`);
2722
- const nowIso = now.toISOString();
2723
2928
  const out: Signal[] = [];
2724
2929
  raw.forEach((entry, index) => {
2725
2930
  if (!entry || typeof entry !== "object") throw new Error(`--from ${path}: row ${index} is not an object.`);
2726
2931
  const e = entry as Record<string, unknown>;
2932
+ // Filter a --bucket-excluded row out BEFORE validation so it never errors;
2933
+ // an unknown bucket falls through to stagedRowToSignal, which rejects it.
2727
2934
  const bucket = String(e.bucket ?? "");
2728
- if (!SIGNAL_BUCKETS.includes(bucket as SignalBucket)) {
2729
- throw new Error(`--from ${path}: row ${index} has unknown bucket "${bucket}" (one of ${SIGNAL_BUCKETS.join(", ")}).`);
2730
- }
2731
- if (buckets.length && !buckets.includes(bucket as SignalBucket)) return; // filtered out by --bucket
2732
- const accountDomain = normalizeAccountDomain(String(e.accountDomain ?? e.domain ?? ""));
2733
- if (!accountDomain) throw new Error(`--from ${path}: row ${index} is missing accountDomain.`);
2734
- const trigger = String(e.trigger ?? "").trim();
2735
- if (!trigger) throw new Error(`--from ${path}: row ${index} is missing trigger.`);
2736
- const quote = String(e.quote ?? "").trim();
2737
- if (!quote) throw new Error(`--from ${path}: row ${index} is missing the verbatim quote (the evidence anchor).`);
2738
- const base = { accountDomain, bucket: bucket as SignalBucket, trigger };
2739
- const firstSeen = typeof e.firstSeen === "string" && e.firstSeen ? e.firstSeen : nowIso;
2740
- out.push({
2741
- id: signalId(base),
2742
- accountDomain,
2743
- bucket: bucket as SignalBucket,
2744
- trigger,
2745
- quote,
2746
- sourceUrl: String(e.sourceUrl ?? ""),
2747
- firstSeen,
2748
- weight: typeof e.weight === "number" ? e.weight : DEFAULT_SIGNALS_CONFIG.buckets[bucket as SignalBucket].weight,
2749
- source: "ingest",
2750
- judgedBy: null,
2751
- });
2935
+ if (buckets.length && SIGNAL_BUCKETS.includes(bucket as SignalBucket) && !buckets.includes(bucket as SignalBucket)) {
2936
+ return;
2937
+ }
2938
+ out.push(stagedRowToSignal(e, { now, source: "ingest", errorLabel: `--from ${path}: row ${index}` }));
2752
2939
  });
2753
2940
  return out;
2754
2941
  }
2755
2942
 
2943
+ /**
2944
+ * Run the selected source connectors and return their candidate signals,
2945
+ * funneled through the SAME staged-row gate as `--from` (so evidence-gating,
2946
+ * dedup, and weighting are identical regardless of intake). Connectors are
2947
+ * resilient by contract; a connector that still throws (e.g. a malformed file
2948
+ * it was explicitly pointed at) surfaces with its id for context. `--bucket`
2949
+ * filtering is applied after the row is validated.
2950
+ */
2951
+ async function runSignalConnectors(
2952
+ ids: string[],
2953
+ ctx: SignalSourceContext,
2954
+ buckets: SignalBucket[],
2955
+ ): Promise<Signal[]> {
2956
+ const out: Signal[] = [];
2957
+ for (const id of ids) {
2958
+ const connector = getSignalSource(id);
2959
+ if (!connector) {
2960
+ const known = listSignalSources().map((c) => c.id).join(", ");
2961
+ throw new Error(`Unknown --connector "${id}" (one of: ${known}).`);
2962
+ }
2963
+ let rows;
2964
+ try {
2965
+ rows = await connector.fetch(ctx);
2966
+ } catch (error) {
2967
+ throw new Error(`signals source "${id}": ${error instanceof Error ? error.message : String(error)}`);
2968
+ }
2969
+ rows.forEach((row, index) => {
2970
+ const e = row as unknown as Record<string, unknown>;
2971
+ const bucket = String(e.bucket ?? "");
2972
+ if (buckets.length && SIGNAL_BUCKETS.includes(bucket as SignalBucket) && !buckets.includes(bucket as SignalBucket)) {
2973
+ return;
2974
+ }
2975
+ out.push(stagedRowToSignal(e, { now: ctx.now, source: id, errorLabel: `signals source "${id}": row ${index}` }));
2976
+ });
2977
+ }
2978
+ return out;
2979
+ }
2980
+
2981
+ /**
2982
+ * Credential-ladder lookup for source connectors. Env wins (CI / agent
2983
+ * sandboxes never touch the filesystem), then the stored login. HubSpot reuses
2984
+ * the existing connection resolver (private-app / OAuth-refresh / broker) so a
2985
+ * forms source needs no separate login. Returns null when nothing is configured
2986
+ * — the connector then treats itself as inactive (returns []), never argv.
2987
+ */
2988
+ async function resolveSignalSourceKey(provider: string): Promise<string | null> {
2989
+ const envKey = `FSGTM_${provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
2990
+ const fromEnv = process.env[envKey] ?? process.env[`${provider.toUpperCase()}_API_KEY`];
2991
+ if (fromEnv) return fromEnv;
2992
+ if (provider === "hubspot") {
2993
+ const connection = await resolveHubspotConnection();
2994
+ return connection?.accessToken ?? null;
2995
+ }
2996
+ return getCredential(provider)?.accessToken ?? null;
2997
+ }
2998
+
2756
2999
  /**
2757
3000
  * `draft` — author ONE trigger-grounded opener per hot judge decision as a
2758
3001
  * governed create_task plan. Structurally a proposal: never sends, never writes
@@ -2833,13 +3076,477 @@ LLM key it emits a clearly-labeled stub rather than fake authored copy.`);
2833
3076
  await createFilePlanStore().save(plan);
2834
3077
  console.error(
2835
3078
  `Saved plan ${plan.id} (status ${plan.status}). Review: \`fullstackgtm plans show ${plan.id}\`, ` +
2836
- `then \`fullstackgtm plans approve ${plan.id} --operations all\` and \`fullstackgtm apply --plan-id ${plan.id} --provider <name>\`.`,
3079
+ `then \`fullstackgtm plans approve ${plan.id} --operations all\` and either ` +
3080
+ `\`fullstackgtm apply --plan-id ${plan.id} --provider <name>\` (log the touch as a CRM task) or ` +
3081
+ `\`fullstackgtm apply --plan-id ${plan.id} --channel outbox\` (render to the outbox for a sender — transmits nothing).`,
2837
3082
  );
2838
3083
  } else {
2839
3084
  console.error("(not saved — re-run with --save to stage the plan for plans approve -> apply)");
2840
3085
  }
2841
3086
  }
2842
3087
 
3088
+ /**
3089
+ * `tam` — Total Addressable Market mapping. Estimate a defensible universe from
3090
+ * the ICP, then iteratively populate it via scheduled governed acquire runs and
3091
+ * track coverage over time. Subcommands: estimate, status, report, populate.
3092
+ */
3093
+ async function tamCommand(args: string[]) {
3094
+ const [sub, ...rest] = args;
3095
+ if (!sub || args.includes("--help") || args.includes("-h")) {
3096
+ console.log(`Usage:
3097
+ fullstackgtm tam estimate [--name <n>] [--icp <path>] (--accounts <n> | --source theirstack|explorium)
3098
+ (--acv <annual-usd> | --acv-from-crm --deal-period monthly|quarterly|annual) [--acv-basis account|buyer]
3099
+ [--buyers-per-account <n>] [--cross-checks <file.json>] [source options for a baseline] [--json]
3100
+ fullstackgtm tam accounts [--name <n>] [--icp <path>] --source theirstack [--max <n>] [--dry-run | --confirm] [--max-credits <n>] [--usd-per-credit <r>] [--out <file.csv> | --json]
3101
+ fullstackgtm tam status [--name <n>] <source options> [--save] [--json]
3102
+ fullstackgtm tam report [--name <n>] [--out <path>]
3103
+ fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--label <l>]
3104
+
3105
+ Estimate the reachable market FROM your ICP: a real account count × a confirmed
3106
+ ANNUAL ACV (--acv <annual-usd>, or --acv-from-crm --deal-period monthly|quarterly|
3107
+ annual to derive+annualize from closed-won — no band defaults, a bare --provider
3108
+ does NOT set ACV, refuses without one); buyers/account (explicit or CRM-derived).
3109
+ Then populate it with scheduled \`enrich acquire --save\` runs (plan-only — apply
3110
+ stays a separate human gate) and watch coverage close. \`status --save\` stamps the
3111
+ timeline so \`report\`/ETA can project how long full coverage will take.
3112
+
3113
+ --source theirstack counts (and \`tam accounts\` LISTS) companies that actually USE a
3114
+ CRM/MAP (set firmographics.technologies, e.g. ["salesforce","hubspot"]) — the real
3115
+ RevOps universe, with real names. --source explorium is a firmographic count only
3116
+ (NAICS/size/geo, no list). See docs/tam.md.`);
3117
+ return;
3118
+ }
3119
+ const name = option(rest, "--name") ?? "default";
3120
+
3121
+ if (sub === "estimate") {
3122
+ const icp = loadIcp(rest);
3123
+ if (!icp) {
3124
+ throw new Error(
3125
+ "tam estimate derives the universe from your ICP — none found (icp.json in cwd, or --icp <path>). " +
3126
+ "Build one: `fullstackgtm icp interview`, or scaffold a workspace with `fullstackgtm init`.",
3127
+ );
3128
+ }
3129
+ // The account universe: an explicit --accounts assumption, or a live count of
3130
+ // matching COMPANIES. `theirstack` is technographic — it counts companies that
3131
+ // actually USE a CRM/MAP (the real RevOps buying signal) and can return the
3132
+ // list. `explorium` is a firmographic count only (NAICS/size/geo, no list).
3133
+ let accounts = numericOption(rest, "--accounts");
3134
+ let accountsSource = "assumption";
3135
+ let capped = false;
3136
+ const probeSource = option(rest, "--source");
3137
+ if (accounts === undefined && probeSource) {
3138
+ if (probeSource === "theirstack") {
3139
+ const total = await theirStackCountCompanies({
3140
+ apiKey: providerKey("theirstack"),
3141
+ filters: icpToTheirStackFilters(icp),
3142
+ });
3143
+ if (total === null) {
3144
+ throw new Error(
3145
+ "TheirStack returned no total for the ICP — check firmographics.technologies (e.g. [\"salesforce\",\"hubspot\"]) " +
3146
+ "and geos/employeeBands, or pass --accounts <n>.",
3147
+ );
3148
+ }
3149
+ accounts = total;
3150
+ accountsSource = "provider:theirstack (uses-CRM)";
3151
+ } else if (probeSource === "explorium") {
3152
+ const probe = await probeExploriumBusinessCount({
3153
+ apiKey: providerKey("explorium"),
3154
+ filters: icpToExploriumBusinessFilters(icp),
3155
+ });
3156
+ if (probe === null) {
3157
+ throw new Error(
3158
+ "Explorium /v1/businesses returned no total for the ICP firmographic — pass --accounts <n>. See docs/tam.md.",
3159
+ );
3160
+ }
3161
+ accounts = probe.total;
3162
+ capped = probe.capped;
3163
+ accountsSource = capped ? "provider:explorium (≥60k cap — floor)" : "provider:explorium";
3164
+ } else {
3165
+ throw new Error(
3166
+ `tam estimate --source must be theirstack (technographic, uses-CRM + a real list) or explorium ` +
3167
+ `(firmographic count only) — got "${probeSource}". pipe0 is a population source, not a count.`,
3168
+ );
3169
+ }
3170
+ }
3171
+ if (accounts === undefined) {
3172
+ throw new Error(
3173
+ "tam estimate needs the account universe size: --source theirstack (count companies that use a CRM), " +
3174
+ "--source explorium (firmographic count), or --accounts <n>.",
3175
+ );
3176
+ }
3177
+ const basis = (option(rest, "--acv-basis") ?? "account") as AcvBasis;
3178
+ if (basis !== "account" && basis !== "buyer") {
3179
+ throw new Error(`tam: --acv-basis must be account|buyer (got "${basis}")`);
3180
+ }
3181
+ const ccPath = option(rest, "--cross-checks");
3182
+ const crossChecks: TamCrossCheck[] = ccPath
3183
+ ? (JSON.parse(readFileSync(resolve(process.cwd(), ccPath), "utf8")) as TamCrossCheck[])
3184
+ : [];
3185
+ // Load the CRM snapshot ONCE if a source is given — reused for the coverage
3186
+ // baseline AND for deriving real ACV / buyers-per-account from the CRM.
3187
+ const hasSource =
3188
+ Boolean(option(rest, "--provider")) ||
3189
+ Boolean(option(rest, "--input")) ||
3190
+ rest.includes("--demo") ||
3191
+ rest.includes("--sample");
3192
+ const snapshot = hasSource ? await readSnapshot(rest) : undefined;
3193
+ const baseline = snapshot ? coverageCountsFromSnapshot(snapshot) : { accounts: 0, contacts: 0 };
3194
+
3195
+ // ACV must be a REAL, ANNUAL figure that the operator confirms — never a band
3196
+ // default, and never silently lifted from the CRM. `--provider` is the
3197
+ // COVERAGE source, not the ACV: the TAM's economics aren't HubSpot's job.
3198
+ // Two confirmed paths: an explicit annual `--acv`, or an opt-in
3199
+ // `--acv-from-crm` that derives the median closed-won amount AND annualizes it
3200
+ // per a REQUIRED `--deal-period` (a monthly MRR deal × 12 = annual ACV).
3201
+ const acvExplicit = numericOption(rest, "--acv");
3202
+ const acvFromCrm = rest.includes("--acv-from-crm");
3203
+ let acvValue: number;
3204
+ let acvSource: string;
3205
+ if (acvExplicit !== undefined) {
3206
+ acvValue = acvExplicit;
3207
+ acvSource = "explicit (annual)";
3208
+ } else if (acvFromCrm) {
3209
+ if (!snapshot) {
3210
+ throw new Error("--acv-from-crm needs a CRM source — add --provider <crm> or --input <snapshot>.");
3211
+ }
3212
+ const period = option(rest, "--deal-period");
3213
+ const factor = period === "monthly" ? 12 : period === "quarterly" ? 4 : period === "annual" ? 1 : undefined;
3214
+ if (factor === undefined) {
3215
+ throw new Error(
3216
+ "--acv-from-crm requires --deal-period monthly|quarterly|annual — deal amounts can be MRR or annual " +
3217
+ "or one-time, and we won't guess (guessing the period is a 4–12× error). e.g. a $15k/mo deal needs " +
3218
+ "`--deal-period monthly` → $180k annual ACV.",
3219
+ );
3220
+ }
3221
+ const derived = deriveAcvFromClosedWon(snapshot);
3222
+ if (!derived) {
3223
+ throw new Error(
3224
+ "--acv-from-crm found no closed-won deals with amounts in the snapshot — pass --acv <annual-usd> instead.",
3225
+ );
3226
+ }
3227
+ acvValue = derived.valueUsd * factor;
3228
+ acvSource =
3229
+ `crm:closed-won (${derived.dealCount} deal${derived.dealCount === 1 ? "" : "s"}, ` +
3230
+ `median $${derived.valueUsd.toLocaleString()}/${period}${factor > 1 ? ` ×${factor}` : ""} = annual)`;
3231
+ } else {
3232
+ throw new Error(
3233
+ "tam estimate needs a real ANNUAL ACV — and it won't guess one. Pass --acv <annual-usd> (your " +
3234
+ "confirmed annualized ACV), or --acv-from-crm --deal-period monthly|quarterly|annual to derive it " +
3235
+ "from closed-won deals. (--provider is your coverage source, not your ACV — it no longer auto-sets it.)",
3236
+ );
3237
+ }
3238
+
3239
+ // Buyers/account — real signal (avg CRM contacts/account) or an explicit
3240
+ // value; never a silent guess. With neither, a clearly-labeled minimal
3241
+ // assumption of 1 (so the contact target = accounts, not an inflated number).
3242
+ let buyersPerAccount = numericOption(rest, "--buyers-per-account");
3243
+ let buyersSource = "explicit";
3244
+ if (buyersPerAccount === undefined) {
3245
+ const db = snapshot ? deriveBuyersPerAccount(snapshot) : null;
3246
+ if (db) {
3247
+ buyersPerAccount = db.value;
3248
+ buyersSource = `crm:avg-contacts/account (${db.accountsSampled} accounts)`;
3249
+ } else {
3250
+ buyersPerAccount = 1;
3251
+ buyersSource = "assumption:1-buyer (no CRM signal)";
3252
+ console.error(
3253
+ "tam: buyers/account not given and no CRM contacts to derive from — assuming 1 buyer/account. " +
3254
+ "Set --buyers-per-account <n> or pass --provider for a real contacts-per-account average.",
3255
+ );
3256
+ }
3257
+ }
3258
+ const createdAt = option(rest, "--today") ?? new Date().toISOString();
3259
+
3260
+ const model = estimateTam({
3261
+ name,
3262
+ icpName: icp.name,
3263
+ accounts,
3264
+ accountsSource,
3265
+ buyersPerAccount,
3266
+ buyersSource,
3267
+ // Capture the ICP filter so `tam status` can classify CRM accounts as
3268
+ // in/out of THIS TAM (not just count everything).
3269
+ targeting: {
3270
+ geos: icp.firmographics?.geos,
3271
+ employeeBands: icp.firmographics?.employeeBands,
3272
+ industries: icp.firmographics?.industries,
3273
+ technologies: icp.firmographics?.technologies,
3274
+ },
3275
+ acv: { basis, valueUsd: acvValue, source: acvSource },
3276
+ crossChecks,
3277
+ baseline,
3278
+ createdAt,
3279
+ });
3280
+ saveTamModel(model);
3281
+ if (rest.includes("--json")) {
3282
+ console.log(JSON.stringify(model, null, 2));
3283
+ return;
3284
+ }
3285
+ const coverage = computeCoverage(model, baseline, createdAt);
3286
+ console.log(coverageToText(model, coverage, null));
3287
+ if (capped) {
3288
+ console.log(
3289
+ `\n⚠ The account count hit Explorium's 60,000 ceiling — the true universe is LARGER. ` +
3290
+ "Treat this TAM as a floor, or narrow the ICP (tighter industry/geo/size) for an exact count.",
3291
+ );
3292
+ }
3293
+ console.log(
3294
+ `\nSaved TAM "${name}". Next: schedule population with ` +
3295
+ `\`fullstackgtm tam populate --name ${name} --cron "0 7 * * 1-5"\`, ` +
3296
+ `then track with \`fullstackgtm tam status --name ${name} --provider hubspot --save\`.`,
3297
+ );
3298
+ return;
3299
+ }
3300
+
3301
+ if (sub === "status") {
3302
+ const model = loadTamModel(name);
3303
+ if (!model) throw new Error(`No TAM "${name}" — run \`fullstackgtm tam estimate\` first.`);
3304
+ const snapshot = await readSnapshot(rest);
3305
+ const at = option(rest, "--today") ?? new Date().toISOString();
3306
+ // Classify CRM accounts against THIS TAM's ICP — only in-TAM accounts count
3307
+ // toward coverage; off-ICP junk lands in out-of-TAM/unknown. Reconciles
3308
+ // bottom-up vs top-down (the in-TAM count is a floor on the real universe).
3309
+ const coverage = classifyCoverage(model, snapshot, at);
3310
+ const save = rest.includes("--save");
3311
+ if (save) appendCoverage(name, coverage);
3312
+ // Include the current reading in the ETA basis whether or not we persisted it.
3313
+ const timeline = save ? readCoverageTimeline(name) : [...readCoverageTimeline(name), coverage];
3314
+ const eta = projectEta(model, timeline);
3315
+ if (rest.includes("--json")) {
3316
+ console.log(JSON.stringify({ model, coverage, eta, saved: save }, null, 2));
3317
+ return;
3318
+ }
3319
+ console.log(coverageToText(model, coverage, eta));
3320
+ if (!save) console.log(`\n(reading not saved — add --save to stamp the timeline for ETA tracking)`);
3321
+ return;
3322
+ }
3323
+
3324
+ if (sub === "report") {
3325
+ const model = loadTamModel(name);
3326
+ if (!model) throw new Error(`No TAM "${name}" — run \`fullstackgtm tam estimate\` first.`);
3327
+ const timeline = readCoverageTimeline(name);
3328
+ const eta = projectEta(model, timeline);
3329
+ const md = tamReportToMarkdown(model, timeline, eta);
3330
+ const out = option(rest, "--out");
3331
+ if (out) {
3332
+ writeFileSync(resolve(process.cwd(), out), md);
3333
+ console.log(`Wrote ${out}.`);
3334
+ } else {
3335
+ console.log(md);
3336
+ }
3337
+ return;
3338
+ }
3339
+
3340
+ if (sub === "populate") {
3341
+ await tamPopulate(name, rest);
3342
+ return;
3343
+ }
3344
+
3345
+ if (sub === "accounts") {
3346
+ await tamAccounts(rest);
3347
+ return;
3348
+ }
3349
+
3350
+ throw new Error(`Unknown tam subcommand "${sub}". Try: estimate | status | report | populate | accounts`);
3351
+ }
3352
+
3353
+ /**
3354
+ * `tam accounts` — pull the REAL target-account list from a technographic source
3355
+ * (companies that actually use a CRM, matched to the ICP). This is the
3356
+ * materialized list the count can't give you: real names + domains you can
3357
+ * review, save, and acquire into the CRM. Read-only (no CRM write); each pulled
3358
+ * company costs provider credits, so `--max` caps the page.
3359
+ */
3360
+ async function tamAccounts(rest: string[]) {
3361
+ const source = option(rest, "--source") ?? "theirstack";
3362
+ if (source !== "theirstack") {
3363
+ throw new Error(`tam accounts --source supports theirstack (only technographic list source wired) — got "${source}".`);
3364
+ }
3365
+ const icp = loadIcp(rest);
3366
+ if (!icp) {
3367
+ throw new Error("tam accounts needs an ICP (icp.json in cwd, or --icp <path>) — its technologies/firmographics are the filter.");
3368
+ }
3369
+ const max = Math.min(numericOption(rest, "--max") ?? 25, 100);
3370
+
3371
+ // Pulling the list costs TheirStack credits (~3/company) — never spend by
3372
+ // surprise. Show the cost up front; --dry-run prices it without spending; a
3373
+ // pull above --max-credits (default 150 ≈ 50 companies) needs --confirm.
3374
+ const usdPerCredit = numericOption(rest, "--usd-per-credit") ?? undefined;
3375
+ const thisCost = theirStackPullCost(max, usdPerCredit);
3376
+ const usdStr = (c: { usd?: number }): string => (c.usd !== undefined ? ` (~$${c.usd.toLocaleString()})` : "");
3377
+ const model = loadTamModel(option(rest, "--name") ?? "default");
3378
+ const fullUniverse = model?.universe.accounts;
3379
+ const fullCost = fullUniverse ? theirStackPullCost(fullUniverse, usdPerCredit) : undefined;
3380
+ const costLine =
3381
+ `Pull cost: up to ${max} companies ≈ ${thisCost.credits.toLocaleString()} TheirStack credits${usdStr(thisCost)}` +
3382
+ (fullCost
3383
+ ? `. Full TAM (~${fullUniverse!.toLocaleString()} accounts) ≈ ${fullCost.credits.toLocaleString()} credits${usdStr(fullCost)} to materialize.`
3384
+ : ".");
3385
+
3386
+ if (rest.includes("--dry-run")) {
3387
+ console.log(`${costLine}\n(dry run — nothing pulled, 0 credits spent. Add --usd-per-credit <rate> for a $ estimate.)`);
3388
+ return;
3389
+ }
3390
+ const maxCredits = numericOption(rest, "--max-credits") ?? 150;
3391
+ if (thisCost.credits > maxCredits && !rest.includes("--confirm")) {
3392
+ console.error(
3393
+ `${costLine}\nThis pull (${thisCost.credits.toLocaleString()} credits) exceeds the --max-credits guard (${maxCredits}). ` +
3394
+ "Re-run with --confirm to spend it, lower --max, or --dry-run to just price it.",
3395
+ );
3396
+ process.exitCode = 2;
3397
+ return;
3398
+ }
3399
+ console.error(costLine);
3400
+
3401
+ const { companies: raw, total } = await theirStackSearchCompanies({
3402
+ apiKey: providerKey("theirstack"),
3403
+ filters: icpToTheirStackFilters(icp),
3404
+ limit: max,
3405
+ });
3406
+ // A company carries its WHOLE tech stack (often 200+ slugs) — noise. Keep only
3407
+ // the ICP-targeted technologies (the CRMs/MAPs that put it in the list).
3408
+ const targeted = new Set((icp.firmographics.technologies ?? []).map((t) => t.trim().toLowerCase()));
3409
+ const companies = raw.map((c) => ({
3410
+ ...c,
3411
+ technologies: (c.technologies ?? []).filter((t) => targeted.has(t.toLowerCase())),
3412
+ }));
3413
+ const out = option(rest, "--out");
3414
+ if (rest.includes("--json")) {
3415
+ console.log(JSON.stringify({ total, returned: companies.length, companies }, null, 2));
3416
+ return;
3417
+ }
3418
+ if (out) {
3419
+ const header = "name,domain,employee_count,country,linkedin_url,matched_technologies";
3420
+ const esc = (v: string | number | undefined): string => {
3421
+ const s = v === undefined ? "" : String(v);
3422
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
3423
+ };
3424
+ const rows = companies.map((c) =>
3425
+ [c.name, c.domain, c.employeeCount, c.countryCode, c.linkedinUrl, (c.technologies ?? []).join("|")]
3426
+ .map(esc)
3427
+ .join(","),
3428
+ );
3429
+ writeFileSync(resolve(process.cwd(), out), `${[header, ...rows].join("\n")}\n`);
3430
+ console.log(`Wrote ${companies.length} companies to ${out}${total !== null ? ` (of ${total.toLocaleString()} matching)` : ""}.`);
3431
+ return;
3432
+ }
3433
+ console.log(
3434
+ `${companies.length} companies${total !== null ? ` of ${total.toLocaleString()} matching the ICP` : ""} ` +
3435
+ `(uses ${[...targeted].join("/")}):\n`,
3436
+ );
3437
+ for (const c of companies) {
3438
+ console.log(
3439
+ ` ${(c.name ?? "?").padEnd(32)} ${(c.domain ?? "").padEnd(28)} ${String(c.employeeCount ?? "?").padStart(6)} emp ` +
3440
+ `[${(c.technologies ?? []).join(",")}]`,
3441
+ );
3442
+ }
3443
+ console.log(
3444
+ `\nNext: stage them with \`fullstackgtm enrich ingest <file.csv> --source clay --objects companies\` ` +
3445
+ "→ `enrich acquire` to create governed account records (or re-run with --out <file.csv>).",
3446
+ );
3447
+ }
3448
+
3449
+ /**
3450
+ * `tam populate` — wire the scheduled population of a TAM: a recurring
3451
+ * `enrich acquire --source <s> --save` that queues a needs_approval lead plan
3452
+ * each firing (apply stays a separate human gate; the meter is charged only at
3453
+ * apply). Delegates to the scheduler so the cron + allowlist (incl. the --save
3454
+ * guard) are validated the same way as any `schedule add`.
3455
+ */
3456
+ async function tamPopulate(name: string, rest: string[]) {
3457
+ const model = loadTamModel(name);
3458
+ if (!model) throw new Error(`No TAM "${name}" — run \`fullstackgtm tam estimate\` first.`);
3459
+ const cron = option(rest, "--cron");
3460
+ if (!cron) {
3461
+ throw new Error('tam populate requires --cron "<expr>" (e.g. "0 7 * * 1-5" = 7am on weekdays)');
3462
+ }
3463
+ const source = option(rest, "--source") ?? "pipe0";
3464
+ const provider = option(rest, "--provider") ?? "hubspot";
3465
+ const label = option(rest, "--label") ?? `tam-${name}`;
3466
+
3467
+ const acquireCmd = `enrich acquire --source ${source} --provider ${provider} --save`;
3468
+ // Reuse the scheduler: validates the cron, the allowlist, and the --save guard.
3469
+ await scheduleCommand(["add", acquireCmd, "--cron", cron, "--label", label]);
3470
+
3471
+ const remainingAccounts = Math.max(0, model.universe.accounts - model.baseline.accounts);
3472
+ console.log(
3473
+ `\nPopulating TAM "${name}": each firing queues a needs_approval lead plan — apply stays gated, the ` +
3474
+ `acquire meter is charged only at apply. ~${remainingAccounts.toLocaleString()} of ` +
3475
+ `${model.universe.accounts.toLocaleString()} accounts remain to reach the universe ` +
3476
+ `($${model.tamUsd.toLocaleString()} TAM).`,
3477
+ );
3478
+ console.log(
3479
+ "Next: `fullstackgtm schedule install` to activate, then each cycle " +
3480
+ "`fullstackgtm plans list` → `plans approve` → `apply`. Track progress with " +
3481
+ `\`fullstackgtm tam status --name ${name} --provider ${provider} --save\`.`,
3482
+ );
3483
+ // Cost lives in two separate places — be explicit so neither surprises.
3484
+ const ts = theirStackPullCost(model.universe.accounts);
3485
+ console.log(
3486
+ `\nCost: population spend is the acquire provider's (${source}), governed by the per-profile acquire meter ` +
3487
+ "(records + $/day caps in enrich.config.json) — not TheirStack. Separately, materializing the account LIST " +
3488
+ `from TheirStack (\`tam accounts\`) is ~${ts.credits.toLocaleString()} credits for the full ${model.universe.accounts.toLocaleString()}-account ` +
3489
+ "universe; `tam accounts --dry-run [--usd-per-credit <r>]` prices any pull without spending.",
3490
+ );
3491
+ }
3492
+
3493
+ /**
3494
+ * `init` — scaffold a GTM workspace from cold scratch: a starter icp.json, an
3495
+ * enrich.config.json (acquire preset + a visible assign seam), and a PLAYBOOK
3496
+ * wired with the chosen source/provider that points at the recipes. Pure
3497
+ * file-writer: no network, never overwrites without --force.
3498
+ */
3499
+ function initCommand(args: string[]) {
3500
+ if (args.includes("--help") || args.includes("-h")) {
3501
+ console.log(`Usage:
3502
+ fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
3503
+
3504
+ Scaffolds a workspace so the first acquire/signals/judge/draft commands work:
3505
+ icp.json starter ICP (edit, or rebuild with \`icp interview\`)
3506
+ enrich.config.json acquire preset for --source + a placeholder assign policy
3507
+ PLAYBOOK.md the cold-start + outbound-loop recipes wired for this workspace
3508
+
3509
+ The CLI ships governed primitives; your coding agent is the orchestrator. See
3510
+ docs/recipes.md for the full play set. Existing files are kept unless --force.`);
3511
+ return;
3512
+ }
3513
+ const source = (option(args, "--source") ?? "pipe0") as InitSource;
3514
+ if (!["pipe0", "explorium", "linkedin"].includes(source)) {
3515
+ throw new Error(`init: --source must be pipe0|explorium|linkedin (got "${source}")`);
3516
+ }
3517
+ const provider = (option(args, "--provider") ?? "hubspot") as InitProvider;
3518
+ if (!["hubspot", "salesforce"].includes(provider)) {
3519
+ throw new Error(`init: --provider must be hubspot|salesforce (got "${provider}")`);
3520
+ }
3521
+ const outDir = resolve(process.cwd(), option(args, "--out") ?? ".");
3522
+ const force = args.includes("--force");
3523
+ const current = activeProfile();
3524
+ const profile = current === DEFAULT_PROFILE ? undefined : current;
3525
+
3526
+ const files = scaffoldWorkspace({ source, provider, profile });
3527
+ const wrote: string[] = [];
3528
+ const kept: string[] = [];
3529
+ for (const file of files) {
3530
+ const path = resolve(outDir, file.path);
3531
+ if (existsSync(path) && !force) {
3532
+ kept.push(file.path);
3533
+ continue;
3534
+ }
3535
+ writeFileSync(path, file.content);
3536
+ wrote.push(file.path);
3537
+ }
3538
+
3539
+ console.log(
3540
+ `Scaffolded workspace (${provider}, discovery via ${source}${profile ? `, profile ${profile}` : ""}).`,
3541
+ );
3542
+ if (wrote.length) console.log(` wrote: ${wrote.join(", ")}`);
3543
+ if (kept.length) console.log(` kept (use --force to overwrite): ${kept.join(", ")}`);
3544
+ console.log(
3545
+ "\nNext: edit icp.json + set acquire.assign.ownerId in enrich.config.json, then follow PLAYBOOK.md\n" +
3546
+ "(full recipe set: docs/recipes.md).",
3547
+ );
3548
+ }
3549
+
2843
3550
  /**
2844
3551
  * `icp` — develop and inspect the Ideal Customer Profile that targets acquire.
2845
3552
  * The CLI can't run AskUserQuestion itself; `icp interview` emits the question
@@ -2929,12 +3636,20 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
2929
3636
  const unjudged = signalRun.signals.filter((s) => !s.judgedBy);
2930
3637
  if (unjudged.length === 0) throw new Error(`Signal run "${signalRun.runLabel}" has no unjudged signals.`);
2931
3638
 
2932
- // 2) Optional inputs: ICP (may be undefined), snapshot (only --with-history),
2933
- // outcomes + config from the signal store / DEFAULT_SIGNALS_CONFIG.
3639
+ // 2) Optional inputs: ICP (may be undefined), snapshot, outcomes + config.
3640
+ // The snapshot is loaded whenever a source is given (--provider/--input/
3641
+ // --demo/--sample) — it resolves each decision's CRM target (accountId +
3642
+ // best contact) so `draft` can write against a real record. --with-history
3643
+ // additionally enables the memory + fit scoring inputs.
2934
3644
  const icp = loadIcp(rest);
2935
3645
  const config: SignalsConfig = DEFAULT_SIGNALS_CONFIG;
2936
3646
  const outcomes = await signalStore.listOutcomes();
2937
- const snapshot = withHistory ? await readSnapshot(rest) : undefined;
3647
+ const hasSnapshotSource =
3648
+ Boolean(option(rest, "--provider")) ||
3649
+ Boolean(option(rest, "--input")) ||
3650
+ rest.includes("--demo") ||
3651
+ rest.includes("--sample");
3652
+ const snapshot = withHistory || hasSnapshotSource ? await readSnapshot(rest) : undefined;
2938
3653
 
2939
3654
  // 3) Resolve the LLM seam ONLY if a key already exists (deterministic baseline
2940
3655
  // otherwise — never PROMPT here; judge must run key-free).
@@ -3037,11 +3752,15 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
3037
3752
  }
3038
3753
 
3039
3754
  // The golden-set gate (default). Resolve "default" BEFORE any file read.
3755
+ // The default set is graded on its own pinned clock (its firstSeen stamps
3756
+ // are relative to DEFAULT_GOLDEN_NOW_ISO) — wall time would let freshness
3757
+ // decay flip the "fresh → send" rows as the calendar advances.
3040
3758
  const rows =
3041
3759
  goldenArg === "default"
3042
3760
  ? DEFAULT_GOLDEN_SET
3043
3761
  : parseGoldenSet(readFileSync(resolve(process.cwd(), goldenArg), "utf8"));
3044
- const result = await gradeJudge(rows, defaultJudgeFn({ icp: loadIcp(rest), now: new Date() }));
3762
+ const gradeNow = goldenArg === "default" ? new Date(DEFAULT_GOLDEN_NOW_ISO) : new Date();
3763
+ const result = await gradeJudge(rows, defaultJudgeFn({ icp: loadIcp(rest), now: gradeNow }));
3045
3764
  if (asJson) {
3046
3765
  console.log(JSON.stringify(result, null, 2));
3047
3766
  } else {
@@ -3116,6 +3835,19 @@ async function acquireFromApi(
3116
3835
  throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0 | linkedin).`);
3117
3836
  }
3118
3837
 
3838
+ // Surface a zero-discovery result LOUDLY rather than emit a silent empty plan.
3839
+ // A provider that returns 0 with no error usually means an over-narrow ICP
3840
+ // filter (most often the industry/seniority vocab) — not "no market exists".
3841
+ const discoveredCount = prospects.length;
3842
+ if (discoveredCount === 0) {
3843
+ console.error(
3844
+ `enrich acquire: ${disc.provider} discovered 0 prospects for this ICP — the plan will be empty. ` +
3845
+ "This is usually an over-narrow filter, not an empty market: check the ICP's industry and seniority " +
3846
+ "(provider vocab is finicky), widen one constraint, or run `fullstackgtm icp show` to inspect the " +
3847
+ "generated discovery filters. (Distinct from dedup: nothing was returned to filter.)",
3848
+ );
3849
+ }
3850
+
3119
3851
  // 2. ICP fit scoring BEFORE paying for emails — only above-threshold prospects
3120
3852
  // proceed to (credit-spending) email resolution.
3121
3853
  if (icp) {
@@ -3123,6 +3855,12 @@ async function acquireFromApi(
3123
3855
  prospects = prospects
3124
3856
  .map((p) => ({ ...p, fitScore: scoreProspectAgainstIcp(p, icp).score }))
3125
3857
  .filter((p) => (p.fitScore ?? 0) >= threshold);
3858
+ if (discoveredCount > 0 && prospects.length === 0) {
3859
+ console.error(
3860
+ `enrich acquire: all ${discoveredCount} discovered prospect(s) scored below the ICP fit threshold ` +
3861
+ `(${fitThreshold(icp)}) — none qualified. Loosen the ICP persona or lower scoring.threshold.`,
3862
+ );
3863
+ }
3126
3864
  }
3127
3865
 
3128
3866
  // 2.5 Pre-email dedup — drop prospects already in the CRM (name+domain match
@@ -4254,7 +4992,13 @@ and (if the signing key is present) the signature.`);
4254
4992
 
4255
4993
  async function apply(args: string[]) {
4256
4994
  const provider = option(args, "--provider");
4257
- if (!provider) throw new Error("apply requires --provider <name>");
4995
+ const channel = option(args, "--channel");
4996
+ if (!provider && !channel) {
4997
+ throw new Error("apply requires --provider <name> (CRM) or --channel <id> (e.g. outbox)");
4998
+ }
4999
+ if (provider && channel) {
5000
+ throw new Error("apply takes --provider OR --channel, not both — a plan applies to one target.");
5001
+ }
4258
5002
 
4259
5003
  const planId = option(args, "--plan-id");
4260
5004
  const planPath = option(args, "--plan");
@@ -4356,7 +5100,9 @@ async function apply(args: string[]) {
4356
5100
  }
4357
5101
  }
4358
5102
 
4359
- const connector = await connectorFor(provider, args);
5103
+ // A channel (e.g. outbox) renders approved ops to a local artifact and
5104
+ // transmits nothing; a CRM provider writes records. Same governed apply path.
5105
+ const connector = channel ? createChannelConnector(channel) : await connectorFor(provider!, args);
4360
5106
  const run = await applyPatchPlan(connector, plan, {
4361
5107
  approvedOperationIds,
4362
5108
  valueOverrides,
@@ -4878,17 +5624,17 @@ async function login(args: string[]) {
4878
5624
  console.log(`Stored Apollo API key in ${credentialsPath()}. \`fullstackgtm enrich append|refresh\` use it automatically.`);
4879
5625
  return;
4880
5626
  }
4881
- if (provider === "pipe0" || provider === "explorium" || provider === "heyreach") {
5627
+ if (provider === "pipe0" || provider === "explorium" || provider === "heyreach" || provider === "theirstack") {
4882
5628
  rejectArgvSecret(args, "--token", "--key", "--api-key");
4883
5629
  const key = await readSecret(`${provider} API key`);
4884
5630
  if (!key) throw new Error(`No ${provider} key provided.`);
4885
5631
  // No free auth-health endpoint; validating would spend credits, so the key
4886
- // is stored as-is and validated on the first `enrich acquire` pull.
5632
+ // is stored as-is and validated on the first pull.
4887
5633
  const stamp = new Date().toISOString();
4888
5634
  storeCredential(provider, { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
4889
- console.log(
4890
- `Stored ${provider} API key in ${credentialsPath()}. \`fullstackgtm enrich acquire\` uses it automatically (validated on first pull).`,
4891
- );
5635
+ const usedBy =
5636
+ provider === "theirstack" ? "`fullstackgtm tam estimate --source theirstack`" : "`fullstackgtm enrich acquire`";
5637
+ console.log(`Stored ${provider} API key in ${credentialsPath()}. ${usedBy} uses it automatically (validated on first pull).`);
4892
5638
  return;
4893
5639
  }
4894
5640
  if (provider !== "hubspot") {
@@ -5206,6 +5952,14 @@ export async function runCli(argv: string[]) {
5206
5952
  await enrichCommand(args);
5207
5953
  return;
5208
5954
  }
5955
+ if (command === "init") {
5956
+ initCommand(args);
5957
+ return;
5958
+ }
5959
+ if (command === "tam") {
5960
+ await tamCommand(args);
5961
+ return;
5962
+ }
5209
5963
  if (command === "icp") {
5210
5964
  await icpCommand(args);
5211
5965
  return;