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