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/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import { createHubspotConnector } from "./connectors/hubspot.js";
8
8
  import { DEFAULT_LOOPBACK_PORT, openInBrowser, runHubspotLoopbackLogin, validateHubspotToken, } from "./connectors/hubspotAuth.js";
9
9
  import { createSalesforceConnector } from "./connectors/salesforce.js";
10
10
  import { createStripeConnector } from "./connectors/stripe.js";
11
+ import { createChannelConnector } from "./connectors/outboxChannel.js";
11
12
  import { pollSalesforceDeviceLogin, startSalesforceDeviceLogin, validateSalesforceToken, } from "./connectors/salesforceAuth.js";
12
13
  import { activeProfile, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotConnection, resolveSalesforceConnection, setActiveProfile, storeCredential, } from "./credentials.js";
13
14
  import { generateDemoSnapshot } from "./demo.js";
@@ -32,16 +33,20 @@ import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
32
33
  import { DEFAULT_RUBRIC, classifyCallLlm, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
33
34
  import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, builtinAcquirePreset, builtinEnrichPreset, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor, } from "./enrich.js";
34
35
  import { loadMeter, recordConsumption, remaining, } from "./acquireMeter.js";
35
- import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspects, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys, } from "./connectors/prospectSources.js";
36
+ import { scaffoldWorkspace, } from "./init.js";
37
+ import { appendCoverage, computeCoverage, coverageCountsFromSnapshot, classifyCoverage, coverageToText, deriveAcvFromClosedWon, deriveBuyersPerAccount, estimateTam, loadTamModel, projectEta, readCoverageTimeline, saveTamModel, tamReportToMarkdown, } from "./tam.js";
38
+ import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspects, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, probeExploriumBusinessCount, prospectIdentityKeys, } from "./connectors/prospectSources.js";
39
+ import { theirStackCountCompanies, theirStackPullCost, theirStackSearchCompanies, } from "./connectors/theirstack.js";
36
40
  import { loadSeen, recordSeen } from "./acquireSeen.js";
37
- import { reportCounts, reportEvent } from "./runReport.js";
41
+ import { reportCounts, reportCrm, reportEvent, reportFindings } from "./runReport.js";
38
42
  import { createLinkedInProvider, discoverLinkedInProspects } from "./acquireLinkedIn.js";
39
- import { fitThreshold, icpFromAnswers, icpToCrustdataFilters, icpToExploriumFilters, parseIcp, scoreProspectAgainstIcp, INTERVIEW_SPEC, } from "./icp.js";
40
- import { buildSignalsFromAts, computeWeights, createFileSignalStore, DEFAULT_SIGNALS_CONFIG, dedupeSignals, loadSignalsConfig, makeOutcome, normalizeAccountDomain, SIGNAL_BUCKETS, signalRunId, signalId, } from "./signals.js";
43
+ import { fitThreshold, icpFromAnswers, icpToCrustdataFilters, icpToExploriumBusinessFilters, icpToExploriumFilters, icpToTheirStackFilters, parseIcp, scoreProspectAgainstIcp, INTERVIEW_SPEC, } from "./icp.js";
44
+ import { buildSignalsFromAts, computeWeights, createFileSignalStore, DEFAULT_SIGNALS_CONFIG, dedupeSignals, loadSignalsConfig, makeOutcome, normalizeAccountDomain, SIGNAL_BUCKETS, signalRunId, signalsSpoolDir, stagedRowToSignal, } from "./signals.js";
41
45
  import { fetchAtsJobs } from "./connectors/atsBoards.js";
46
+ import { getSignalSource, listSignalSources } from "./connectors/signalSources.js";
42
47
  import { createFileJudgeStore, DEFAULT_JUDGE_PROMPT, judgeRunId, judgeSignals, } from "./judge.js";
43
48
  import { authorOpeners, DEFAULT_DRAFT_PROMPT, DRAFT_CHANNELS, draft, } from "./draft.js";
44
- import { DEFAULT_GOLDEN_SET, DEFAULT_MIN_ACCURACY, defaultJudgeFn, gradeAgainstOutcomes, gradeJudge, parseGoldenSet, } from "./judgeEval.js";
49
+ import { DEFAULT_GOLDEN_NOW_ISO, DEFAULT_GOLDEN_SET, DEFAULT_MIN_ACCURACY, defaultJudgeFn, gradeAgainstOutcomes, gradeJudge, parseGoldenSet, } from "./judgeEval.js";
45
50
  import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, } from "./enrichApollo.js";
46
51
  import { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, assertSingleLineLabel, hasControlChar, scheduleId, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, } from "./schedule.js";
47
52
  import { resolveRecord } from "./resolve.js";
@@ -61,12 +66,16 @@ Usage:
61
66
  fullstackgtm login salesforce --instance-url <url> [--no-validate]
62
67
  fullstackgtm login stripe [--no-validate]
63
68
  fullstackgtm login anthropic | openai store an LLM API key for call parse/score
64
- 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>
69
+ 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>
65
70
 
66
71
  Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
67
72
  the process list and shell history. Pipe them on stdin or enter them at the
68
73
  interactive prompt:
69
74
  echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot
75
+ fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
76
+ cold start: scaffold icp.json + enrich.config.json + a
77
+ PLAYBOOK wired for this workspace (the CLI ships primitives,
78
+ your agent is the orchestrator — see docs/recipes.md)
70
79
  fullstackgtm snapshot [source options] [--since <iso>] [--out <path> | --archive <dir>]
71
80
  fullstackgtm audit [source options] [audit options] [--save]
72
81
  fullstackgtm report [source options] [audit options] [report options]
@@ -102,6 +111,16 @@ Usage:
102
111
  against the stored capture it cites before it's accepted — then
103
112
  compute deterministic front states and drift, render the field
104
113
  report. refresh = capture → classify → drift → report in one step
114
+ 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]
115
+ fullstackgtm tam status [--name <n>] <source options> [--save] [--json]
116
+ fullstackgtm tam report [--name <n>] [--out <path>]
117
+ fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--label <l>]
118
+ size the reachable market FROM the ICP (a real account count ×
119
+ ACV; buyers/account = the contact population target), then fill
120
+ it: populate schedules plan-only enrich acquire --save (apply
121
+ stays gated), status --save stamps a coverage timeline, report
122
+ projects a burn-up + ETA. estimate --source probes the provider's
123
+ ICP-match count (else --accounts), always labeled provider-vs-assumption
105
124
  fullstackgtm enrich append [--source apollo] [--objects companies,contacts] [--save] [--config <path>] [source options]
106
125
  fullstackgtm enrich refresh [--source apollo] [--stale-days <n>] [--save] [--config <path>] [source options]
107
126
  fullstackgtm enrich ingest <file.csv|payload.json> --source clay [--run-label <label>]
@@ -193,6 +212,7 @@ Usage:
193
212
  fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]
194
213
  fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
195
214
  fullstackgtm apply --plan-id <id> --provider <name>
215
+ fullstackgtm apply --plan-id <id> --channel outbox (render approved openers; transmits nothing)
196
216
  fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [options]
197
217
  fullstackgtm audit-log export [--out <path>] | verify --in <path> tamper-evident apply-run record
198
218
  fullstackgtm rules [--json]
