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/CHANGELOG.md CHANGED
@@ -7,6 +7,48 @@ The path to 1.0 is planned in [docs/roadmap-to-1.0.md](https://github.com/fullst
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.55.0] — 2026-07-13
11
+
12
+ ### Added
13
+
14
+ - `signals discover` turns editable ICP behavioral-trigger hypotheses into
15
+ evidence-gated public ATS searches through Exa, resolves employers to
16
+ official domains, and emits verbatim-quoted signals for `icp judge`.
17
+ - ICP derivation now proposes structured trigger hypotheses with positive
18
+ evidence, active projects, buyer functions, negative evidence, and preferred
19
+ public source classes; existing `intentTopics` ICPs remain compatible.
20
+ - Exa joins the credential ladder through `EXA_API_KEY`,
21
+ `FSGTM_EXA_API_KEY`, or `fullstackgtm login exa`.
22
+
23
+ ### Changed
24
+
25
+ - Signal discovery exposes explicit account, result, search, and reported-cost
26
+ limits, structured JSON output, and remains read-only unless `--save` is used
27
+ to persist the local signal ledger.
28
+ - `signals discover` is schedulable alongside `signals fetch`; unattended runs
29
+ still cannot emit or apply CRM patch plans.
30
+
31
+ ## [0.54.0] — 2026-07-12
32
+
33
+ ### Added
34
+
35
+ - Website-derived ICPs classify investment firms as an explicit `investment`
36
+ motion with editable target stages, target-company funding bands, thesis
37
+ keywords, and founder personas.
38
+ - Clay investment acquisition searches thesis-shaped companies first, then
39
+ resolves founders across the matched accounts in one batched people search.
40
+ - The public Clay connector exports normalized company-search pages and the
41
+ shared account-first investment discovery primitive.
42
+
43
+ ### Changed
44
+
45
+ - Investment discovery validates that resolved people currently work at the
46
+ thesis-qualified account, preventing past-employer matches from becoming
47
+ proposed leads.
48
+ - ICP derivation distinguishes fund size, AUM, and check size from a target
49
+ company's prior funding, and maps earliest-stage language conservatively.
50
+ - Clay company-size filters use the provider's lower-bound bucket vocabulary.
51
+
10
52
  ## [0.53.1] — 2026-07-11
11
53
 
12
54
  ### Changed
package/DATA-FLOWS.md CHANGED
@@ -24,6 +24,7 @@ is no fullstackgtm-operated server in the data path for the open package.**
24
24
  | `snapshot`, `audit`, `apply`, `resolve`, `bulk-update`, `dedupe`, `reassign`, `fix`, `enrich` (writeback) | **Your CRM** (HubSpot / Salesforce / Stripe API) | Reads: your CRM records. Writes: only approved patch operations. | Your CRM token (env / stored / broker) |
25
25
  | `call parse`, `call score`, `market classify`, `market refresh` | **Your LLM provider** (api.anthropic.com or api.openai.com) | The call transcript / captured competitor page text you point at, plus the extraction prompt | Your `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` (BYO) |
26
26
  | `enrich append --source apollo`, `enrich refresh` | **Apollo** (api.apollo.io) | The company domain / contact email being enriched | Your `APOLLO_API_KEY` (BYO) |
27
+ | `signals discover --source exa` | **Exa** (api.exa.ai) | Behavioral trigger labels/evidence terms from the selected ICP; employer names from matching public ATS results are sent in follow-up searches to resolve official domains. No CRM snapshot or credentials other than the Exa key. | Your `EXA_API_KEY` / stored `login exa` key (BYO) |
27
28
  | `market capture`, `market refresh` | **Public vendor websites** you list in `market.config.json` | A bounded HTTP GET; DNS answers are validated and pinned, mixed/private/reserved answers are refused, and every redirect is revalidated | none |
28
29
  | `login --via <url>` (optional) | **Your hosted deployment's broker** | A pairing handshake; the broker mints short-lived CRM tokens | broker pairing token |
29
30
  | paired `enrich acquire` checkpoint sync (optional) | **Your hosted deployment** | Opaque query fingerprint plus provider cursor/list offset, exhaustion flag, and CAS revision. No prospect records, emails, names, or provider credentials. Scoped to the broker token's organization. | broker pairing token |
package/README.md CHANGED
@@ -275,6 +275,8 @@ Cleaning and filling the CRM tells you *who* to reach; it never tells you *when*
275
275
  fullstackgtm signals fetch --bucket job --source greenhouse,lever,ashby --keywords "revops,growth" --save
276
276
  fullstackgtm signals fetch --connector serpapi-news,hubspot-forms --save # news + first-party form demand
277
277
  fullstackgtm signals fetch --connector file --save # webhook landing zone (see docs/signal-spool-format.md)
278
+ echo "$EXA_API_KEY" | fullstackgtm login exa # once; or keep the key in env
279
+ fullstackgtm signals discover --icp ./icp.json --source exa --since 30d --max-searches 12 --max-usd 0.25 --save
278
280
  fullstackgtm signals list --since 7d # ranked triggers, each with a verbatim source quote
279
281
 
