fullstackgtm 0.53.1 → 0.55.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.53.1",
3
+ "version": "0.55.0",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
@@ -64,7 +64,7 @@ credentials AND stored plans per client org.
64
64
  | `call parse\|score\|link\|plan` | Transcripts → evidence-quoted insights, rubric scorecards, deal linking, governed next-step writes |
65
65
  | `enrich append\|refresh\|ingest\|status` | Governed enrichment (Apollo pull / Clay ingest), fill-blanks-only plans |
66
66
  | `enrich acquire [--source explorium\|pipe0\|linkedin] [--max <new>] [--scan-limit <raw>]` | Net-new ICP-targeted lead gen: independent per-provider/list/query checkpoints traverse full audiences instead of rereading page one; paired CLIs CAS-sync opaque, non-PII continuation to the hosted org; resolve-first deduped `create_record` plans, meter-capped and owner-stamped; `enrich status --runs` exposes funnel + continuation; never auto-writes |
67
- | `signals fetch\|list\|outcome\|weights` | Detect-side timing layer: capture fresh buying triggers into a profile-scoped ledger (free Greenhouse/Lever/Ashby ATS in the box; `--from` staged files; `--connector file\|serpapi-news\|hubspot-forms` to pull connected platforms / read the webhook spool `<home>/signals/spool`, secrets via the credential ladder), ranked by learned weights. Writes NOTHING to the CRM; `outcome --account <domain> --contact <id> --result …` re-weights which triggers earn a touch |
67
+ | `signals fetch\|discover\|list\|outcome\|weights` | Detect-side timing layer: capture fresh buying triggers into a profile-scoped ledger (free Greenhouse/Lever/Ashby watchlist scans; Exa-backed ICP behavioral evidence discovery; `--from` staged files; `--connector file\|serpapi-news\|hubspot-forms` to pull connected platforms / read the webhook spool `<home>/signals/spool`, secrets via the credential ladder), ranked by learned weights. Writes NOTHING to the CRM; `outcome --account <domain> --contact <id> --result …` re-weights which triggers earn a touch |
68
68
  | `icp interview\|set\|show\|judge\|eval` | Build the ICP, then `judge` ranks fresh signals into send/nurture/skip — pass a snapshot (`--provider`/`--input`) and each decision resolves its CRM target (`accountId` + the `contact`/`contacts` to reach). `eval` is the calibration gate (exit 2 below the bar) |
69
69
  | `draft [--from-judge latest] [--channel email\|linkedin\|task]` | One trigger-grounded opener per hot account → a governed `create_task` targeting the resolved `contact.id` (or `accountId`); rejects a domain-only decision ("acquire it first"). **Never sends** — after `plans approve`, `apply --provider <crm>` logs it as a CRM task, or `apply --channel outbox` renders it to `<home>/signals/outbox/<channel>.jsonl` for a downstream sender to drain; the CLI transmits nothing |
70
70
  | `market init\|capture\|classify\|worksheet\|observe\|fronts\|axes\|overlay\|scale\|report\|refresh` | Competitive category map; evidence quotes verified verbatim against stored captures |
package/src/cli/auth.ts CHANGED
@@ -399,7 +399,7 @@ export async function login(args: string[]) {
399
399
  console.log(`Stored Clay Public API key in ${credentialsPath()}. Clay connector commands resolve it automatically.`);
400
400
  return;
401
401
  }