@@ -269,6 +289,16 @@ Safety:
269
289
  and never writes requires_human_* placeholders without a --value override.`;
270
290
  }
271
291
  const HELP = {
292
+ // Get started
293
+ init: {
294
+ summary: "scaffold a workspace (icp.json + enrich.config.json + PLAYBOOK)",
295
+ phase: "Setup",
296
+ synopsis: [
297
+ "fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]",
298
+ ],
299
+ detail: "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).",
300
+ seeAlso: ["icp", "enrich", "signals"],
301
+ },
272
302
  // Setup & health
273
303
  login: {
274
304
  summary: "connect a provider or LLM key (secrets via stdin/env, never argv)",
@@ -432,6 +462,7 @@ const HELP = {
432
462
  phase: "Govern / Verify",
433
463
  synopsis: [
434
464
  "fullstackgtm apply --plan-id <id> --provider <name>",
465
+ "fullstackgtm apply --plan-id <id> --channel outbox (render approved drafted openers to the outbox; transmits nothing)",
435
466
  "fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [--value <opId>=<v>]",
436
467
  ],
437
468
  detail: "The only verb that mutates a CRM. Writes only operations approved via `plans approve` or `--approve`, with compare-and-set and readback. Never writes requires_human_* placeholders without a --value override.",
@@ -464,19 +495,26 @@ const HELP = {
464
495
  phase: "Intelligence",
465
496
  synopsis: ["fullstackgtm market init|capture|classify|worksheet|observe|fronts|axes|overlay|scale|report|refresh … (run `market --help` for full options)"],
466
497
  detail: "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.",
467
- seeAlso: [],
498
+ seeAlso: ["tam"],
499
+ },
500
+ tam: {
501
+ summary: "size the reachable market from your ICP, then populate + track coverage",
502
+ phase: "Intelligence",
503
+ synopsis: ["fullstackgtm tam estimate|status|report|populate … (run `tam --help` for full options)"],
504
+ detail: "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.",
505
+ seeAlso: ["icp", "enrich", "schedule", "market"],
468
506
  },
469
507
  // Outbound intelligence — signals → judge → draft (the GTM brain)
470
508
  signals: {
471
- summary: "detect fresh buying triggers (ATS hiring + staged ingest), ranked",
509
+ summary: "detect fresh buying triggers (ATS hiring + source connectors), ranked",
472
510
  phase: "Detect",
473
511
  synopsis: [
474
- "fullstackgtm signals fetch [--bucket job,…] [--source greenhouse,lever,ashby] [--watchlist <path|crm:seg>] [--keywords …] [--from <file.json>] [--save]",
512
+ "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]",
475
513
  "fullstackgtm signals list [--since 7d] [--bucket b] [--account d] [--unjudged]",
476
514
  "fullstackgtm signals outcome --account <d> [--touch <id>] --result replied|meeting|bounced|no_reply",
477
515
  "fullstackgtm signals weights [--explain]",
478
516
  ],
479
- detail: "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`.",
517
+ detail: "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`.",
480
518
  seeAlso: ["icp", "draft"],
481
519
  },
482
520
  icp: {
@@ -502,18 +540,19 @@ const HELP = {
502
540
  };
503
541
  // Verbs that print their own richer multi-subcommand help; runCli routes their
504
542
  // `--help` to themselves, so commandHelp() only renders these via `help <verb>`.
505
- const BESPOKE_HELP = ["call", "market", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
543
+ const BESPOKE_HELP = ["init", "call", "market", "tam", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
506
544
  // Lifecycle-grouped front door. One line per verb, organized by the
507
545
  // Prevent→Detect→Remediate→Verify loop so a new user sees ~6 jobs, not 22 verbs.
508
546
  function shortUsage() {
509
547
  const groups = [
548
+ ["Get started", ["init"]],
510
549
  ["Setup & health", ["login", "logout", "doctor", "profiles", "health"]],
511
550
  ["Detect — read-only", ["audit", "report", "snapshot", "diff", "rules"]],
512
551
  ["Prevent — gate writes", ["resolve"]],
513
552
  ["Remediate — governed writes", ["fix", "bulk-update", "dedupe", "reassign", "enrich"]],
514
553
  ["Calls → evidence", ["call"]],
515
554
  ["Govern — the plan/apply spine", ["suggest", "plans", "apply", "audit-log", "merge"]],
516
- ["Market intelligence", ["market"]],
555
+ ["Market intelligence", ["market", "tam"]],
517
556
  ["Outbound — signals → judge → draft", ["signals", "icp", "draft"]],
518
557
  ["Schedule — make it continuous", ["schedule"]],
519
558
  ];
@@ -775,6 +814,66 @@ function auditNextStep(args, plan) {
775
814
  ` fullstackgtm apply --plan-id ${plan.id}${providerFlag}`,
776
815
  ].join("\n");
777
816
  }
817
+ /**
818
+ * Resolve the live HubSpot account's record-URL base (e.g.
819
+ * `https://app-na2.hubspot.com/contacts/<portalId>/record`) and report it on the
820
+ * run so the dashboard can deep-link findings. Best-effort: any failure is
821
+ * swallowed — deep-links are a nicety, never a reason to fail the audit.
822
+ */
823
+ async function reportHubspotDeepLinkBase() {
824
+ try {
825
+ const connection = await resolveHubspotConnection();
826
+ if (!connection?.accessToken)
827
+ return;
828
+ const response = await fetch("https://api.hubapi.com/account-info/v3/details", {
829
+ headers: { Authorization: `Bearer ${connection.accessToken}` },
830
+ });
831
+ if (!response.ok)
832
+ return;
833
+ const info = (await response.json());
834
+ if (!info.portalId || !info.uiDomain)
835
+ return;
836
+ reportCrm({
837
+ provider: "hubspot",
838
+ recordUrlBase: `https://${info.uiDomain}/contacts/${info.portalId}/record`,
839
+ });
840
+ }
841
+ catch {
842
+ // deep-link base is best-effort
843
+ }
844
+ }
845
+ /**
846
+ * When paired (a broker credential exists), hand the local HubSpot token to the
847
+ * hosted deployment so its Integrations page shows HubSpot connected and the
848
+ * backend can sync on its own. The token lands in the same place a manual
849
+ * in-app connect would (encrypted on the org's integration). Best-effort:
850
+ * never blocks or fails the audit.
851
+ */
852
+ async function registerHubspotWithBroker() {
853
+ try {
854
+ const broker = getCredential("broker");
855
+ if (!broker?.baseUrl || !broker.accessToken)
856
+ return; // only when paired
857
+ const connection = await resolveHubspotConnection();
858
+ if (!connection?.accessToken)
859
+ return;
860
+ const response = await fetch(`${broker.baseUrl.replace(/\/+$/, "")}/api/cli/integration`, {
861
+ method: "POST",
862
+ headers: {
863
+ Authorization: `Bearer ${broker.accessToken}`,
864
+ "Content-Type": "application/json",
865
+ },
866
+ body: JSON.stringify({ provider: "hubspot", token: connection.accessToken }),
867
+ signal: AbortSignal.timeout(8000),
868
+ });
869
+ if (response.ok) {
870
+ console.error(`Registered HubSpot with ${broker.baseUrl} — it now shows on the dashboard's Integrations page.`);
871
+ }
872
+ }
873
+ catch {
874
+ // registering the connection is best-effort; never affect the audit
875
+ }
876
+ }
778
877
  async function audit(args) {
779
878
  const threshold = failOnThreshold(args);
780
879
  const loaded = loadConfig(option(args, "--config") ?? undefined);
@@ -793,6 +892,31 @@ async function audit(args) {
793
892
  critical: plan.findings.filter((f) => f.severity === "critical").length,
794
893
  warning: plan.findings.filter((f) => f.severity === "warning").length,
795
894
  });
895
+ // Row-level detail for the hosted dashboard: IDs + issue type only (no field
896
+ // values). Enrich each finding with its proposed op's field/operation when one
897
+ // is linked, so the dashboard can show "what" and "where" without "the value".
898
+ const opByFinding = new Map();
899
+ for (const op of plan.operations ?? []) {
900
+ for (const findingId of op.findingIds ?? []) {
901
+ if (!opByFinding.has(findingId)) {
902
+ opByFinding.set(findingId, { field: op.field, operation: op.operation });
903
+ }
904
+ }
905
+ }
906
+ reportFindings(plan.findings.map((f) => ({
907
+ objectType: f.objectType,
908
+ objectId: f.objectId,
909
+ severity: f.severity,
910
+ ruleId: f.ruleId,
911
+ ...opByFinding.get(f.id),
912
+ })));
913
+ // When auditing a live HubSpot, emit the account's record-URL base so the
914
+ // hosted dashboard can deep-link each finding to the real CRM record. Best
915
+ // effort and HubSpot-only for now; never blocks or fails the audit.
916
+ if (option(args, "--provider") === "hubspot") {
917
+ await reportHubspotDeepLinkBase();
918
+ await registerHubspotWithBroker();
919
+ }
796
920
  const out = option(args, "--out");
797
921
  if (out) {
798
922
  writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(plan, null, 2)}\n`);
@@ -2049,7 +2173,13 @@ function formatEnrichCounts(counts, ambiguities) {
2049
2173
  /** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
2050
2174
  /** Provider API key: env override first, then the credential store (`login`). */
2051
2175
  function providerKey(provider) {
2052
- const envName = provider === "explorium" ? "EXPLORIUM_API_KEY" : provider === "pipe0" ? "PIPE0_API_KEY" : "HEYREACH_API_KEY";
2176
+ const envName = provider === "explorium"
2177
+ ? "EXPLORIUM_API_KEY"
2178
+ : provider === "pipe0"
2179
+ ? "PIPE0_API_KEY"
2180
+ : provider === "theirstack"
2181
+ ? "THEIRSTACK_API_KEY"
2182
+ : "HEYREACH_API_KEY";
2053
2183
  if (process.env[envName])
2054
2184
  return process.env[envName];
2055
2185
  const stored = getCredential(provider);
@@ -2147,14 +2277,22 @@ async function signalsCommand(args) {
2147
2277
  // anywhere in argv (`signals --help` and `signals fetch --help` both land here).
2148
2278
  if (!sub || args.includes("--help") || args.includes("-h")) {
2149
2279
  console.log(`Usage:
2150
- fullstackgtm signals fetch [--bucket job,funding,...] [--source greenhouse,lever,ashby] [--watchlist <path|crm:segment>] [--keywords "growth,revops"] [--from <file.json>] [--config <path>] [--save]
2280
+ 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]
2151
2281
  fullstackgtm signals list [--since 7d] [--bucket b] [--account d] [--unjudged]
2152
2282
  fullstackgtm signals outcome --account <domain> [--touch <id>] --result replied|meeting|bounced|no_reply
2153
2283
  fullstackgtm signals weights [--explain]
2154
2284
 
2155
2285
  Detect fresh buying triggers and rank them. \`fetch\` is read-only re: CRM — it
2156
2286
  NEVER emits a patch plan; --save persists only the local signal ledger (used by
2157
- \`icp judge\`). ATS adapters are no-auth, so no credential flags exist.`);
2287
+ \`icp judge\`). ATS adapters are no-auth. Source connectors (--connector) pull
2288
+ from connected platforms: file (local JSON/JSONL spool, no auth), serpapi-news
2289
+ (API key via env/login), hubspot-forms (reuses the HubSpot login). Secrets come
2290
+ from the credential ladder, never argv; --connector-opt carries non-secret knobs
2291
+ (e.g. path=… for file, bucket=company for serpapi-news).
2292
+
2293
+ \`--connector file\` with no path reads the conventional webhook landing zone
2294
+ (${signalsSpoolDir()}) — every *.jsonl in it. Point a webhook receiver there
2295
+ (one row per event); see docs/signal-spool-format.md.`);
2158
2296
  return;