280
282
  # 2. Decide who's worth a touch — and who isn't. Scores timing × fit × memory into send/nurture/skip.
@@ -313,7 +315,7 @@ fullstackgtm schedule status --runs 5 # last runs, exit codes, artifa
313
315
  fullstackgtm schedule uninstall # remove the managed block, touch nothing else
314
316
  ```
315
317
 
316
- **Scheduling never auto-approves.** Schedulable commands are read/plan-side only — `audit`, `snapshot`, `enrich append|refresh`, `enrich acquire --save`, `market capture|refresh`, `signals fetch`, `icp judge`, `icp eval`, `draft`, `suggest`, `report`, `doctor` — so unattended runs accumulate *proposals* (plans in the queue, run records, reports), never CRM writes. `apply` is schedulable only as `apply --plan-id <id>`, and every firing re-checks the plan's status is approved: an unapproved plan records a `plan_not_approved` no-op run instead of executing, and no flag relaxes this. Arbitrary shell is not schedulable — an entry's argv must resolve to a known fullstackgtm command (validated at `add` time and re-checked at run time), and the timer line you audit — crontab entry or LaunchAgent `ProgramArguments` — is always `fullstackgtm schedule run <id>` and nothing else.
318
+ **Scheduling never auto-approves.** Schedulable commands are read/plan-side only — `audit`, `snapshot`, `enrich append|refresh`, `enrich acquire --save`, `market capture|refresh`, `signals fetch|discover`, `icp judge`, `icp eval`, `draft`, `suggest`, `report`, `doctor` — so unattended runs accumulate *proposals* (plans in the queue, run records, reports), never CRM writes. `apply` is schedulable only as `apply --plan-id <id>`, and every firing re-checks the plan's status is approved: an unapproved plan records a `plan_not_approved` no-op run instead of executing, and no flag relaxes this. Arbitrary shell is not schedulable — an entry's argv must resolve to a known fullstackgtm command (validated at `add` time and re-checked at run time), and the timer line you audit — crontab entry or LaunchAgent `ProgramArguments` — is always `fullstackgtm schedule run <id>` and nothing else.
317
319
 
318
320
  `install` materializes enabled entries into the system timer; `--timer` defaults by platform. On macOS it writes one LaunchAgent plist per entry (`com.fullstackgtm.<profile>.<id>` in `~/Library/LaunchAgents`, loaded via `launchctl bootstrap`) — macOS gates `crontab` writes behind Full Disk Access, a permission no program can request, while LaunchAgents need none; launchd also coalesces firings missed during sleep into one run on wake. Re-install replaces the profile's plist fleet wholesale and never touches foreign plists. Elsewhere (or with `--timer crontab`) it renders a sentinel-delimited block (`# >>> fullstackgtm <profile> >>>` … `# <<< fullstackgtm <profile> <<<`) in your user crontab; re-install replaces the block wholesale and never touches lines outside it. Honest limitation: cron has no catch-up — a laptop asleep at firing time means a missed run. `schedule status` surfaces missed firings by comparing expected-vs-actual run history, so the gap is at least visible. Entries are provider-agnostic; cloud providers (Modal, AWS) arrive as scaffold generators that call the same `schedule run <id>` contract, and are refused as "not yet implemented" until then.
319
321
 
package/dist/cli/auth.js CHANGED
@@ -376,7 +376,7 @@ export async function login(args) {
376
376
  console.log(`Stored Clay Public API key in ${credentialsPath()}. Clay connector commands resolve it automatically.`);
377
377
  return;
378
378
  }