402
- if (provider === "pipe0" || provider === "explorium" || provider === "heyreach" || provider === "theirstack") {
402
+ if (provider === "pipe0" || provider === "explorium" || provider === "heyreach" || provider === "theirstack" || provider === "exa") {
403
403
  rejectArgvSecret(args, "--token", "--key", "--api-key");
404
404
  const key = await readSecret(`${provider} API key`);
405
405
  if (!key) throw new Error(`No ${provider} key provided.`);
@@ -408,13 +408,14 @@ export async function login(args: string[]) {
408
408
  const stamp = new Date().toISOString();
409
409
  storeCredential(provider, { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
410
410
  const usedBy =
411
- provider === "theirstack" ? "`fullstackgtm tam estimate --source theirstack`" : "`fullstackgtm enrich acquire`";
411
+ provider === "theirstack" ? "`fullstackgtm tam estimate --source theirstack`" :
412
+ provider === "exa" ? "`fullstackgtm signals discover`" : "`fullstackgtm enrich acquire`";
412
413
  console.log(`Stored ${provider} API key in ${credentialsPath()}. ${usedBy} uses it automatically (validated on first pull).`);
413
414
  return;
414
415
  }
415
416
  if (provider !== "hubspot") {
416
417
  throw new Error(
417
- "login supports: hubspot, salesforce, stripe, anthropic, openai, openrouter, apollo, clay, pipe0, explorium, heyreach, theirstack, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com",
418
+ "login supports: hubspot, salesforce, stripe, anthropic, openai, openrouter, apollo, clay, pipe0, explorium, heyreach, theirstack, exa, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com",
418
419
  );
419
420
  }
420
421
  const now = new Date().toISOString();
package/src/cli/enrich.ts CHANGED
@@ -9,7 +9,7 @@ import { createFilePlanStore } from "../planStore.ts";
9
9
  import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, builtinAcquirePreset, builtinEnrichPreset, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor, type EnrichConfig, type EnrichCounts, type EnrichObjectType, type EnrichRun, type EnrichRunStore, type EnrichSourceRecord } from "../enrich.ts";
10
10
  import { loadMeter, remaining, type AcquireRemaining } from "../acquireMeter.ts";
11
11
  import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspectPage, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys, type Prospect } from "../connectors/prospectSources.ts";
12
- import { createClaySearch, runClayPeopleSearchPage } from "../connectors/clay.ts";
12
+ import { createClaySearch, discoverClayInvestmentProspects, runClayPeopleSearchPage } from "../connectors/clay.ts";
13
13
  import { runContactWaterfall, type ContactProviderAdapter, type ContactWaterfallStep } from "../contactProviders.ts";
14
14
  import { loadSeen, recordSeen } from "../acquireSeen.ts";
15
15
  import { acquireCheckpointId, createFileAcquireCheckpointStore, type AcquireCheckpointKey } from "../acquireCheckpoint.ts";
@@ -18,14 +18,14 @@ import { uploadHostedPatchPlan } from "../hostedPatchPlan.ts";
18
18
  import { progressReporter, reportCounts, reportEvent } from "../runReport.ts";
19
19
  import { ACQUIRE_STAGES, composeListeners, createProgressEmitter } from "../progress.ts";
20
20
  import { createLinkedInProvider, discoverLinkedInProspects } from "../acquireLinkedIn.ts";
21
- import { clayPeopleFilterRoutes, fitThreshold, icpToClayPeopleFilters, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp, type Icp } from "../icp.ts";
21
+ import { clayPeopleFilterRoutes, fitThreshold, icpToClayInvestmentCompanyFilters, icpToClayPeopleFilters, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp, type Icp } from "../icp.ts";
22
22
  import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, type ApolloPullKey } from "../enrichApollo.ts";
23
23
  import type { CanonicalGtmSnapshot, CreateRecordPayload } from "../types.ts";
24
24
  import { isSpoolPath, readSpoolPath } from "../spoolFiles.ts";
25
25
  import { isOptionValue, loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.ts";
26
26
  import { providerKey } from "./tam.ts";
27
27
  import { unknownSubcommandError } from "./suggest.ts";
28
- import { box, colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint, truncateToWidth, type Paint } from "./ui.ts";
28
+ import { box, colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint, scoreColor, truncateToWidth, type Paint } from "./ui.ts";
29
29
  import { compactPlan, verbosePlanRequested } from "./planOutput.ts";
30
30
  import type { AcquireBudget } from "../acquireMeter.ts";
31
31
 
@@ -674,6 +674,19 @@ async function acquireFromApi(
674
674
  cursor = result.nextCursor;
675
675
  exhausted = result.nextCursor === null;
676
676
  } else if (disc.provider === "clay") {
677
+ if (icp?.motion === "investment") {
678
+ const companyFilters = icpToClayInvestmentCompanyFilters(icp);
679
+ progress.note("searching Clay companies against the investment thesis");
680
+ const result = await discoverClayInvestmentProspects({
681
+ apiKey: providerKey("clay"), companyFilters,
682
+ titleKeywords: icp.persona.titleKeywords ?? ["Founder", "Co-founder", "CEO", "CTO"],
683
+ companyLimit: Math.min(scanLimit - discovered, 25), prospectLimit: requestSize,
684
+ });
685
+ page = result.prospects;
686
+ progress.note(`${result.companiesScanned} target account(s) scanned · resolving founders`);
687
+ exhausted = true;
688
+ cursor = "investment-account-first";
689
+ } else {
677
690
  while (true) {
678
691
  if (!cursor) {
679
692
  const route = clayRoutes[clayRouteIndex];
@@ -694,6 +707,7 @@ async function acquireFromApi(
694
707
  offset = 0;
695
708
  exhausted = false;
696
709
  }
710
+ }
697
711
  } else if (disc.provider === "explorium") {
698
712
  const pageNumber = offset + 1;
699
713
  exploriumPage = pageNumber;
@@ -932,12 +946,14 @@ function renderAcquireLeadCards(result: ReturnType<typeof buildAcquirePlan>): st
932
946
  let fit: number | undefined;
933
947
  const item = operation.evidenceIds?.[0] ? evidence.get(operation.evidenceIds[0]) : undefined;
934
948
  try { fit = Number((JSON.parse(item?.text ?? "{}") as { fitScore?: unknown }).fitScore); } catch { /* malformed evidence remains display-only */ }
949
+ const fitPercent = Number.isFinite(fit) ? Math.round((fit ?? 0) * 100) : undefined;
935
950
  const lines = [
936
- truncateToWidth(name, 84),
937
- truncateToWidth(`${title} · ${company}`, 84),
938
- truncateToWidth([payload.associateCompanyDomain, props.hs_linkedin_url].filter(Boolean).join(" · "), 84),
951
+ p.bold(truncateToWidth(name, 84)),
952
+ p.cyan(truncateToWidth(`${title} · ${company}`, 84)),
953
+ p.blue(truncateToWidth([payload.associateCompanyDomain, props.hs_linkedin_url].filter(Boolean).join(" · "), 84)),
939
954
  ];
940
- return box(lines, p, `Lead ${index + 1}/${result.plan.operations.length}${Number.isFinite(fit) ? ` · ${Math.round((fit ?? 0) * 100)}% fit` : ""}`).join("\n");
955
+ const fitLabel = fitPercent === undefined ? "" : ` · ${scoreColor(fitPercent, p)}% fit`;
956
+ return box(lines, p, `Lead ${index + 1}/${result.plan.operations.length}${fitLabel}`).join("\n");
941
957
  }).join("\n");
942
958
  }
943
959
 
package/src/cli/help.ts CHANGED
@@ -21,9 +21,9 @@ Usage:
21
21
  fullstackgtm login stripe [--no-validate]
22
22
  fullstackgtm login anthropic | openai store an LLM API key for call parse/score
23
23
  fullstackgtm login apollo | clay store a data-provider Public API key
24
- fullstackgtm login pipe0 | explorium | theirstack store a discovery-provider key (theirstack = technographic TAM)
24
+ fullstackgtm login pipe0 | explorium | theirstack | exa store a discovery-provider key
25
25
  fullstackgtm login heyreach store a HeyReach key for enrich acquire --source linkedin
26
- fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|clay|pipe0|explorium|heyreach|broker>
26
+ fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|clay|pipe0|explorium|theirstack|exa|heyreach|broker>
27
27
 
28
28
  Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
29
29
  the process list and shell history. Pipe them on stdin or enter them at the
@@ -105,10 +105,12 @@ Usage:
105
105
  in the same plan (freemail domains never used;
106
106
  --skip-unmatched = report-only). HubSpot-only for now.
107
107
  fullstackgtm signals fetch [--bucket job,…] [--source greenhouse,lever,ashby] [--watchlist <path|crm:seg>] [--keywords …] [--from <file.json|spool.jsonl|spool-dir>] [--save]
108
+ fullstackgtm signals discover [--icp <path>] [--source exa] [--since 30d] [--max-accounts 10] [--max-results 50] [--max-searches 12] [--max-usd 0.25] [--save]
108
109
  fullstackgtm signals list [--since 7d] [--bucket b] [--account d] [--unjudged]
109
110
  fullstackgtm signals outcome --account <d> [--touch <id>] --result replied|meeting|bounced|no_reply
110
111
  fullstackgtm signals weights [--explain]
111
- detect fresh buying triggers (ATS hiring scrapes + staged
112
+ detect fresh buying triggers (ICP-driven evidence discovery,
113
+ ATS hiring scans, and staged
112
114
  funding/company/social ingest), rank them, persist a local
113
115
  signal ledger. READ-ONLY re: CRM — fetch NEVER emits a plan;
114
116
  --save persists only the signal ledger. outcome feeds the
@@ -324,7 +326,7 @@ export const HELP: Record<string, HelpEntry> = {
324
326
  logout: {
325
327
  summary: "remove stored credentials for a provider",
326
328
  phase: "Setup",
327
- synopsis: ["fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|clay|pipe0|explorium|broker>"],
329
+ synopsis: ["fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|clay|pipe0|explorium|theirstack|exa|broker>"],
328
330
  seeAlso: ["login", "doctor"],
329
331
  },
330
332
  doctor: {
@@ -622,12 +624,13 @@ export const HELP: Record<string, HelpEntry> = {
622
624
  phase: "Detect",
623
625
  synopsis: [
624
626
  "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|spool.jsonl|spool-dir>] [--save]",
627
+ "fullstackgtm signals discover [--icp <path>] [--source exa] [--since 30d] [--max-accounts 10] [--max-results 50] [--max-searches 12] [--max-usd 0.25] [--save]",
625
628
  "fullstackgtm signals list [--since 7d] [--bucket b] [--account d] [--unjudged]",
626
629
  "fullstackgtm signals outcome --account <d> [--touch <id>] --result replied|meeting|bounced|no_reply",
627
630
  "fullstackgtm signals weights [--explain]",
628
631
  ],
629
632
  detail:
630
- "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`.",
633
+ "Read-only re: CRM — `fetch` and `discover` NEVER emit a patch plan; `--save` persists only the local signal ledger. `discover` matches ICP behavioral hypotheses against canonical public ATS evidence through Exa, with explicit request and reported-cost budgets. Secrets resolve through env/login, never argv. `outcome` feeds the learned per-bucket `weights`.",
631
634
  seeAlso: ["icp", "draft"],
632
635
  },
633
636
  icp: {
@@ -699,13 +702,13 @@ export const COMMAND_FLAGS: Record<string, string[]> = {
699
702
  market: ["--category", "--config", "--vendor", "--snapshot", "--task-account", "--task-deal", "--capture-run", "--run", "--prior-run", "--diff", "--from", "--calls", "--anchor", "--min-mentions", "--max-claims", "--promote-lift", "--model", "--format", "--auto", "--unverified", "--save", "--dry-run", "--verbose", "--json", "--out"],
700
703
  tam: [...SOURCE_FLAGS, "--source", "--name", "--icp", "--accounts", "--acv", "--acv-basis", "--acv-from-crm", "--deal-period", "--buyers-per-account", "--cross-checks", "--max", "--max-credits", "--usd-per-credit", "--dry-run", "--confirm", "--cron", "--label", "--save", "--verbose", "--json", "--out"],
701
704
  icp: [...SOURCE_FLAGS, "--name", "--icp", "--domain", "--no-interactive", "--signals-from", "--with-history", "--prompt", "--min-score", "--from-judge", "--golden", "--against-outcomes", "--min-accuracy", "--model", "--label", "--save", "--verbose", "--json", "--out"],
702
- signals: ["--bucket", "--source", "--connector", "--connector-opt", "--watchlist", "--keywords", "--from", "--label", "--since", "--account", "--contact", "--unjudged", "--touch", "--result", "--save", "--verbose", "--json", "--explain"],
705
+ signals: ["--bucket", "--source", "--connector", "--connector-opt", "--watchlist", "--keywords", "--from", "--label", "--since", "--icp", "--max-accounts", "--max-results", "--max-searches", "--max-usd", "--account", "--contact", "--unjudged", "--touch", "--result", "--save", "--verbose", "--json", "--explain"],
703
706
  draft: ["--from-judge", "--min-score", "--prompt", "--model", "--channel", "--save", "--dry-run", "--verbose", "--json", "--out"],
704
707
  schedule: ["--cron", "--label", "--provider", "--trigger", "--timer", "--runs", "--verbose", "--json"],
705
708
  };
706
709
 
707
710
  export const FLAGS_WITH_VALUES = new Set([
708
- "--account", "--account-id", "--accounts", "--acv", "--acv-basis", "--after", "--anchor", "--api-key", "--approve", "--archive", "--assign-owner", "--attendees", "--before", "--bucket", "--buyers-per-account", "--call", "--call-type", "--calls", "--capture-run", "--category", "--channel", "--client", "--client-id", "--client-secret", "--config", "--connector", "--connector-opt", "--contact", "--cron", "--cross-checks", "--deal", "--deal-period", "--diff", "--domain", "--email", "--except-deal-stage", "--format", "--from", "--from-judge", "--golden", "--guard", "--icp", "--in", "--input", "--instance-url", "--keep", "--key", "--keywords", "--label", "--list", "--login-url", "--match", "--match-property", "--max", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--min-accuracy", "--min-confidence", "--min-mentions", "--min-score", "--model", "--name", "--objects", "--operations", "--out", "--pipeline", "--plan", "--plan-id", "--policy", "--port", "--prepared-by", "--prior-run", "--profile", "--promote-lift", "--prompt", "--provider", "--reason", "--require", "--result", "--rubric", "--rule", "--rules", "--run", "--run-label", "--runs", "--scan-limit", "--scopes", "--seed", "--set", "--signals-from", "--since", "--snapshot", "--source", "--staged-run", "--stale-days", "--status", "--task-account", "--task-deal", "--timer", "--title", "--to", "--today", "--token", "--token-env", "--touch", "--transcript", "--trigger", "--usd-per-credit", "--value", "--values-from", "--vendor", "--via", "--watchlist", "--where",
711
+ "--account", "--account-id", "--accounts", "--acv", "--acv-basis", "--after", "--anchor", "--api-key", "--approve", "--archive", "--assign-owner", "--attendees", "--before", "--bucket", "--buyers-per-account", "--call", "--call-type", "--calls", "--capture-run", "--category", "--channel", "--client", "--client-id", "--client-secret", "--config", "--connector", "--connector-opt", "--contact", "--cron", "--cross-checks", "--deal", "--deal-period", "--diff", "--domain", "--email", "--except-deal-stage", "--format", "--from", "--from-judge", "--golden", "--guard", "--icp", "--in", "--input", "--instance-url", "--keep", "--key", "--keywords", "--label", "--list", "--login-url", "--match", "--match-property", "--max", "--max-accounts", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--max-results", "--max-searches", "--max-usd", "--min-accuracy", "--min-confidence", "--min-mentions", "--min-score", "--model", "--name", "--objects", "--operations", "--out", "--pipeline", "--plan", "--plan-id", "--policy", "--port", "--prepared-by", "--prior-run", "--profile", "--promote-lift", "--prompt", "--provider", "--reason", "--require", "--result", "--rubric", "--rule", "--rules", "--run", "--run-label", "--runs", "--scan-limit", "--scopes", "--seed", "--set", "--signals-from", "--since", "--snapshot", "--source", "--staged-run", "--stale-days", "--status", "--task-account", "--task-deal", "--timer", "--title", "--to", "--today", "--token", "--token-env", "--touch", "--transcript", "--trigger", "--usd-per-credit", "--value", "--values-from", "--vendor", "--via", "--watchlist", "--where",
709
712
  ]);
710
713
 
711
714
  // Lifecycle-grouped front door. One line per verb, organized by the
package/src/cli/icp.ts CHANGED
@@ -38,6 +38,10 @@ function updateReviewSegment(icp: Icp, id: string, raw: string): Icp {
38
38
  if (["titleKeywords", "jobLevels", "departments"].includes(id)) {
39
39
  return { ...icp, persona: { ...icp.persona, [id]: values } };
40
40
  }
41
+ if (["investmentStages", "fundingAmounts", "thesisKeywords"].includes(id)) {
42
+ const field = id === "investmentStages" ? "stages" : id;
43
+ return { ...icp, investment: { ...icp.investment, [field]: values } } as Icp;
44
+ }
41
45
  return { ...icp, signals: { ...icp.signals, intentTopics: values } };
42
46
  }
43
47
 
@@ -3,11 +3,12 @@
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { resolve } from "node:path";
5
5
  import { getCredential, resolveHubspotConnection } from "../credentials.ts";
6
+ import { discoverSignalsWithExa } from "../signalDiscovery.ts";
6
7
  import { buildSignalsFromAts, computeWeights, createFileSignalStore, DEFAULT_SIGNALS_CONFIG, dedupeSignals, loadSignalsConfig, makeOutcome, normalizeAccountDomain, SIGNAL_BUCKETS, signalRunId, signalsSpoolDir, stagedRowToSignal, type Signal, type SignalBucket, type SignalOutcomeResult, type SignalsConfig } from "../signals.ts";
7
8
  import { fetchAtsJobs, type AtsBoardSource, type AtsJob } from "../connectors/atsBoards.ts";
8
9
  import { getSignalSource, listSignalSources, type SignalSourceContext } from "../connectors/signalSources.ts";
9
10
  import { isSpoolPath, readSpoolPath } from "../spoolFiles.ts";
10
- import { option, readSnapshot, repeatedOption, saveRequested } from "./shared.ts";
11
+ import { loadIcp, option, readSnapshot, repeatedOption, saveRequested } from "./shared.ts";
11
12
  import { createStatusLine } from "./ui.ts";
12
13
  import { unknownSubcommandError } from "./suggest.ts";
13
14
 
@@ -113,12 +114,15 @@ export async function signalsCommand(args: string[]) {
113
114
  if (!sub || args.includes("--help") || args.includes("-h")) {
114
115
  console.log(`Usage:
115
116
  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|spool.jsonl|spool-dir>] [--config <path>] [--save]
117
+ fullstackgtm signals discover [--icp <path>] [--source exa] [--since 30d] [--max-accounts 10] [--max-results 50] [--max-searches 12] [--max-usd 0.25] [--save]
116
118
  fullstackgtm signals list [--since 7d] [--bucket b] [--account d] [--unjudged]
117
119
  fullstackgtm signals outcome --account <domain> [--touch <id>] --result replied|meeting|bounced|no_reply
118
120
  fullstackgtm signals weights [--explain]
119
121
 
120
- Detect fresh buying triggers and rank them. \`fetch\` is read-only re: CRM — it
121
- NEVER emits a patch plan; --save persists only the local signal ledger (used by
122
+ Detect fresh buying triggers and rank them. \`discover\` searches canonical
123
+ public ATS pages for evidence matching the ICP's behavioral trigger hypotheses,
124
+ then resolves employer domains through Exa. \`fetch\` and \`discover\` are read-only re: CRM — they
125
+ NEVER emit a patch plan; --save persists only the local signal ledger (used by
122
126
  \`icp judge\`). ATS adapters are no-auth. Source connectors (--connector) pull
123
127
  from connected platforms: file (local JSON/JSONL spool, no auth), serpapi-news
124
128
  (API key via env/login), hubspot-forms (reuses the HubSpot login). Secrets come
@@ -131,6 +135,54 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
131
135
  return;
132
136
  }
133
137
 
138
+ if (sub === "discover") {
139
+ const source = option(rest, "--source") ?? "exa";
140
+ if (source !== "exa") throw new Error(`signals discover: unsupported --source "${source}" (currently: exa).`);
141
+ const icp = loadIcp(rest);
142
+ if (!icp) throw new Error("signals discover: no ICP found. Pass --icp <path> or create ./icp.json.");
143
+ const apiKey = await resolveSignalSourceKey("exa");
144
+ if (!apiKey) throw new Error("signals discover: missing Exa API key. Set EXA_API_KEY or run `fullstackgtm login exa`.");
145
+ const now = new Date();
146
+ const sinceArg = option(rest, "--since") ?? "30d";
147
+ const since = new Date(now.getTime() - parseSinceWindowMs(sinceArg));
148
+ const maxAccounts = positiveIntegerOption(rest, "--max-accounts", 10);
149
+ const maxResults = positiveIntegerOption(rest, "--max-results", 50);
150
+ const maxSearches = positiveIntegerOption(rest, "--max-searches", 12);
151
+ const maxUsd = positiveNumberOption(rest, "--max-usd", 0.25);
152
+ const config = resolveSignalsConfig(rest);
153
+ const status = createStatusLine();
154
+ status.set(`Searching public evidence with Exa… up to ${maxSearches} searches / $${maxUsd.toFixed(2)}`);
155
+ let discovered;
156
+ try {
157
+ discovered = await discoverSignalsWithExa({ icp, apiKey, since, maxAccounts, maxResults, maxSearches, maxUsd, now,
158
+ config });
159
+ } finally {
160
+ status.done();
161
+ }
162
+ const store = createFileSignalStore();
163
+ const priorSignals = await store.allSignals();
164
+ const { fresh, deduped } = dedupeSignals(discovered.signals, priorSignals, config.dedupWindowDays, now);
165
+ const ranked = [...fresh].sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
166
+ const output = { ...discovered.summary, freshSignals: ranked.length, dedupedSignals: deduped.length,
167
+ warnings: discovered.summary.warnings, signals: ranked };
168
+ console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(output, null, 2) : renderSignals(ranked));
169
+ console.error(
170
+ `Exa: ${discovered.summary.searchesUsed}/${maxSearches} searches, $${discovered.summary.costUsd.toFixed(4)} reported cost; ` +
171
+ `${discovered.summary.matchedEvidence} evidence match(es), ${discovered.summary.resolvedAccounts} account(s), ` +
172
+ `${ranked.length} fresh. NO plan emitted — discovery is read-only re: CRM.`,
173
+ );
174
+ for (const warning of discovered.summary.warnings) console.error(`Warning: ${warning}`);
175
+ if (saveRequested(rest)) {
176
+ const runLabel = option(rest, "--label") ?? `discover-${now.toISOString().slice(0, 10)}`;
177
+ await store.appendRun({ id: signalRunId(runLabel), runLabel, startedAt: now.toISOString(), completedAt: new Date().toISOString(),
178
+ buckets: ["job"], counts: { fetched: discovered.summary.rawResults, new: ranked.length, deduped: deduped.length }, signals: ranked });
179
+ console.error(`Saved signal run "${runLabel}". Next: \`fullstackgtm icp judge --signals-from ${runLabel} --save\`.`);
180
+ } else {
181
+ console.error("(not saved — re-run with --save to persist this evidence to the signal ledger)");
182
+ }
183
+ return;
184
+ }
185
+
134
186
  if (sub === "fetch") {
135
187
  const config = resolveSignalsConfig(rest);
136
188
  // Merge --keywords into the job bucket; filter buckets + job sources.
@@ -348,7 +400,23 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
348
400
  return;
349
401
  }
350
402
 
351
- throw unknownSubcommandError("signals", sub, ["fetch", "list", "outcome", "weights"]);
403
+ throw unknownSubcommandError("signals", sub, ["fetch", "discover", "list", "outcome", "weights"]);
404
+ }
405
+
406
+ function positiveIntegerOption(args: string[], flag: string, fallback: number): number {
407
+ const raw = option(args, flag);
408
+ if (raw == null) return fallback;
409
+ const value = Number(raw);
410
+ if (!Number.isInteger(value) || value < 1) throw new Error(`${flag}: expected a positive integer (got "${raw}").`);
411
+ return value;
412
+ }
413
+
414
+ function positiveNumberOption(args: string[], flag: string, fallback: number): number {
415
+ const raw = option(args, flag);
416
+ if (raw == null) return fallback;
417
+ const value = Number(raw);
418
+ if (!Number.isFinite(value) || value <= 0) throw new Error(`${flag}: expected a number greater than zero (got "${raw}").`);
419
+ return value;
352
420
  }
353
421
 
354
422
  /** Parse a "7d"/"30d"/"12h" recency window into milliseconds. */
package/src/cli/ui.ts CHANGED
@@ -156,16 +156,16 @@ export function formatCount(value: number): string {
156
156
  */
157
157
  export function box(lines: string[], p: Paint, title?: string): string[] {
158
158
  const inner = Math.max(
159
- ...lines.map((line) => line.length),
160
- title ? title.length + 2 : 0,
159
+ ...lines.map((line) => stripAnsi(line).length),
160
+ title ? stripAnsi(title).length + 2 : 0,
161
161
  );
162
162
  const top = title
163
- ? `╭─ ${title} ${"─".repeat(Math.max(0, inner - title.length - 1))}╮`
163
+ ? `╭─ ${title} ${"─".repeat(Math.max(0, inner - stripAnsi(title).length - 1))}╮`
164
164
  : `╭${"─".repeat(inner + 2)}╮`;
165
165
  const bottom = `╰${"─".repeat(inner + 2)}╯`;
166
166
  return [
167
167
  p.dim(top),
168
- ...lines.map((line) => `${p.dim("│")} ${line.padEnd(inner)} ${p.dim("│")}`),
168
+ ...lines.map((line) => `${p.dim("│")} ${line}${" ".repeat(Math.max(0, inner - stripAnsi(line).length))} ${p.dim("│")}`),
169
169
  p.dim(bottom),
170
170
  ];
171
171
  }
@@ -17,6 +17,19 @@ export type ClayPeopleSearchPage = {
17
17
  hasMore: boolean;
18
18
  };
19
19
 
20
+ export type ClayCompany = {
21
+ name?: string;
22
+ domain?: string;
23
+ linkedin?: string;
24
+ description?: string;
25
+ industry?: string;
26
+ size?: string;
27
+ location?: string;
28
+ fundingAmountRange?: string;
29
+ };
30
+
31
+ export type ClayCompanySearchPage = { companies: ClayCompany[]; hasMore: boolean };
32
+
20
33
  function clayHeaders(apiKey: string): Record<string, string> {
21
34
  return { "clay-api-key": apiKey, Accept: "application/json", "Content-Type": "application/json" };
22
35
  }
@@ -67,6 +80,74 @@ export async function runClayPeopleSearchPage(opts: {
67
80
  return { prospects: body.data.map(normalizeClayPerson), hasMore };
68
81
  }
69
82
 
83
+ /** Consume the next company page from a Clay search iterator. */
84
+ export async function runClayCompanySearchPage(opts: {
85
+ apiKey: string; searchId: string; limit?: number; fetchImpl?: typeof fetch; apiBaseUrl?: string;
86
+ }): Promise<ClayCompanySearchPage> {
87
+ const fetchImpl = opts.fetchImpl ?? fetch;
88
+ const base = (opts.apiBaseUrl ?? CLAY_PUBLIC_API_BASE).replace(/\/$/, "");
89
+ const limit = Math.max(1, Math.min(opts.limit ?? 25, 100));
90
+ const response = await fetchImpl(`${base}/search/filters-mode/${encodeURIComponent(opts.searchId)}/run`, {
91
+ method: "POST", headers: clayHeaders(opts.apiKey), body: JSON.stringify({ limit }),
92
+ });
93
+ if (!response.ok) throw new ProviderHttpError("Clay", "run company search", response.status);
94
+ const body = await response.json() as { data?: unknown; has_more?: unknown; hasMore?: unknown };
95
+ if (!Array.isArray(body.data)) throw new Error("Clay company search returned an invalid data array.");
96
+ const hasMore = body.has_more ?? body.hasMore;
97
+ if (typeof hasMore !== "boolean") throw new Error("Clay company search returned no has_more flag.");
98
+ return { companies: body.data.map(normalizeClayCompany), hasMore };
99
+ }
100
+
101
+ export function normalizeClayCompany(value: unknown): ClayCompany {
102
+ const row = value && typeof value === "object" ? value as Record<string, unknown> : {};
103
+ return {
104
+ name: stringValue(row.name), domain: bareDomain(stringValue(row.domain)),
105
+ linkedin: normalizeLinkedin(stringValue(row.linkedin_url)), description: stringValue(row.description),
106
+ industry: stringValue(row.industry), size: stringValue(row.size), location: stringValue(row.location),
107
+ fundingAmountRange: stringValue(row.total_funding_amount_range_usd),
108
+ };
109
+ }
110
+
111
+ /** Account-first investment discovery: find thesis-shaped companies, then
112
+ * resolve founders/operators only inside those accounts. */
113
+ export async function discoverClayInvestmentProspects(opts: {
114
+ apiKey: string;
115
+ companyFilters: Record<string, unknown>;
116
+ titleKeywords: string[];
117
+ companyLimit: number;
118
+ prospectLimit: number;
119
+ fetchImpl?: typeof fetch;
120
+ apiBaseUrl?: string;
121
+ }): Promise<{ prospects: Prospect[]; companiesScanned: number; companiesMatched: ClayCompany[] }> {
122
+ const companySearchId = await createClaySearch({
123
+ apiKey: opts.apiKey, sourceType: "companies", filters: opts.companyFilters,
124
+ fetchImpl: opts.fetchImpl, apiBaseUrl: opts.apiBaseUrl,
125
+ });
126
+ const companyPage = await runClayCompanySearchPage({
127
+ apiKey: opts.apiKey, searchId: companySearchId, limit: opts.companyLimit,
128
+ fetchImpl: opts.fetchImpl, apiBaseUrl: opts.apiBaseUrl,
129
+ });
130
+ const prospects: Prospect[] = [];
131
+ const identifiers = companyPage.companies.map((company) => company.domain ?? company.linkedin).filter((value): value is string => Boolean(value));
132
+ if (identifiers.length > 0) {
133
+ const peopleSearchId = await createClaySearch({
134
+ apiKey: opts.apiKey, sourceType: "people",
135
+ filters: { company_identifier: identifiers, job_title_keywords: opts.titleKeywords },
136
+ fetchImpl: opts.fetchImpl, apiBaseUrl: opts.apiBaseUrl,
137
+ });
138
+ const peoplePage = await runClayPeopleSearchPage({
139
+ apiKey: opts.apiKey, searchId: peopleSearchId,
140
+ limit: opts.prospectLimit,
141
+ fetchImpl: opts.fetchImpl, apiBaseUrl: opts.apiBaseUrl,
142
+ });
143
+ // company_identifier may match a past experience. Investment discovery is
144
+ // explicitly account-first, so retain only people currently at an account.
145
+ const currentDomains = new Set(companyPage.companies.map((company) => company.domain).filter(Boolean));
146
+ prospects.push(...peoplePage.prospects.filter((person) => person.companyDomain && currentDomains.has(person.companyDomain)));
147
+ }
148
+ return { prospects, companiesScanned: companyPage.companies.length, companiesMatched: companyPage.companies };
149
+ }
150
+
70
151
  export function normalizeClayPerson(value: unknown): Prospect {
71
152
  const row = value && typeof value === "object" ? value as Record<string, unknown> : {};
72
153
  const location = row.structured_location && typeof row.structured_location === "object"
package/src/icp.ts CHANGED
@@ -13,8 +13,36 @@
13
13
  * Zero runtime deps; pure functions (translation + scoring take plain data).
14
14
  */
15
15
 
16
+ export type TriggerHypothesis = {
17
+ /** Stable, human-editable key used to trace discovered evidence back to the ICP. */
18
+ id: string;
19
+ /** Observable business condition, e.g. "sales compensation process is breaking". */
20
+ label: string;
21
+ /** Phrases or concepts that count as supporting public evidence. */
22
+ positiveEvidence: string[];
23
+ /** Concrete projects/roles that often expose the condition. */
24
+ activeProjects?: string[];
25
+ /** Functions likely to own or feel the problem. */
26
+ buyerFunctions?: string[];
27
+ /** Terms that make a result a likely false positive. */
28
+ negativeEvidence?: string[];
29
+ /** Preferred public source classes. Discovery currently implements `job`. */
30
+ preferredSources?: Array<"job" | "news" | "company" | "social" | "review" | "legal">;
31
+ };
32
+
16
33
  export type Icp = {
17
34
  name: string;
35
+ /** `investment` means the organization is sourcing companies to invest in,
36
+ * not conventional customers. Discovery becomes company-first, then resolves
37
+ * founders at the matched accounts. */
38
+ motion?: "sales" | "investment";
39
+ investment?: {
40
+ stages?: Array<"pre-seed" | "seed" | "series-a" | "series-b" | "growth" | "bootstrapped">;
41
+ /** Clay's native funding bands. */
42
+ fundingAmounts?: Array<"under_1m" | "1m_5m" | "5m_10m" | "10m_25m" | "25m_50m" | "50m_100m" | "100m_250m" | "over_250m" | "unknown">;
43
+ /** Terms expected in an investment target's company description. */
44
+ thesisKeywords?: string[];
45
+ };
18
46
  firmographics: {
19
47
  /** human industry labels, e.g. ["software","saas"] — used for keyword/industry filters */
20
48
  industries?: string[];
@@ -37,7 +65,11 @@ export type Icp = {
37
65
  /** the title phrases that DEFINE the buyer — also the scoring signal */
38
66
  titleKeywords?: string[];
39
67
  };
40
- signals?: { intentTopics?: string[] };
68
+ signals?: {
69
+ intentTopics?: string[];
70
+ /** Testable behavioral hypotheses for evidence-first account discovery. */
71
+ triggerHypotheses?: TriggerHypothesis[];
72
+ };
41
73
  scoring?: {
42
74
  /** minimum fit (0..1) for a prospect to become a create_record op. Default 0.5. */
43
75
  threshold?: number;
@@ -66,9 +98,39 @@ export function parseIcp(raw: string): Icp {
66
98
  if (!icp.persona || (!icp.persona.titleKeywords?.length && !icp.persona.jobLevels?.length)) {
67
99
  throw new Error('icp: "persona" needs at least titleKeywords or jobLevels (without them, scoring cannot rank fit)');
68
100
  }
101
+ if (icp.motion === "investment" && !icp.investment?.thesisKeywords?.length && !icp.firmographics.industries?.length) {
102
+ throw new Error('icp: investment motion needs investment.thesisKeywords or firmographics.industries');
103
+ }
69
104
  return icp;
70
105
  }
71
106
 
107
+ const CLAY_COMPANY_SIZE: Record<string, string> = {
108
+ // Clay's company enum is the lower boundary of its displayed bucket:
109
+ // 2 => 2-10, 10 => 11-50, 50 => 51-200, etc. Bucket `1` is one employee.
110
+ "1-10": "2", "11-50": "10", "51-200": "50", "201-500": "200",
111
+ "501-1000": "500", "1001-5000": "1000", "5001-10000": "5000", "10001+": "10000",
112
+ };
113
+
114
+ /** Native Clay company filters for an investment thesis. */
115
+ export function icpToClayInvestmentCompanyFilters(icp: Icp): Record<string, unknown> {
116
+ const filters: Record<string, unknown> = {};
117
+ const sizes = [...new Set((icp.firmographics.employeeBands ?? []).map((band) => CLAY_COMPANY_SIZE[band.replace(/,/g, "")]).filter(Boolean))];
118
+ if (sizes.length) filters.sizes = sizes;
119
+ if (icp.investment?.fundingAmounts?.length) filters.funding_amounts = icp.investment.fundingAmounts;
120
+ if (icp.investment?.thesisKeywords?.length) filters.description_keywords = icp.investment.thesisKeywords;
121
+ const industries = [...new Set((icp.firmographics.industries ?? []).flatMap((industry) => CLAY_INDUSTRY[industry.toLowerCase()] ?? []))];
122
+ if (industries.length) filters.industries = industries;
123
+ return filters;
124
+ }
125
+
126
+ /** People filters used only after an investment target account has matched. */
127
+ export function clayInvestmentPeopleFilters(icp: Icp, companyIdentifier: string): Record<string, unknown> {
128
+ return {
129
+ company_identifier: [companyIdentifier],
130
+ job_title_keywords: icp.persona.titleKeywords?.length ? icp.persona.titleKeywords : ["Founder", "Co-founder", "CEO", "CTO"],
131
+ };
132
+ }
133
+
72
134
  // ---------------------------------------------------------------------------
73
135
  // Discovery-filter translation
74
136
 
@@ -352,6 +414,30 @@ function titleCase(value: string): string {
352
414
 
353
415
  export type IcpFit = { score: number; reasons: string[] };
354
416
 
417
+ function inferredLevel(title: string): string {
418
+ if (/\b(chief|ceo|cfo|coo|cto|cio|cmo|cro)\b/.test(title)) return "cxo";
419
+ if (/\b(vp|vice president)\b/.test(title)) return "vp";
420
+ if (/\b(head|general manager|gm)\b/.test(title)) return "head";
421
+ if (/\bdirector\b/.test(title)) return "director";
422
+ if (/\bmanager\b/.test(title)) return "manager";
423
+ if (/\b(founder|owner|partner)\b/.test(title)) return "owner";
424
+ return "";
425
+ }
426
+
427
+ function inferredDepartment(title: string): string {
428
+ const aliases: Array<[string, RegExp]> = [
429
+ ["operations", /\b(operations|ops|revops|gtm)\b/],
430
+ ["sales", /\b(sales|revenue|commercial)\b/],
431
+ ["marketing", /\b(marketing|growth|demand generation|brand)\b/],
432
+ ["creative", /\b(creative|content|design)\b/],
433
+ ["media", /\b(media|advertising)\b/],
434
+ ["engineering", /\b(engineering|developer|technical)\b/],
435
+ ["product", /\bproduct\b/],
436
+ ["finance", /\b(finance|financial)\b/],
437
+ ];
438
+ return aliases.find(([, pattern]) => pattern.test(title))?.[0] ?? "";
439
+ }
440
+
355
441
  /**
356
442
  * Score a prospect's PERSONA fit 0..1. Title-keyword match is the strongest
357
443
  * signal (it's what defines the buyer); job level and department add to it.
@@ -376,26 +462,30 @@ export function scoreProspectAgainstIcp(
376
462
 
377
463
  if (keywords.length) {
378
464
  weightSum += 0.6;
379
- const hit = keywords.find((k) => title.includes(k)) ?? roleKeywords(icp).find((keyword) => title.includes(keyword));
380
- if (hit) {
465
+ const exact = keywords.find((k) => title.includes(k));
466
+ const functional = exact ? undefined : roleKeywords(icp).find((keyword) => title.includes(keyword));
467
+ if (exact) {
381
468
  score += 0.6;
382
- reasons.push(`title matches ICP keyword "${hit}"`);
469
+ reasons.push(`title matches ICP keyword "${exact}"`);
470
+ } else if (functional) {
471
+ score += 0.45;
472
+ reasons.push(`title matches ICP function "${functional}"`);
383
473
  }
384
474
  }
385
475
  if (levels.length) {
386
476
  weightSum += 0.25;
387
- const level = (prospect.jobLevel ?? "").toLowerCase();
477
+ const level = (prospect.jobLevel ?? inferredLevel(title)).toLowerCase();
388
478
  if (level && levels.some((l) => level.includes(l))) {
389
479
  score += 0.25;
390
- reasons.push(`seniority "${prospect.jobLevel}" in ICP levels`);
480
+ reasons.push(`seniority "${level}" in ICP levels${prospect.jobLevel ? "" : " (inferred from title)"}`);
391
481
  }
392
482
  }
393
483
  if (depts.length) {
394
484
  weightSum += 0.15;
395
- const dept = (prospect.jobDepartment ?? "").toLowerCase();
485
+ const dept = (prospect.jobDepartment ?? inferredDepartment(title)).toLowerCase();
396
486
  if (dept && depts.some((d) => dept.includes(d))) {
397
487
  score += 0.15;
398
- reasons.push(`department "${prospect.jobDepartment}" in ICP`);
488
+ reasons.push(`department "${dept}" in ICP${prospect.jobDepartment ? "" : " (inferred from title)"}`);
399
489
  }
400
490
  }
401
491
  const normalized = weightSum > 0 ? score / weightSum : 0;