2159
2297
  }
2160
2298
  if (sub === "fetch") {
@@ -2166,13 +2304,21 @@ NEVER emits a patch plan; --save persists only the local signal ledger (used by
2166
2304
  config.buckets.job = { ...config.buckets.job, keywords: merged };
2167
2305
  }
2168
2306
  const bucketFilter = option(rest, "--bucket");
2169
- const buckets = bucketFilter
2307
+ // Explicit --bucket narrows EVERYTHING (job scan, --from, connectors). With
2308
+ // no --bucket, the JOB SCAN defaults to buckets that have configured sources
2309
+ // (its legacy behavior), but staged/connector rows are NOT narrowed — they
2310
+ // carry their own bucket and must flow even for buckets with no `sources`
2311
+ // (e.g. `demand`, which `hubspot-forms` and form-spool rows produce).
2312
+ const explicitBuckets = bucketFilter
2170
2313
  ? bucketFilter.split(",").map((b) => b.trim()).filter(Boolean)
2171
- : SIGNAL_BUCKETS.filter((b) => (config.buckets[b]?.sources.length ?? 0) > 0);
2314
+ : null;
2315
+ const buckets = explicitBuckets ?? SIGNAL_BUCKETS.filter((b) => (config.buckets[b]?.sources.length ?? 0) > 0);
2172
2316
  for (const b of buckets) {
2173
2317
  if (!SIGNAL_BUCKETS.includes(b))
2174
2318
  throw new Error(`Unknown bucket: ${b} (one of ${SIGNAL_BUCKETS.join(", ")})`);
2175
2319
  }
2320
+ // Bucket filter for explicitly-provided rows: the --bucket list, else none.
2321
+ const rowBuckets = explicitBuckets ?? [];
2176
2322
  const sourceFilter = option(rest, "--source");
2177
2323
  const allJobSources = ["greenhouse", "lever", "ashby"];
2178
2324
  const jobSources = sourceFilter
@@ -2205,12 +2351,40 @@ NEVER emits a patch plan; --save persists only the local signal ledger (used by
2205
2351
  candidates.push(...accountSignals);
2206
2352
  }
2207
2353
  }
2208
- // Staged ingest (funding/company/social): --from <file.json>.
2354
+ // Staged ingest (any bucket): --from <file.json>. Narrowed only by --bucket.
2209
2355
  const fromFile = option(rest, "--from");
2210
2356
  if (fromFile) {
2211
- const ingested = readStagedSignals(resolve(process.cwd(), fromFile), buckets, now);
2357
+ const ingested = readStagedSignals(resolve(process.cwd(), fromFile), rowBuckets, now);
2212
2358
  candidates.push(...ingested);
2213
2359
  }
2360
+ // Source connectors (opt-in, additive): --connector id[,id] [--connector-opt k=v ...].
2361
+ // Default behavior is unchanged when no --connector is passed.
2362
+ const connectorArg = option(rest, "--connector");
2363
+ if (connectorArg) {
2364
+ const ids = connectorArg.split(",").map((s) => s.trim()).filter(Boolean);
2365
+ const options = {};
2366
+ for (const pair of repeatedOption(rest, "--connector-opt")) {
2367
+ const eq = pair.indexOf("=");
2368
+ if (eq === -1)
2369
+ throw new Error(`--connector-opt "${pair}" must be key=value.`);
2370
+ options[pair.slice(0, eq).trim()] = pair.slice(eq + 1);
2371
+ }
2372
+ // The `file` connector defaults to the conventional spool dir (the webhook
2373
+ // landing zone) when no explicit path is given, so `--connector file`
2374
+ // works with zero args against `<profile home>/signals/spool`.
2375
+ if (ids.includes("file") && !options.path && !options.file) {
2376
+ options.path = signalsSpoolDir();
2377
+ }
2378
+ const watchlist = await resolveWatchlist(rest, config);
2379
+ const ctx = {
2380
+ watchlist: watchlist.map((a) => ({ domain: a.domain })),
2381
+ keywords: config.buckets.job.keywords ?? [],
2382
+ now,
2383
+ getApiKey: resolveSignalSourceKey,
2384
+ options,
2385
+ };
2386
+ candidates.push(...(await runSignalConnectors(ids, ctx, rowBuckets)));
2387
+ }
2214
2388
  const fetched = candidates.length;
2215
2389
  const { fresh, deduped } = dedupeSignals(candidates, priorSignals, config.dedupWindowDays, now);
2216
2390
  const ranked = [...fresh].sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
@@ -2277,7 +2451,8 @@ NEVER emits a patch plan; --save persists only the local signal ledger (used by
2277
2451
  throw new Error(`signals outcome: --result must be one of ${valid.join(", ")}.`);
2278
2452
  }
2279
2453
  const touchId = option(rest, "--touch") ?? undefined;
2280
- const outcome = makeOutcome({ accountDomain: accountArg, touchId, result: result });
2454
+ const contactId = option(rest, "--contact") ?? undefined;
2455
+ const outcome = makeOutcome({ accountDomain: accountArg, touchId, contactId, result: result });
2281
2456
  await createFileSignalStore().appendOutcome(outcome);
2282
2457
  console.error(`Recorded outcome "${outcome.result}" for ${outcome.accountDomain} (${outcome.id}). ` +
2283
2458
  `Re-run \`fullstackgtm signals weights\` to see the learned shift.`);