379
- if (provider === "pipe0" || provider === "explorium" || provider === "heyreach" || provider === "theirstack") {
379
+ if (provider === "pipe0" || provider === "explorium" || provider === "heyreach" || provider === "theirstack" || provider === "exa") {
380
380
  rejectArgvSecret(args, "--token", "--key", "--api-key");
381
381
  const key = await readSecret(`${provider} API key`);
382
382
  if (!key)
@@ -385,12 +385,13 @@ export async function login(args) {
385
385
  // is stored as-is and validated on the first pull.
386
386
  const stamp = new Date().toISOString();
387
387
  storeCredential(provider, { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
388
- const usedBy = provider === "theirstack" ? "`fullstackgtm tam estimate --source theirstack`" : "`fullstackgtm enrich acquire`";
388
+ const usedBy = provider === "theirstack" ? "`fullstackgtm tam estimate --source theirstack`" :
389
+ provider === "exa" ? "`fullstackgtm signals discover`" : "`fullstackgtm enrich acquire`";
389
390
  console.log(`Stored ${provider} API key in ${credentialsPath()}. ${usedBy} uses it automatically (validated on first pull).`);
390
391
  return;
391
392
  }
392
393
  if (provider !== "hubspot") {
393
- throw new Error("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");
394
+ throw new Error("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");
394
395
  }
395
396
  const now = new Date().toISOString();
396
397
  rejectArgvSecret(args, "--token");
@@ -8,7 +8,7 @@ import { createFilePlanStore } from "../planStore.js";
8
8
  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";
9
9
  import { loadMeter, remaining } from "../acquireMeter.js";
10
10
  import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspectPage, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys } from "../connectors/prospectSources.js";
11
- import { createClaySearch, runClayPeopleSearchPage } from "../connectors/clay.js";
11
+ import { createClaySearch, discoverClayInvestmentProspects, runClayPeopleSearchPage } from "../connectors/clay.js";
12
12
  import { runContactWaterfall } from "../contactProviders.js";
13
13
  import { loadSeen, recordSeen } from "../acquireSeen.js";
14
14
  import { acquireCheckpointId, createFileAcquireCheckpointStore } from "../acquireCheckpoint.js";
@@ -17,13 +17,13 @@ import { uploadHostedPatchPlan } from "../hostedPatchPlan.js";
17
17
  import { progressReporter, reportCounts, reportEvent } from "../runReport.js";
18
18
  import { ACQUIRE_STAGES, composeListeners, createProgressEmitter } from "../progress.js";
19
19
  import { createLinkedInProvider, discoverLinkedInProspects } from "../acquireLinkedIn.js";
20
- import { clayPeopleFilterRoutes, fitThreshold, icpToClayPeopleFilters, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp } from "../icp.js";
20
+ import { clayPeopleFilterRoutes, fitThreshold, icpToClayInvestmentCompanyFilters, icpToClayPeopleFilters, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp } from "../icp.js";
21
21
  import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords } from "../enrichApollo.js";
22
22
  import { isSpoolPath, readSpoolPath } from "../spoolFiles.js";
23
23
  import { isOptionValue, loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.js";
24
24
  import { providerKey } from "./tam.js";
25
25
  import { unknownSubcommandError } from "./suggest.js";
26
- import { box, colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint, truncateToWidth } from "./ui.js";
26
+ import { box, colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint, scoreColor, truncateToWidth } from "./ui.js";
27
27
  import { compactPlan, verbosePlanRequested } from "./planOutput.js";
28
28
  /**
29
29
  * The enrich layer: governed append/refresh of third-party data (Apollo pull,
@@ -581,27 +581,42 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
581
581
  exhausted = result.nextCursor === null;
582
582
  }
583
583
  else if (disc.provider === "clay") {
584
- while (true) {
585
- if (!cursor) {
586
- const route = clayRoutes[clayRouteIndex];
587
- if (route)
588
- filters = route.filters;
589
- progress.note(`creating Clay people-search iterator · ${route?.id ?? "configured"}`);
590
- cursor = await createClaySearch({ apiKey: providerKey("clay"), sourceType: "people", filters });
591
- }
592
- const result = await runClayPeopleSearchPage({ apiKey: providerKey("clay"), searchId: cursor, limit: requestSize });
584
+ if (icp?.motion === "investment") {
585
+ const companyFilters = icpToClayInvestmentCompanyFilters(icp);
586
+ progress.note("searching Clay companies against the investment thesis");
587
+ const result = await discoverClayInvestmentProspects({
588
+ apiKey: providerKey("clay"), companyFilters,
589
+ titleKeywords: icp.persona.titleKeywords ?? ["Founder", "Co-founder", "CEO", "CTO"],
590
+ companyLimit: Math.min(scanLimit - discovered, 25), prospectLimit: requestSize,
591
+ });
593
592
  page = result.prospects;
594
- offset += page.length;
595
- exhausted = !result.hasMore;
596
- if (page.length > 0 || !allowClayFallback || clayRouteIndex >= clayRoutes.length - 1)
597
- break;
598
- const failed = clayRoutes[clayRouteIndex]?.id ?? "configured";
599
- clayRouteIndex += 1;
600
- const next = clayRoutes[clayRouteIndex];
601
- progress.note(`Clay ${failed} route returned 0 · trying ${next.id}`);
602
- cursor = null;
603
- offset = 0;
604
- exhausted = false;
593
+ progress.note(`${result.companiesScanned} target account(s) scanned · resolving founders`);
594
+ exhausted = true;
595
+ cursor = "investment-account-first";
596
+ }
597
+ else {
598
+ while (true) {
599
+ if (!cursor) {
600
+ const route = clayRoutes[clayRouteIndex];
601
+ if (route)
602
+ filters = route.filters;
603
+ progress.note(`creating Clay people-search iterator · ${route?.id ?? "configured"}`);
604
+ cursor = await createClaySearch({ apiKey: providerKey("clay"), sourceType: "people", filters });
605
+ }
606
+ const result = await runClayPeopleSearchPage({ apiKey: providerKey("clay"), searchId: cursor, limit: requestSize });
607
+ page = result.prospects;
608
+ offset += page.length;
609
+ exhausted = !result.hasMore;
610
+ if (page.length > 0 || !allowClayFallback || clayRouteIndex >= clayRoutes.length - 1)
611
+ break;
612
+ const failed = clayRoutes[clayRouteIndex]?.id ?? "configured";
613
+ clayRouteIndex += 1;
614
+ const next = clayRoutes[clayRouteIndex];
615
+ progress.note(`Clay ${failed} route returned 0 · trying ${next.id}`);
616
+ cursor = null;
617
+ offset = 0;
618
+ exhausted = false;
619
+ }
605
620
  }
606
621
  }
607
622
  else if (disc.provider === "explorium") {
@@ -815,12 +830,14 @@ function renderAcquireLeadCards(result) {
815
830
  fit = Number(JSON.parse(item?.text ?? "{}").fitScore);
816
831
  }
817
832
  catch { /* malformed evidence remains display-only */ }
833
+ const fitPercent = Number.isFinite(fit) ? Math.round((fit ?? 0) * 100) : undefined;
818
834
  const lines = [
819
- truncateToWidth(name, 84),
820
- truncateToWidth(`${title} · ${company}`, 84),
821
- truncateToWidth([payload.associateCompanyDomain, props.hs_linkedin_url].filter(Boolean).join(" · "), 84),
835
+ p.bold(truncateToWidth(name, 84)),
836
+ p.cyan(truncateToWidth(`${title} · ${company}`, 84)),
837
+ p.blue(truncateToWidth([payload.associateCompanyDomain, props.hs_linkedin_url].filter(Boolean).join(" · "), 84)),
822
838
  ];
823
- return box(lines, p, `Lead ${index + 1}/${result.plan.operations.length}${Number.isFinite(fit) ? ` · ${Math.round((fit ?? 0) * 100)}% fit` : ""}`).join("\n");
839
+ const fitLabel = fitPercent === undefined ? "" : ` · ${scoreColor(fitPercent, p)}% fit`;
840
+ return box(lines, p, `Lead ${index + 1}/${result.plan.operations.length}${fitLabel}`).join("\n");
824
841
  }).join("\n");
825
842
  }
826
843
  function printAcquireOutput(options) {
package/dist/cli/help.js CHANGED
@@ -17,9 +17,9 @@ Usage:
17
17
  fullstackgtm login stripe [--no-validate]
18
18
  fullstackgtm login anthropic | openai store an LLM API key for call parse/score
19
19
  fullstackgtm login apollo | clay store a data-provider Public API key
20
- fullstackgtm login pipe0 | explorium | theirstack store a discovery-provider key (theirstack = technographic TAM)
20
+ fullstackgtm login pipe0 | explorium | theirstack | exa store a discovery-provider key
21
21
  fullstackgtm login heyreach store a HeyReach key for enrich acquire --source linkedin
22
- fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|clay|pipe0|explorium|heyreach|broker>
22
+ fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|clay|pipe0|explorium|theirstack|exa|heyreach|broker>
23
23
 
24
24
  Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
25
25
  the process list and shell history. Pipe them on stdin or enter them at the
@@ -101,10 +101,12 @@ Usage:
101
101
  in the same plan (freemail domains never used;
102
102
  --skip-unmatched = report-only). HubSpot-only for now.
103
103
  fullstackgtm signals fetch [--bucket job,…] [--source greenhouse,lever,ashby] [--watchlist <path|crm:seg>] [--keywords …] [--from <file.json|spool.jsonl|spool-dir>] [--save]
104
+ fullstackgtm signals discover [--icp <path>] [--source exa] [--since 30d] [--max-accounts 10] [--max-results 50] [--max-searches 12] [--max-usd 0.25] [--save]
104
105
  fullstackgtm signals list [--since 7d] [--bucket b] [--account d] [--unjudged]
105
106
  fullstackgtm signals outcome --account <d> [--touch <id>] --result replied|meeting|bounced|no_reply
106
107
  fullstackgtm signals weights [--explain]
107
- detect fresh buying triggers (ATS hiring scrapes + staged
108
+ detect fresh buying triggers (ICP-driven evidence discovery,
109
+ ATS hiring scans, and staged
108
110
  funding/company/social ingest), rank them, persist a local
109
111
  signal ledger. READ-ONLY re: CRM — fetch NEVER emits a plan;
110
112
  --save persists only the signal ledger. outcome feeds the
@@ -300,7 +302,7 @@ export const HELP = {
300
302
  logout: {
301
303
  summary: "remove stored credentials for a provider",
302
304
  phase: "Setup",
303
- synopsis: ["fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|clay|pipe0|explorium|broker>"],
305
+ synopsis: ["fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|clay|pipe0|explorium|theirstack|exa|broker>"],
304
306
  seeAlso: ["login", "doctor"],
305
307
  },
306
308
  doctor: {
@@ -566,11 +568,12 @@ export const HELP = {
566
568
  phase: "Detect",
567
569
  synopsis: [
568
570
  "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]",
571
+ "fullstackgtm signals discover [--icp <path>] [--source exa] [--since 30d] [--max-accounts 10] [--max-results 50] [--max-searches 12] [--max-usd 0.25] [--save]",
569
572
  "fullstackgtm signals list [--since 7d] [--bucket b] [--account d] [--unjudged]",
570
573
  "fullstackgtm signals outcome --account <d> [--touch <id>] --result replied|meeting|bounced|no_reply",
571
574
  "fullstackgtm signals weights [--explain]",
572
575
  ],
573
- 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`.",
576
+ detail: "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`.",
574
577
  seeAlso: ["icp", "draft"],
575
578
  },
576
579
  icp: {
@@ -637,12 +640,12 @@ export const COMMAND_FLAGS = {
637
640
  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"],
638
641
  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"],
639
642
  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"],
640
- signals: ["--bucket", "--source", "--connector", "--connector-opt", "--watchlist", "--keywords", "--from", "--label", "--since", "--account", "--contact", "--unjudged", "--touch", "--result", "--save", "--verbose", "--json", "--explain"],
643
+ 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"],
641
644
  draft: ["--from-judge", "--min-score", "--prompt", "--model", "--channel", "--save", "--dry-run", "--verbose", "--json", "--out"],
642
645
  schedule: ["--cron", "--label", "--provider", "--trigger", "--timer", "--runs", "--verbose", "--json"],
643
646
  };
644
647
  export const FLAGS_WITH_VALUES = new Set([
645
- "--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",
648
+ "--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",
646
649
  ]);
647
650
  // Lifecycle-grouped front door. One line per verb, organized by the
648
651
  // Prevent→Detect→Remediate→Verify loop so a new user sees ~6 jobs, not 22 verbs.
package/dist/cli/icp.js CHANGED
@@ -38,6 +38,10 @@ function updateReviewSegment(icp, id, raw) {
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 } };
44
+ }
41
45
  return { ...icp, signals: { ...icp.signals, intentTopics: values } };
42
46
  }
43
47
  function renderDerivedIcp(result, selected = -1) {
@@ -2,11 +2,12 @@
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { getCredential, resolveHubspotConnection } from "../credentials.js";
5
+ import { discoverSignalsWithExa } from "../signalDiscovery.js";
5
6
  import { buildSignalsFromAts, computeWeights, createFileSignalStore, DEFAULT_SIGNALS_CONFIG, dedupeSignals, loadSignalsConfig, makeOutcome, normalizeAccountDomain, SIGNAL_BUCKETS, signalRunId, signalsSpoolDir, stagedRowToSignal } from "../signals.js";
6
7
  import { fetchAtsJobs } from "../connectors/atsBoards.js";
7
8
  import { getSignalSource, listSignalSources } from "../connectors/signalSources.js";
8
9
  import { isSpoolPath, readSpoolPath } from "../spoolFiles.js";
9
- import { option, readSnapshot, repeatedOption, saveRequested } from "./shared.js";
10
+ import { loadIcp, option, readSnapshot, repeatedOption, saveRequested } from "./shared.js";
10
11
  import { createStatusLine } from "./ui.js";
11
12
  import { unknownSubcommandError } from "./suggest.js";
12
13
  /**
@@ -103,12 +104,15 @@ export async function signalsCommand(args) {
103
104
  if (!sub || args.includes("--help") || args.includes("-h")) {
104
105
  console.log(`Usage:
105
106
  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]
107
+ fullstackgtm signals discover [--icp <path>] [--source exa] [--since 30d] [--max-accounts 10] [--max-results 50] [--max-searches 12] [--max-usd 0.25] [--save]
106
108
  fullstackgtm signals list [--since 7d] [--bucket b] [--account d] [--unjudged]
107
109
  fullstackgtm signals outcome --account <domain> [--touch <id>] --result replied|meeting|bounced|no_reply
108
110
  fullstackgtm signals weights [--explain]
109
111
 
110
- Detect fresh buying triggers and rank them. \`fetch\` is read-only re: CRM — it
111
- NEVER emits a patch plan; --save persists only the local signal ledger (used by
112
+ Detect fresh buying triggers and rank them. \`discover\` searches canonical
113
+ public ATS pages for evidence matching the ICP's behavioral trigger hypotheses,
114
+ then resolves employer domains through Exa. \`fetch\` and \`discover\` are read-only re: CRM — they
115
+ NEVER emit a patch plan; --save persists only the local signal ledger (used by
112
116
  \`icp judge\`). ATS adapters are no-auth. Source connectors (--connector) pull
113
117
  from connected platforms: file (local JSON/JSONL spool, no auth), serpapi-news
114
118
  (API key via env/login), hubspot-forms (reuses the HubSpot login). Secrets come
@@ -120,6 +124,57 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
120
124
  (one row per event); see docs/signal-spool-format.md.`);
121
125
  return;
122
126
  }
127
+ if (sub === "discover") {
128
+ const source = option(rest, "--source") ?? "exa";
129
+ if (source !== "exa")
130
+ throw new Error(`signals discover: unsupported --source "${source}" (currently: exa).`);
131
+ const icp = loadIcp(rest);
132
+ if (!icp)
133
+ throw new Error("signals discover: no ICP found. Pass --icp <path> or create ./icp.json.");
134
+ const apiKey = await resolveSignalSourceKey("exa");
135
+ if (!apiKey)
136
+ throw new Error("signals discover: missing Exa API key. Set EXA_API_KEY or run `fullstackgtm login exa`.");
137
+ const now = new Date();
138
+ const sinceArg = option(rest, "--since") ?? "30d";
139
+ const since = new Date(now.getTime() - parseSinceWindowMs(sinceArg));
140
+ const maxAccounts = positiveIntegerOption(rest, "--max-accounts", 10);
141
+ const maxResults = positiveIntegerOption(rest, "--max-results", 50);
142
+ const maxSearches = positiveIntegerOption(rest, "--max-searches", 12);
143
+ const maxUsd = positiveNumberOption(rest, "--max-usd", 0.25);
144
+ const config = resolveSignalsConfig(rest);
145
+ const status = createStatusLine();
146
+ status.set(`Searching public evidence with Exa… up to ${maxSearches} searches / $${maxUsd.toFixed(2)}`);
147
+ let discovered;
148
+ try {
149
+ discovered = await discoverSignalsWithExa({ icp, apiKey, since, maxAccounts, maxResults, maxSearches, maxUsd, now,
150
+ config });
151
+ }
152
+ finally {
153
+ status.done();
154
+ }
155
+ const store = createFileSignalStore();
156
+ const priorSignals = await store.allSignals();
157
+ const { fresh, deduped } = dedupeSignals(discovered.signals, priorSignals, config.dedupWindowDays, now);
158
+ const ranked = [...fresh].sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
159
+ const output = { ...discovered.summary, freshSignals: ranked.length, dedupedSignals: deduped.length,
160
+ warnings: discovered.summary.warnings, signals: ranked };
161
+ console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(output, null, 2) : renderSignals(ranked));
162
+ console.error(`Exa: ${discovered.summary.searchesUsed}/${maxSearches} searches, $${discovered.summary.costUsd.toFixed(4)} reported cost; ` +
163
+ `${discovered.summary.matchedEvidence} evidence match(es), ${discovered.summary.resolvedAccounts} account(s), ` +
164
+ `${ranked.length} fresh. NO plan emitted — discovery is read-only re: CRM.`);
165
+ for (const warning of discovered.summary.warnings)
166
+ console.error(`Warning: ${warning}`);
167
+ if (saveRequested(rest)) {
168
+ const runLabel = option(rest, "--label") ?? `discover-${now.toISOString().slice(0, 10)}`;
169
+ await store.appendRun({ id: signalRunId(runLabel), runLabel, startedAt: now.toISOString(), completedAt: new Date().toISOString(),
170
+ buckets: ["job"], counts: { fetched: discovered.summary.rawResults, new: ranked.length, deduped: deduped.length }, signals: ranked });
171
+ console.error(`Saved signal run "${runLabel}". Next: \`fullstackgtm icp judge --signals-from ${runLabel} --save\`.`);
172
+ }
173
+ else {
174
+ console.error("(not saved — re-run with --save to persist this evidence to the signal ledger)");
175
+ }
176
+ return;
177
+ }
123
178
  if (sub === "fetch") {
124
179
  const config = resolveSignalsConfig(rest);
125
180
  // Merge --keywords into the job bucket; filter buckets + job sources.
@@ -328,7 +383,25 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
328
383
  }
329
384
  return;
330
385
  }
331
- throw unknownSubcommandError("signals", sub, ["fetch", "list", "outcome", "weights"]);
386
+ throw unknownSubcommandError("signals", sub, ["fetch", "discover", "list", "outcome", "weights"]);
387
+ }
388
+ function positiveIntegerOption(args, flag, fallback) {
389
+ const raw = option(args, flag);
390
+ if (raw == null)
391
+ return fallback;
392
+ const value = Number(raw);
393
+ if (!Number.isInteger(value) || value < 1)
394
+ throw new Error(`${flag}: expected a positive integer (got "${raw}").`);
395
+ return value;
396
+ }
397
+ function positiveNumberOption(args, flag, fallback) {
398
+ const raw = option(args, flag);
399
+ if (raw == null)
400
+ return fallback;
401
+ const value = Number(raw);
402
+ if (!Number.isFinite(value) || value <= 0)
403
+ throw new Error(`${flag}: expected a number greater than zero (got "${raw}").`);
404
+ return value;
332
405
  }
333
406
  /** Parse a "7d"/"30d"/"12h" recency window into milliseconds. */
334
407
  function parseSinceWindowMs(value) {
package/dist/cli/ui.js CHANGED
@@ -135,14 +135,14 @@ export function formatCount(value) {
135
135
  * ANSI codes inside `lines` would inflate the measured width.
136
136
  */
137
137
  export function box(lines, p, title) {
138
- const inner = Math.max(...lines.map((line) => line.length), title ? title.length + 2 : 0);
138
+ const inner = Math.max(...lines.map((line) => stripAnsi(line).length), title ? stripAnsi(title).length + 2 : 0);
139
139
  const top = title
140
- ? `╭─ ${title} ${"─".repeat(Math.max(0, inner - title.length - 1))}╮`
140
+ ? `╭─ ${title} ${"─".repeat(Math.max(0, inner - stripAnsi(title).length - 1))}╮`
141
141
  : `╭${"─".repeat(inner + 2)}╮`;
142
142
  const bottom = `╰${"─".repeat(inner + 2)}╯`;
143
143
  return [
144
144
  p.dim(top),
145
- ...lines.map((line) => `${p.dim("│")} ${line.padEnd(inner)} ${p.dim("│")}`),
145
+ ...lines.map((line) => `${p.dim("│")} ${line}${" ".repeat(Math.max(0, inner - stripAnsi(line).length))} ${p.dim("│")}`),
146
146
  p.dim(bottom),
147
147
  ];
148
148
  }
@@ -10,6 +10,20 @@ export type ClayPeopleSearchPage = {
10
10
  prospects: Prospect[];
11
11
  hasMore: boolean;
12
12
  };
13
+ export type ClayCompany = {
14
+ name?: string;
15
+ domain?: string;
16
+ linkedin?: string;
17
+ description?: string;
18
+ industry?: string;
19
+ size?: string;
20
+ location?: string;
21
+ fundingAmountRange?: string;
22
+ };
23
+ export type ClayCompanySearchPage = {
24
+ companies: ClayCompany[];
25
+ hasMore: boolean;
26
+ };
13
27
  /** Create a forward-only Clay search iterator. This call does not fetch a page. */
14
28
  export declare function createClaySearch(opts: {
15
29
  apiKey: string;
@@ -26,6 +40,30 @@ export declare function runClayPeopleSearchPage(opts: {
26
40
  fetchImpl?: typeof fetch;
27
41
  apiBaseUrl?: string;
28
42
  }): Promise<ClayPeopleSearchPage>;
43
+ /** Consume the next company page from a Clay search iterator. */
44
+ export declare function runClayCompanySearchPage(opts: {
45
+ apiKey: string;
46
+ searchId: string;
47
+ limit?: number;
48
+ fetchImpl?: typeof fetch;
49
+ apiBaseUrl?: string;
50
+ }): Promise<ClayCompanySearchPage>;
51
+ export declare function normalizeClayCompany(value: unknown): ClayCompany;
52
+ /** Account-first investment discovery: find thesis-shaped companies, then
53
+ * resolve founders/operators only inside those accounts. */
54
+ export declare function discoverClayInvestmentProspects(opts: {
55
+ apiKey: string;
56
+ companyFilters: Record<string, unknown>;
57
+ titleKeywords: string[];
58
+ companyLimit: number;
59
+ prospectLimit: number;
60
+ fetchImpl?: typeof fetch;
61
+ apiBaseUrl?: string;
62
+ }): Promise<{
63
+ prospects: Prospect[];
64
+ companiesScanned: number;
65
+ companiesMatched: ClayCompany[];
66
+ }>;
29
67
  export declare function normalizeClayPerson(value: unknown): Prospect;
30
68
  /** Validate a Clay Public API key without creating a search or spending enrichment credits. */
31
69
  export declare function validateClayApiKey(apiKey: string, fetchImpl?: typeof fetch, apiBaseUrl?: string): Promise<ClayKeyValidation>;
@@ -41,6 +41,64 @@ export async function runClayPeopleSearchPage(opts) {
41
41
  throw new Error("Clay people search returned no has_more flag.");
42
42
  return { prospects: body.data.map(normalizeClayPerson), hasMore };
43
43
  }
44
+ /** Consume the next company page from a Clay search iterator. */
45
+ export async function runClayCompanySearchPage(opts) {
46
+ const fetchImpl = opts.fetchImpl ?? fetch;
47
+ const base = (opts.apiBaseUrl ?? CLAY_PUBLIC_API_BASE).replace(/\/$/, "");
48
+ const limit = Math.max(1, Math.min(opts.limit ?? 25, 100));
49
+ const response = await fetchImpl(`${base}/search/filters-mode/${encodeURIComponent(opts.searchId)}/run`, {
50
+ method: "POST", headers: clayHeaders(opts.apiKey), body: JSON.stringify({ limit }),
51
+ });
52
+ if (!response.ok)
53
+ throw new ProviderHttpError("Clay", "run company search", response.status);
54
+ const body = await response.json();
55
+ if (!Array.isArray(body.data))
56
+ throw new Error("Clay company search returned an invalid data array.");
57
+ const hasMore = body.has_more ?? body.hasMore;
58
+ if (typeof hasMore !== "boolean")
59
+ throw new Error("Clay company search returned no has_more flag.");
60
+ return { companies: body.data.map(normalizeClayCompany), hasMore };
61
+ }
62
+ export function normalizeClayCompany(value) {
63
+ const row = value && typeof value === "object" ? value : {};
64
+ return {
65
+ name: stringValue(row.name), domain: bareDomain(stringValue(row.domain)),
66
+ linkedin: normalizeLinkedin(stringValue(row.linkedin_url)), description: stringValue(row.description),
67
+ industry: stringValue(row.industry), size: stringValue(row.size), location: stringValue(row.location),
68
+ fundingAmountRange: stringValue(row.total_funding_amount_range_usd),
69
+ };
70
+ }
71
+ /** Account-first investment discovery: find thesis-shaped companies, then
72
+ * resolve founders/operators only inside those accounts. */
73
+ export async function discoverClayInvestmentProspects(opts) {
74
+ const companySearchId = await createClaySearch({
75
+ apiKey: opts.apiKey, sourceType: "companies", filters: opts.companyFilters,
76
+ fetchImpl: opts.fetchImpl, apiBaseUrl: opts.apiBaseUrl,
77
+ });
78
+ const companyPage = await runClayCompanySearchPage({
79
+ apiKey: opts.apiKey, searchId: companySearchId, limit: opts.companyLimit,
80
+ fetchImpl: opts.fetchImpl, apiBaseUrl: opts.apiBaseUrl,
81
+ });
82
+ const prospects = [];
83
+ const identifiers = companyPage.companies.map((company) => company.domain ?? company.linkedin).filter((value) => Boolean(value));
84
+ if (identifiers.length > 0) {
85
+ const peopleSearchId = await createClaySearch({
86
+ apiKey: opts.apiKey, sourceType: "people",
87
+ filters: { company_identifier: identifiers, job_title_keywords: opts.titleKeywords },
88
+ fetchImpl: opts.fetchImpl, apiBaseUrl: opts.apiBaseUrl,
89
+ });
90
+ const peoplePage = await runClayPeopleSearchPage({
91
+ apiKey: opts.apiKey, searchId: peopleSearchId,
92
+ limit: opts.prospectLimit,
93
+ fetchImpl: opts.fetchImpl, apiBaseUrl: opts.apiBaseUrl,
94
+ });
95
+ // company_identifier may match a past experience. Investment discovery is
96
+ // explicitly account-first, so retain only people currently at an account.
97
+ const currentDomains = new Set(companyPage.companies.map((company) => company.domain).filter(Boolean));
98
+ prospects.push(...peoplePage.prospects.filter((person) => person.companyDomain && currentDomains.has(person.companyDomain)));
99
+ }
100
+ return { prospects, companiesScanned: companyPage.companies.length, companiesMatched: companyPage.companies };
101
+ }
44
102
  export function normalizeClayPerson(value) {
45
103
  const row = value && typeof value === "object" ? value : {};
46
104
  const location = row.structured_location && typeof row.structured_location === "object"
package/dist/icp.d.ts CHANGED
@@ -12,8 +12,35 @@
12
12
  *
13
13
  * Zero runtime deps; pure functions (translation + scoring take plain data).
14
14
  */
15
+ export type TriggerHypothesis = {
16
+ /** Stable, human-editable key used to trace discovered evidence back to the ICP. */
17
+ id: string;
18
+ /** Observable business condition, e.g. "sales compensation process is breaking". */
19
+ label: string;
20
+ /** Phrases or concepts that count as supporting public evidence. */
21
+ positiveEvidence: string[];
22
+ /** Concrete projects/roles that often expose the condition. */
23
+ activeProjects?: string[];
24
+ /** Functions likely to own or feel the problem. */
25
+ buyerFunctions?: string[];
26
+ /** Terms that make a result a likely false positive. */
27
+ negativeEvidence?: string[];
28
+ /** Preferred public source classes. Discovery currently implements `job`. */
29
+ preferredSources?: Array<"job" | "news" | "company" | "social" | "review" | "legal">;
30
+ };
15
31
  export type Icp = {
16
32
  name: string;
33
+ /** `investment` means the organization is sourcing companies to invest in,
34
+ * not conventional customers. Discovery becomes company-first, then resolves
35
+ * founders at the matched accounts. */
36
+ motion?: "sales" | "investment";
37
+ investment?: {
38
+ stages?: Array<"pre-seed" | "seed" | "series-a" | "series-b" | "growth" | "bootstrapped">;
39
+ /** Clay's native funding bands. */
40
+ fundingAmounts?: Array<"under_1m" | "1m_5m" | "5m_10m" | "10m_25m" | "25m_50m" | "50m_100m" | "100m_250m" | "over_250m" | "unknown">;
41
+ /** Terms expected in an investment target's company description. */
42
+ thesisKeywords?: string[];
43
+ };
17
44
  firmographics: {
18
45
  /** human industry labels, e.g. ["software","saas"] — used for keyword/industry filters */
19
46
  industries?: string[];
@@ -38,6 +65,8 @@ export type Icp = {
38
65
  };
39
66
  signals?: {
40
67
  intentTopics?: string[];
68
+ /** Testable behavioral hypotheses for evidence-first account discovery. */
69
+ triggerHypotheses?: TriggerHypothesis[];
41
70
  };
42
71
  scoring?: {
43
72
  /** minimum fit (0..1) for a prospect to become a create_record op. Default 0.5. */
@@ -46,6 +75,10 @@ export type Icp = {
46
75
  };
47
76
  export declare const DEFAULT_FIT_THRESHOLD = 0.5;
48
77
  export declare function parseIcp(raw: string): Icp;
78
+ /** Native Clay company filters for an investment thesis. */
79
+ export declare function icpToClayInvestmentCompanyFilters(icp: Icp): Record<string, unknown>;
80
+ /** People filters used only after an investment target account has matched. */
81
+ export declare function clayInvestmentPeopleFilters(icp: Icp, companyIdentifier: string): Record<string, unknown>;
49
82
  /** Explorium /v1/prospects filters from the ICP. */
50
83
  export declare function icpToExploriumFilters(icp: Icp): Record<string, {
51
84
  values?: string[];