@@ -2333,44 +2508,73 @@ function readStagedSignals(path, buckets, now) {
2333
2508
  const raw = JSON.parse(readFileSync(path, "utf8"));
2334
2509
  if (!Array.isArray(raw))
2335
2510
  throw new Error(`--from ${path}: expected a JSON array of staged signals.`);
2336
- const nowIso = now.toISOString();
2337
2511
  const out = [];
2338
2512
  raw.forEach((entry, index) => {
2339
2513
  if (!entry || typeof entry !== "object")
2340
2514
  throw new Error(`--from ${path}: row ${index} is not an object.`);
2341
2515
  const e = entry;
2516
+ // Filter a --bucket-excluded row out BEFORE validation so it never errors;
2517
+ // an unknown bucket falls through to stagedRowToSignal, which rejects it.
2342
2518
  const bucket = String(e.bucket ?? "");
2343
- if (!SIGNAL_BUCKETS.includes(bucket)) {
2344
- throw new Error(`--from ${path}: row ${index} has unknown bucket "${bucket}" (one of ${SIGNAL_BUCKETS.join(", ")}).`);
2345
- }
2346
- if (buckets.length && !buckets.includes(bucket))
2347
- return; // filtered out by --bucket
2348
- const accountDomain = normalizeAccountDomain(String(e.accountDomain ?? e.domain ?? ""));
2349
- if (!accountDomain)
2350
- throw new Error(`--from ${path}: row ${index} is missing accountDomain.`);
2351
- const trigger = String(e.trigger ?? "").trim();
2352
- if (!trigger)
2353
- throw new Error(`--from ${path}: row ${index} is missing trigger.`);
2354
- const quote = String(e.quote ?? "").trim();
2355
- if (!quote)
2356
- throw new Error(`--from ${path}: row ${index} is missing the verbatim quote (the evidence anchor).`);
2357
- const base = { accountDomain, bucket: bucket, trigger };
2358
- const firstSeen = typeof e.firstSeen === "string" && e.firstSeen ? e.firstSeen : nowIso;
2359
- out.push({
2360
- id: signalId(base),
2361
- accountDomain,
2362
- bucket: bucket,
2363
- trigger,
2364
- quote,
2365
- sourceUrl: String(e.sourceUrl ?? ""),
2366
- firstSeen,
2367
- weight: typeof e.weight === "number" ? e.weight : DEFAULT_SIGNALS_CONFIG.buckets[bucket].weight,
2368
- source: "ingest",
2369
- judgedBy: null,
2370
- });
2519
+ if (buckets.length && SIGNAL_BUCKETS.includes(bucket) && !buckets.includes(bucket)) {
2520
+ return;
2521
+ }
2522
+ out.push(stagedRowToSignal(e, { now, source: "ingest", errorLabel: `--from ${path}: row ${index}` }));
2371
2523
  });
2372
2524
  return out;
2373
2525
  }
2526
+ /**
2527
+ * Run the selected source connectors and return their candidate signals,
2528
+ * funneled through the SAME staged-row gate as `--from` (so evidence-gating,
2529
+ * dedup, and weighting are identical regardless of intake). Connectors are
2530
+ * resilient by contract; a connector that still throws (e.g. a malformed file
2531
+ * it was explicitly pointed at) surfaces with its id for context. `--bucket`
2532
+ * filtering is applied after the row is validated.
2533
+ */
2534
+ async function runSignalConnectors(ids, ctx, buckets) {
2535
+ const out = [];
2536
+ for (const id of ids) {
2537
+ const connector = getSignalSource(id);
2538
+ if (!connector) {
2539
+ const known = listSignalSources().map((c) => c.id).join(", ");
2540
+ throw new Error(`Unknown --connector "${id}" (one of: ${known}).`);
2541
+ }
2542
+ let rows;
2543
+ try {
2544
+ rows = await connector.fetch(ctx);
2545
+ }
2546
+ catch (error) {
2547
+ throw new Error(`signals source "${id}": ${error instanceof Error ? error.message : String(error)}`);
2548
+ }
2549
+ rows.forEach((row, index) => {
2550
+ const e = row;
2551
+ const bucket = String(e.bucket ?? "");
2552
+ if (buckets.length && SIGNAL_BUCKETS.includes(bucket) && !buckets.includes(bucket)) {
2553
+ return;
2554
+ }
2555
+ out.push(stagedRowToSignal(e, { now: ctx.now, source: id, errorLabel: `signals source "${id}": row ${index}` }));
2556
+ });
2557
+ }
2558
+ return out;
2559
+ }
2560
+ /**
2561
+ * Credential-ladder lookup for source connectors. Env wins (CI / agent
2562
+ * sandboxes never touch the filesystem), then the stored login. HubSpot reuses
2563
+ * the existing connection resolver (private-app / OAuth-refresh / broker) so a
2564
+ * forms source needs no separate login. Returns null when nothing is configured
2565
+ * — the connector then treats itself as inactive (returns []), never argv.
2566
+ */
2567
+ async function resolveSignalSourceKey(provider) {
2568
+ const envKey = `FSGTM_${provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
2569
+ const fromEnv = process.env[envKey] ?? process.env[`${provider.toUpperCase()}_API_KEY`];
2570
+ if (fromEnv)
2571
+ return fromEnv;
2572
+ if (provider === "hubspot") {
2573
+ const connection = await resolveHubspotConnection();
2574
+ return connection?.accessToken ?? null;
2575
+ }
2576
+ return getCredential(provider)?.accessToken ?? null;
2577
+ }
2374
2578
  /**
2375
2579
  * `draft` — author ONE trigger-grounded opener per hot judge decision as a
2376
2580
  * governed create_task plan. Structurally a proposal: never sends, never writes
@@ -2441,12 +2645,425 @@ LLM key it emits a clearly-labeled stub rather than fake authored copy.`);
2441
2645
  if (save) {
2442
2646
  await createFilePlanStore().save(plan);
2443
2647
  console.error(`Saved plan ${plan.id} (status ${plan.status}). Review: \`fullstackgtm plans show ${plan.id}\`, ` +
2444
- `then \`fullstackgtm plans approve ${plan.id} --operations all\` and \`fullstackgtm apply --plan-id ${plan.id} --provider <name>\`.`);
2648
+ `then \`fullstackgtm plans approve ${plan.id} --operations all\` and either ` +
2649
+ `\`fullstackgtm apply --plan-id ${plan.id} --provider <name>\` (log the touch as a CRM task) or ` +
2650
+ `\`fullstackgtm apply --plan-id ${plan.id} --channel outbox\` (render to the outbox for a sender — transmits nothing).`);
2445
2651
  }
2446
2652
  else {
2447
2653
  console.error("(not saved — re-run with --save to stage the plan for plans approve -> apply)");
2448
2654
  }
2449
2655
  }
2656
+ /**
2657
+ * `tam` — Total Addressable Market mapping. Estimate a defensible universe from
2658
+ * the ICP, then iteratively populate it via scheduled governed acquire runs and
2659
+ * track coverage over time. Subcommands: estimate, status, report, populate.
2660
+ */
2661
+ async function tamCommand(args) {
2662
+ const [sub, ...rest] = args;
2663
+ if (!sub || args.includes("--help") || args.includes("-h")) {
2664
+ console.log(`Usage:
2665
+ fullstackgtm tam estimate [--name <n>] [--icp <path>] (--accounts <n> | --source theirstack|explorium)
2666
+ (--acv <annual-usd> | --acv-from-crm --deal-period monthly|quarterly|annual) [--acv-basis account|buyer]
2667
+ [--buyers-per-account <n>] [--cross-checks <file.json>] [source options for a baseline] [--json]
2668
+ 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]
2669
+ fullstackgtm tam status [--name <n>] <source options> [--save] [--json]
2670
+ fullstackgtm tam report [--name <n>] [--out <path>]
2671
+ fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--label <l>]
2672
+
2673
+ Estimate the reachable market FROM your ICP: a real account count × a confirmed
2674
+ ANNUAL ACV (--acv <annual-usd>, or --acv-from-crm --deal-period monthly|quarterly|
2675
+ annual to derive+annualize from closed-won — no band defaults, a bare --provider
2676
+ does NOT set ACV, refuses without one); buyers/account (explicit or CRM-derived).
2677
+ Then populate it with scheduled \`enrich acquire --save\` runs (plan-only — apply
2678
+ stays a separate human gate) and watch coverage close. \`status --save\` stamps the
2679
+ timeline so \`report\`/ETA can project how long full coverage will take.
2680
+
2681
+ --source theirstack counts (and \`tam accounts\` LISTS) companies that actually USE a
2682
+ CRM/MAP (set firmographics.technologies, e.g. ["salesforce","hubspot"]) — the real
2683
+ RevOps universe, with real names. --source explorium is a firmographic count only
2684
+ (NAICS/size/geo, no list). See docs/tam.md.`);
2685
+ return;
2686
+ }
2687
+ const name = option(rest, "--name") ?? "default";
2688
+ if (sub === "estimate") {
2689
+ const icp = loadIcp(rest);
2690
+ if (!icp) {
2691
+ throw new Error("tam estimate derives the universe from your ICP — none found (icp.json in cwd, or --icp <path>). " +
2692
+ "Build one: `fullstackgtm icp interview`, or scaffold a workspace with `fullstackgtm init`.");
2693
+ }
2694
+ // The account universe: an explicit --accounts assumption, or a live count of
2695
+ // matching COMPANIES. `theirstack` is technographic — it counts companies that
2696
+ // actually USE a CRM/MAP (the real RevOps buying signal) and can return the
2697
+ // list. `explorium` is a firmographic count only (NAICS/size/geo, no list).
2698
+ let accounts = numericOption(rest, "--accounts");
2699
+ let accountsSource = "assumption";
2700
+ let capped = false;
2701
+ const probeSource = option(rest, "--source");
2702
+ if (accounts === undefined && probeSource) {
2703
+ if (probeSource === "theirstack") {
2704
+ const total = await theirStackCountCompanies({
2705
+ apiKey: providerKey("theirstack"),
2706
+ filters: icpToTheirStackFilters(icp),
2707
+ });
2708
+ if (total === null) {
2709
+ throw new Error("TheirStack returned no total for the ICP — check firmographics.technologies (e.g. [\"salesforce\",\"hubspot\"]) " +
2710
+ "and geos/employeeBands, or pass --accounts <n>.");
2711
+ }
2712
+ accounts = total;
2713
+ accountsSource = "provider:theirstack (uses-CRM)";
2714
+ }
2715
+ else if (probeSource === "explorium") {
2716
+ const probe = await probeExploriumBusinessCount({
2717
+ apiKey: providerKey("explorium"),
2718
+ filters: icpToExploriumBusinessFilters(icp),
2719
+ });
2720
+ if (probe === null) {
2721
+ throw new Error("Explorium /v1/businesses returned no total for the ICP firmographic — pass --accounts <n>. See docs/tam.md.");
2722
+ }
2723
+ accounts = probe.total;
2724
+ capped = probe.capped;
2725
+ accountsSource = capped ? "provider:explorium (≥60k cap — floor)" : "provider:explorium";
2726
+ }
2727
+ else {
2728
+ throw new Error(`tam estimate --source must be theirstack (technographic, uses-CRM + a real list) or explorium ` +
2729
+ `(firmographic count only) — got "${probeSource}". pipe0 is a population source, not a count.`);
2730
+ }
2731
+ }
2732
+ if (accounts === undefined) {
2733
+ throw new Error("tam estimate needs the account universe size: --source theirstack (count companies that use a CRM), " +
2734
+ "--source explorium (firmographic count), or --accounts <n>.");
2735
+ }
2736
+ const basis = (option(rest, "--acv-basis") ?? "account");
2737
+ if (basis !== "account" && basis !== "buyer") {
2738
+ throw new Error(`tam: --acv-basis must be account|buyer (got "${basis}")`);
2739
+ }
2740
+ const ccPath = option(rest, "--cross-checks");
2741
+ const crossChecks = ccPath
2742
+ ? JSON.parse(readFileSync(resolve(process.cwd(), ccPath), "utf8"))
2743
+ : [];
2744
+ // Load the CRM snapshot ONCE if a source is given — reused for the coverage
2745
+ // baseline AND for deriving real ACV / buyers-per-account from the CRM.
2746
+ const hasSource = Boolean(option(rest, "--provider")) ||
2747
+ Boolean(option(rest, "--input")) ||
2748
+ rest.includes("--demo") ||
2749
+ rest.includes("--sample");
2750
+ const snapshot = hasSource ? await readSnapshot(rest) : undefined;
2751
+ const baseline = snapshot ? coverageCountsFromSnapshot(snapshot) : { accounts: 0, contacts: 0 };
2752
+ // ACV must be a REAL, ANNUAL figure that the operator confirms — never a band
2753
+ // default, and never silently lifted from the CRM. `--provider` is the
2754
+ // COVERAGE source, not the ACV: the TAM's economics aren't HubSpot's job.
2755
+ // Two confirmed paths: an explicit annual `--acv`, or an opt-in
2756
+ // `--acv-from-crm` that derives the median closed-won amount AND annualizes it
2757
+ // per a REQUIRED `--deal-period` (a monthly MRR deal × 12 = annual ACV).
2758
+ const acvExplicit = numericOption(rest, "--acv");
2759
+ const acvFromCrm = rest.includes("--acv-from-crm");
2760
+ let acvValue;
2761
+ let acvSource;
2762
+ if (acvExplicit !== undefined) {
2763
+ acvValue = acvExplicit;
2764
+ acvSource = "explicit (annual)";
2765
+ }
2766
+ else if (acvFromCrm) {
2767
+ if (!snapshot) {
2768
+ throw new Error("--acv-from-crm needs a CRM source — add --provider <crm> or --input <snapshot>.");
2769
+ }
2770
+ const period = option(rest, "--deal-period");
2771
+ const factor = period === "monthly" ? 12 : period === "quarterly" ? 4 : period === "annual" ? 1 : undefined;
2772
+ if (factor === undefined) {
2773
+ throw new Error("--acv-from-crm requires --deal-period monthly|quarterly|annual — deal amounts can be MRR or annual " +
2774
+ "or one-time, and we won't guess (guessing the period is a 4–12× error). e.g. a $15k/mo deal needs " +
2775
+ "`--deal-period monthly` → $180k annual ACV.");
2776
+ }
2777
+ const derived = deriveAcvFromClosedWon(snapshot);
2778
+ if (!derived) {
2779
+ throw new Error("--acv-from-crm found no closed-won deals with amounts in the snapshot — pass --acv <annual-usd> instead.");
2780
+ }
2781
+ acvValue = derived.valueUsd * factor;
2782
+ acvSource =
2783
+ `crm:closed-won (${derived.dealCount} deal${derived.dealCount === 1 ? "" : "s"}, ` +
2784
+ `median $${derived.valueUsd.toLocaleString()}/${period}${factor > 1 ? ` ×${factor}` : ""} = annual)`;
2785
+ }
2786
+ else {
2787
+ throw new Error("tam estimate needs a real ANNUAL ACV — and it won't guess one. Pass --acv <annual-usd> (your " +
2788
+ "confirmed annualized ACV), or --acv-from-crm --deal-period monthly|quarterly|annual to derive it " +
2789
+ "from closed-won deals. (--provider is your coverage source, not your ACV — it no longer auto-sets it.)");
2790
+ }
2791
+ // Buyers/account — real signal (avg CRM contacts/account) or an explicit
2792
+ // value; never a silent guess. With neither, a clearly-labeled minimal
2793
+ // assumption of 1 (so the contact target = accounts, not an inflated number).
2794
+ let buyersPerAccount = numericOption(rest, "--buyers-per-account");
2795
+ let buyersSource = "explicit";
2796
+ if (buyersPerAccount === undefined) {
2797
+ const db = snapshot ? deriveBuyersPerAccount(snapshot) : null;
2798
+ if (db) {
2799
+ buyersPerAccount = db.value;
2800
+ buyersSource = `crm:avg-contacts/account (${db.accountsSampled} accounts)`;
2801
+ }
2802
+ else {
2803
+ buyersPerAccount = 1;
2804
+ buyersSource = "assumption:1-buyer (no CRM signal)";
2805
+ console.error("tam: buyers/account not given and no CRM contacts to derive from — assuming 1 buyer/account. " +
2806
+ "Set --buyers-per-account <n> or pass --provider for a real contacts-per-account average.");
2807
+ }
2808
+ }
2809
+ const createdAt = option(rest, "--today") ?? new Date().toISOString();
2810
+ const model = estimateTam({
2811
+ name,
2812
+ icpName: icp.name,
2813
+ accounts,
2814
+ accountsSource,
2815
+ buyersPerAccount,
2816
+ buyersSource,
2817
+ // Capture the ICP filter so `tam status` can classify CRM accounts as
2818
+ // in/out of THIS TAM (not just count everything).
2819
+ targeting: {
2820
+ geos: icp.firmographics?.geos,
2821
+ employeeBands: icp.firmographics?.employeeBands,
2822
+ industries: icp.firmographics?.industries,
2823
+ technologies: icp.firmographics?.technologies,
2824
+ },
2825
+ acv: { basis, valueUsd: acvValue, source: acvSource },
2826
+ crossChecks,
2827
+ baseline,
2828
+ createdAt,
2829
+ });
2830
+ saveTamModel(model);
2831
+ if (rest.includes("--json")) {
2832
+ console.log(JSON.stringify(model, null, 2));
2833
+ return;
2834
+ }
2835
+ const coverage = computeCoverage(model, baseline, createdAt);
2836
+ console.log(coverageToText(model, coverage, null));
2837
+ if (capped) {
2838
+ console.log(`\n⚠ The account count hit Explorium's 60,000 ceiling — the true universe is LARGER. ` +
2839
+ "Treat this TAM as a floor, or narrow the ICP (tighter industry/geo/size) for an exact count.");
2840
+ }
2841
+ console.log(`\nSaved TAM "${name}". Next: schedule population with ` +
2842
+ `\`fullstackgtm tam populate --name ${name} --cron "0 7 * * 1-5"\`, ` +
2843
+ `then track with \`fullstackgtm tam status --name ${name} --provider hubspot --save\`.`);
2844
+ return;
2845
+ }
2846
+ if (sub === "status") {
2847
+ const model = loadTamModel(name);
2848
+ if (!model)
2849
+ throw new Error(`No TAM "${name}" — run \`fullstackgtm tam estimate\` first.`);
2850
+ const snapshot = await readSnapshot(rest);
2851
+ const at = option(rest, "--today") ?? new Date().toISOString();
2852
+ // Classify CRM accounts against THIS TAM's ICP — only in-TAM accounts count
2853
+ // toward coverage; off-ICP junk lands in out-of-TAM/unknown. Reconciles
2854
+ // bottom-up vs top-down (the in-TAM count is a floor on the real universe).
2855
+ const coverage = classifyCoverage(model, snapshot, at);
2856
+ const save = rest.includes("--save");
2857
+ if (save)
2858
+ appendCoverage(name, coverage);
2859
+ // Include the current reading in the ETA basis whether or not we persisted it.
2860
+ const timeline = save ? readCoverageTimeline(name) : [...readCoverageTimeline(name), coverage];
2861
+ const eta = projectEta(model, timeline);
2862
+ if (rest.includes("--json")) {
2863
+ console.log(JSON.stringify({ model, coverage, eta, saved: save }, null, 2));
2864
+ return;
2865
+ }
2866
+ console.log(coverageToText(model, coverage, eta));
2867
+ if (!save)
2868
+ console.log(`\n(reading not saved — add --save to stamp the timeline for ETA tracking)`);
2869
+ return;
2870
+ }
2871
+ if (sub === "report") {
2872
+ const model = loadTamModel(name);
2873
+ if (!model)
2874
+ throw new Error(`No TAM "${name}" — run \`fullstackgtm tam estimate\` first.`);
2875
+ const timeline = readCoverageTimeline(name);
2876
+ const eta = projectEta(model, timeline);
2877
+ const md = tamReportToMarkdown(model, timeline, eta);
2878
+ const out = option(rest, "--out");
2879
+ if (out) {
2880
+ writeFileSync(resolve(process.cwd(), out), md);
2881
+ console.log(`Wrote ${out}.`);
2882
+ }
2883
+ else {
2884
+ console.log(md);
2885
+ }
2886
+ return;
2887
+ }
2888
+ if (sub === "populate") {
2889
+ await tamPopulate(name, rest);
2890
+ return;
2891
+ }
2892
+ if (sub === "accounts") {
2893
+ await tamAccounts(rest);
2894
+ return;
2895
+ }
2896
+ throw new Error(`Unknown tam subcommand "${sub}". Try: estimate | status | report | populate | accounts`);
2897
+ }
2898
+ /**
2899
+ * `tam accounts` — pull the REAL target-account list from a technographic source
2900
+ * (companies that actually use a CRM, matched to the ICP). This is the
2901
+ * materialized list the count can't give you: real names + domains you can
2902
+ * review, save, and acquire into the CRM. Read-only (no CRM write); each pulled
2903
+ * company costs provider credits, so `--max` caps the page.
2904
+ */
2905
+ async function tamAccounts(rest) {
2906
+ const source = option(rest, "--source") ?? "theirstack";
2907
+ if (source !== "theirstack") {
2908
+ throw new Error(`tam accounts --source supports theirstack (only technographic list source wired) — got "${source}".`);
2909
+ }
2910
+ const icp = loadIcp(rest);
2911
+ if (!icp) {
2912
+ throw new Error("tam accounts needs an ICP (icp.json in cwd, or --icp <path>) — its technologies/firmographics are the filter.");
2913
+ }
2914
+ const max = Math.min(numericOption(rest, "--max") ?? 25, 100);
2915
+ // Pulling the list costs TheirStack credits (~3/company) — never spend by
2916
+ // surprise. Show the cost up front; --dry-run prices it without spending; a
2917
+ // pull above --max-credits (default 150 ≈ 50 companies) needs --confirm.
2918
+ const usdPerCredit = numericOption(rest, "--usd-per-credit") ?? undefined;
2919
+ const thisCost = theirStackPullCost(max, usdPerCredit);
2920
+ const usdStr = (c) => (c.usd !== undefined ? ` (~$${c.usd.toLocaleString()})` : "");
2921
+ const model = loadTamModel(option(rest, "--name") ?? "default");
2922
+ const fullUniverse = model?.universe.accounts;
2923
+ const fullCost = fullUniverse ? theirStackPullCost(fullUniverse, usdPerCredit) : undefined;
2924
+ const costLine = `Pull cost: up to ${max} companies ≈ ${thisCost.credits.toLocaleString()} TheirStack credits${usdStr(thisCost)}` +
2925
+ (fullCost
2926
+ ? `. Full TAM (~${fullUniverse.toLocaleString()} accounts) ≈ ${fullCost.credits.toLocaleString()} credits${usdStr(fullCost)} to materialize.`
2927
+ : ".");
2928
+ if (rest.includes("--dry-run")) {
2929
+ console.log(`${costLine}\n(dry run — nothing pulled, 0 credits spent. Add --usd-per-credit <rate> for a $ estimate.)`);
2930
+ return;
2931
+ }
2932
+ const maxCredits = numericOption(rest, "--max-credits") ?? 150;
2933
+ if (thisCost.credits > maxCredits && !rest.includes("--confirm")) {
2934
+ console.error(`${costLine}\nThis pull (${thisCost.credits.toLocaleString()} credits) exceeds the --max-credits guard (${maxCredits}). ` +
2935
+ "Re-run with --confirm to spend it, lower --max, or --dry-run to just price it.");
2936
+ process.exitCode = 2;
2937
+ return;
2938
+ }
2939
+ console.error(costLine);
2940
+ const { companies: raw, total } = await theirStackSearchCompanies({
2941
+ apiKey: providerKey("theirstack"),
2942
+ filters: icpToTheirStackFilters(icp),
2943
+ limit: max,
2944
+ });
2945
+ // A company carries its WHOLE tech stack (often 200+ slugs) — noise. Keep only
2946
+ // the ICP-targeted technologies (the CRMs/MAPs that put it in the list).
2947
+ const targeted = new Set((icp.firmographics.technologies ?? []).map((t) => t.trim().toLowerCase()));
2948
+ const companies = raw.map((c) => ({
2949
+ ...c,
2950
+ technologies: (c.technologies ?? []).filter((t) => targeted.has(t.toLowerCase())),
2951
+ }));
2952
+ const out = option(rest, "--out");
2953
+ if (rest.includes("--json")) {
2954
+ console.log(JSON.stringify({ total, returned: companies.length, companies }, null, 2));
2955
+ return;
2956
+ }
2957
+ if (out) {
2958
+ const header = "name,domain,employee_count,country,linkedin_url,matched_technologies";
2959
+ const esc = (v) => {
2960
+ const s = v === undefined ? "" : String(v);
2961
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
2962
+ };
2963
+ const rows = companies.map((c) => [c.name, c.domain, c.employeeCount, c.countryCode, c.linkedinUrl, (c.technologies ?? []).join("|")]
2964
+ .map(esc)
2965
+ .join(","));
2966
+ writeFileSync(resolve(process.cwd(), out), `${[header, ...rows].join("\n")}\n`);
2967
+ console.log(`Wrote ${companies.length} companies to ${out}${total !== null ? ` (of ${total.toLocaleString()} matching)` : ""}.`);
2968
+ return;
2969
+ }
2970
+ console.log(`${companies.length} companies${total !== null ? ` of ${total.toLocaleString()} matching the ICP` : ""} ` +
2971
+ `(uses ${[...targeted].join("/")}):\n`);
2972
+ for (const c of companies) {
2973
+ console.log(` ${(c.name ?? "?").padEnd(32)} ${(c.domain ?? "").padEnd(28)} ${String(c.employeeCount ?? "?").padStart(6)} emp ` +
2974
+ `[${(c.technologies ?? []).join(",")}]`);
2975
+ }
2976
+ console.log(`\nNext: stage them with \`fullstackgtm enrich ingest <file.csv> --source clay --objects companies\` ` +
2977
+ "→ `enrich acquire` to create governed account records (or re-run with --out <file.csv>).");
2978
+ }
2979
+ /**
2980
+ * `tam populate` — wire the scheduled population of a TAM: a recurring
2981
+ * `enrich acquire --source <s> --save` that queues a needs_approval lead plan
2982
+ * each firing (apply stays a separate human gate; the meter is charged only at
2983
+ * apply). Delegates to the scheduler so the cron + allowlist (incl. the --save
2984
+ * guard) are validated the same way as any `schedule add`.
2985
+ */
2986
+ async function tamPopulate(name, rest) {
2987
+ const model = loadTamModel(name);
2988
+ if (!model)
2989
+ throw new Error(`No TAM "${name}" — run \`fullstackgtm tam estimate\` first.`);
2990
+ const cron = option(rest, "--cron");
2991
+ if (!cron) {
2992
+ throw new Error('tam populate requires --cron "<expr>" (e.g. "0 7 * * 1-5" = 7am on weekdays)');
2993
+ }
2994
+ const source = option(rest, "--source") ?? "pipe0";
2995
+ const provider = option(rest, "--provider") ?? "hubspot";
2996
+ const label = option(rest, "--label") ?? `tam-${name}`;
2997
+ const acquireCmd = `enrich acquire --source ${source} --provider ${provider} --save`;
2998
+ // Reuse the scheduler: validates the cron, the allowlist, and the --save guard.
2999
+ await scheduleCommand(["add", acquireCmd, "--cron", cron, "--label", label]);
3000
+ const remainingAccounts = Math.max(0, model.universe.accounts - model.baseline.accounts);
3001
+ console.log(`\nPopulating TAM "${name}": each firing queues a needs_approval lead plan — apply stays gated, the ` +
3002
+ `acquire meter is charged only at apply. ~${remainingAccounts.toLocaleString()} of ` +
3003
+ `${model.universe.accounts.toLocaleString()} accounts remain to reach the universe ` +
3004
+ `($${model.tamUsd.toLocaleString()} TAM).`);
3005
+ console.log("Next: `fullstackgtm schedule install` to activate, then each cycle " +
3006
+ "`fullstackgtm plans list` → `plans approve` → `apply`. Track progress with " +
3007
+ `\`fullstackgtm tam status --name ${name} --provider ${provider} --save\`.`);
3008
+ // Cost lives in two separate places — be explicit so neither surprises.
3009
+ const ts = theirStackPullCost(model.universe.accounts);
3010
+ console.log(`\nCost: population spend is the acquire provider's (${source}), governed by the per-profile acquire meter ` +
3011
+ "(records + $/day caps in enrich.config.json) — not TheirStack. Separately, materializing the account LIST " +
3012
+ `from TheirStack (\`tam accounts\`) is ~${ts.credits.toLocaleString()} credits for the full ${model.universe.accounts.toLocaleString()}-account ` +
3013
+ "universe; `tam accounts --dry-run [--usd-per-credit <r>]` prices any pull without spending.");
3014
+ }
3015
+ /**
3016
+ * `init` — scaffold a GTM workspace from cold scratch: a starter icp.json, an
3017
+ * enrich.config.json (acquire preset + a visible assign seam), and a PLAYBOOK
3018
+ * wired with the chosen source/provider that points at the recipes. Pure
3019
+ * file-writer: no network, never overwrites without --force.
3020
+ */
3021
+ function initCommand(args) {
3022
+ if (args.includes("--help") || args.includes("-h")) {
3023
+ console.log(`Usage:
3024
+ fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
3025
+
3026
+ Scaffolds a workspace so the first acquire/signals/judge/draft commands work:
3027
+ icp.json starter ICP (edit, or rebuild with \`icp interview\`)
3028
+ enrich.config.json acquire preset for --source + a placeholder assign policy
3029
+ PLAYBOOK.md the cold-start + outbound-loop recipes wired for this workspace
3030
+
3031
+ The CLI ships governed primitives; your coding agent is the orchestrator. See
3032
+ docs/recipes.md for the full play set. Existing files are kept unless --force.`);
3033
+ return;
3034
+ }
3035
+ const source = (option(args, "--source") ?? "pipe0");
3036
+ if (!["pipe0", "explorium", "linkedin"].includes(source)) {
3037
+ throw new Error(`init: --source must be pipe0|explorium|linkedin (got "${source}")`);
3038
+ }
3039
+ const provider = (option(args, "--provider") ?? "hubspot");
3040
+ if (!["hubspot", "salesforce"].includes(provider)) {
3041
+ throw new Error(`init: --provider must be hubspot|salesforce (got "${provider}")`);
3042
+ }
3043
+ const outDir = resolve(process.cwd(), option(args, "--out") ?? ".");
3044
+ const force = args.includes("--force");
3045
+ const current = activeProfile();
3046
+ const profile = current === DEFAULT_PROFILE ? undefined : current;
3047
+ const files = scaffoldWorkspace({ source, provider, profile });
3048
+ const wrote = [];
3049
+ const kept = [];
3050
+ for (const file of files) {
3051
+ const path = resolve(outDir, file.path);
3052
+ if (existsSync(path) && !force) {
3053
+ kept.push(file.path);
3054
+ continue;
3055
+ }
3056
+ writeFileSync(path, file.content);
3057
+ wrote.push(file.path);
3058
+ }
3059
+ console.log(`Scaffolded workspace (${provider}, discovery via ${source}${profile ? `, profile ${profile}` : ""}).`);
3060
+ if (wrote.length)
3061
+ console.log(` wrote: ${wrote.join(", ")}`);
3062
+ if (kept.length)
3063
+ console.log(` kept (use --force to overwrite): ${kept.join(", ")}`);
3064
+ console.log("\nNext: edit icp.json + set acquire.assign.ownerId in enrich.config.json, then follow PLAYBOOK.md\n" +
3065
+ "(full recipe set: docs/recipes.md).");
3066
+ }
2450
3067
  /**
2451
3068
  * `icp` — develop and inspect the Ideal Customer Profile that targets acquire.
2452
3069
  * The CLI can't run AskUserQuestion itself; `icp interview` emits the question
@@ -2521,12 +3138,19 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
2521
3138
  const unjudged = signalRun.signals.filter((s) => !s.judgedBy);
2522
3139
  if (unjudged.length === 0)
2523
3140
  throw new Error(`Signal run "${signalRun.runLabel}" has no unjudged signals.`);
2524
- // 2) Optional inputs: ICP (may be undefined), snapshot (only --with-history),
2525
- // outcomes + config from the signal store / DEFAULT_SIGNALS_CONFIG.
3141
+ // 2) Optional inputs: ICP (may be undefined), snapshot, outcomes + config.
3142
+ // The snapshot is loaded whenever a source is given (--provider/--input/
3143
+ // --demo/--sample) — it resolves each decision's CRM target (accountId +
3144
+ // best contact) so `draft` can write against a real record. --with-history
3145
+ // additionally enables the memory + fit scoring inputs.
2526
3146
  const icp = loadIcp(rest);
2527
3147
  const config = DEFAULT_SIGNALS_CONFIG;
2528
3148
  const outcomes = await signalStore.listOutcomes();
2529
- const snapshot = withHistory ? await readSnapshot(rest) : undefined;
3149
+ const hasSnapshotSource = Boolean(option(rest, "--provider")) ||
3150
+ Boolean(option(rest, "--input")) ||
3151
+ rest.includes("--demo") ||
3152
+ rest.includes("--sample");
3153
+ const snapshot = withHistory || hasSnapshotSource ? await readSnapshot(rest) : undefined;
2530
3154
  // 3) Resolve the LLM seam ONLY if a key already exists (deterministic baseline
2531
3155
  // otherwise — never PROMPT here; judge must run key-free).
2532
3156
  const cred = resolveLlmCredential();
@@ -2616,10 +3240,14 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
2616
3240
  return;
2617
3241
  }
2618
3242
  // The golden-set gate (default). Resolve "default" BEFORE any file read.
3243
+ // The default set is graded on its own pinned clock (its firstSeen stamps
3244
+ // are relative to DEFAULT_GOLDEN_NOW_ISO) — wall time would let freshness
3245
+ // decay flip the "fresh → send" rows as the calendar advances.
2619
3246
  const rows = goldenArg === "default"
2620
3247
  ? DEFAULT_GOLDEN_SET
2621
3248
  : parseGoldenSet(readFileSync(resolve(process.cwd(), goldenArg), "utf8"));
2622
- const result = await gradeJudge(rows, defaultJudgeFn({ icp: loadIcp(rest), now: new Date() }));
3249
+ const gradeNow = goldenArg === "default" ? new Date(DEFAULT_GOLDEN_NOW_ISO) : new Date();
3250
+ const result = await gradeJudge(rows, defaultJudgeFn({ icp: loadIcp(rest), now: gradeNow }));
2623
3251
  if (asJson) {
2624
3252
  console.log(JSON.stringify(result, null, 2));
2625
3253
  }
@@ -2683,6 +3311,16 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen) {
2683
3311
  else {
2684
3312
  throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0 | linkedin).`);
2685
3313
  }
3314
+ // Surface a zero-discovery result LOUDLY rather than emit a silent empty plan.
3315
+ // A provider that returns 0 with no error usually means an over-narrow ICP
3316
+ // filter (most often the industry/seniority vocab) — not "no market exists".
3317
+ const discoveredCount = prospects.length;
3318
+ if (discoveredCount === 0) {
3319
+ console.error(`enrich acquire: ${disc.provider} discovered 0 prospects for this ICP — the plan will be empty. ` +
3320
+ "This is usually an over-narrow filter, not an empty market: check the ICP's industry and seniority " +
3321
+ "(provider vocab is finicky), widen one constraint, or run `fullstackgtm icp show` to inspect the " +
3322
+ "generated discovery filters. (Distinct from dedup: nothing was returned to filter.)");
3323
+ }
2686
3324
  // 2. ICP fit scoring BEFORE paying for emails — only above-threshold prospects
2687
3325
  // proceed to (credit-spending) email resolution.
2688
3326
  if (icp) {
@@ -2690,6 +3328,10 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen) {
2690
3328
  prospects = prospects
2691
3329
  .map((p) => ({ ...p, fitScore: scoreProspectAgainstIcp(p, icp).score }))
2692
3330
  .filter((p) => (p.fitScore ?? 0) >= threshold);
3331
+ if (discoveredCount > 0 && prospects.length === 0) {
3332
+ console.error(`enrich acquire: all ${discoveredCount} discovered prospect(s) scored below the ICP fit threshold ` +
3333
+ `(${fitThreshold(icp)}) — none qualified. Loosen the ICP persona or lower scoring.threshold.`);
3334
+ }
2693
3335
  }
2694
3336
  // 2.5 Pre-email dedup — drop prospects already in the CRM (name+domain match
2695
3337
  // vs the snapshot) or already processed in a prior run (the seen cache),
@@ -3741,8 +4383,13 @@ and (if the signing key is present) the signature.`);
3741
4383
  }
3742
4384
  async function apply(args) {
3743
4385
  const provider = option(args, "--provider");
3744
- if (!provider)
3745
- throw new Error("apply requires --provider <name>");
4386
+ const channel = option(args, "--channel");
4387
+ if (!provider && !channel) {
4388
+ throw new Error("apply requires --provider <name> (CRM) or --channel <id> (e.g. outbox)");
4389
+ }
4390
+ if (provider && channel) {
4391
+ throw new Error("apply takes --provider OR --channel, not both — a plan applies to one target.");
4392
+ }
3746
4393
  const planId = option(args, "--plan-id");
3747
4394
  const planPath = option(args, "--plan");
3748
4395
  if (!planId && !planPath)
@@ -3827,7 +4474,9 @@ async function apply(args) {
3827
4474
  }
3828
4475
  }
3829
4476
  }
3830
- const connector = await connectorFor(provider, args);
4477
+ // A channel (e.g. outbox) renders approved ops to a local artifact and
4478
+ // transmits nothing; a CRM provider writes records. Same governed apply path.
4479
+ const connector = channel ? createChannelConnector(channel) : await connectorFor(provider, args);
3831
4480
  const run = await applyPatchPlan(connector, plan, {
3832
4481
  approvedOperationIds,
3833
4482
  valueOverrides,
@@ -4293,16 +4942,17 @@ async function login(args) {
4293
4942
  console.log(`Stored Apollo API key in ${credentialsPath()}. \`fullstackgtm enrich append|refresh\` use it automatically.`);
4294
4943
  return;
4295
4944
  }
4296
- if (provider === "pipe0" || provider === "explorium" || provider === "heyreach") {
4945
+ if (provider === "pipe0" || provider === "explorium" || provider === "heyreach" || provider === "theirstack") {
4297
4946
  rejectArgvSecret(args, "--token", "--key", "--api-key");
4298
4947
  const key = await readSecret(`${provider} API key`);
4299
4948
  if (!key)
4300
4949
  throw new Error(`No ${provider} key provided.`);
4301
4950
  // No free auth-health endpoint; validating would spend credits, so the key
4302
- // is stored as-is and validated on the first `enrich acquire` pull.
4951
+ // is stored as-is and validated on the first pull.
4303
4952
  const stamp = new Date().toISOString();
4304
4953
  storeCredential(provider, { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
4305
- console.log(`Stored ${provider} API key in ${credentialsPath()}. \`fullstackgtm enrich acquire\` uses it automatically (validated on first pull).`);
4954
+ const usedBy = provider === "theirstack" ? "`fullstackgtm tam estimate --source theirstack`" : "`fullstackgtm enrich acquire`";
4955
+ console.log(`Stored ${provider} API key in ${credentialsPath()}. ${usedBy} uses it automatically (validated on first pull).`);
4306
4956
  return;
4307
4957
  }
4308
4958
  if (provider !== "hubspot") {
@@ -4599,6 +5249,14 @@ export async function runCli(argv) {
4599
5249
  await enrichCommand(args);
4600
5250
  return;
4601
5251
  }
5252
+ if (command === "init") {
5253
+ initCommand(args);
5254
+ return;
5255
+ }
5256
+ if (command === "tam") {
5257
+ await tamCommand(args);
5258
+ return;
5259
+ }
4602
5260
  if (command === "icp") {
4603
5261
  await icpCommand(args);
4604
5262
  return;