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.
Files changed (47) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +18 -7
  3. package/dist/cli.js +634 -56
  4. package/dist/connectors/outboxChannel.d.ts +64 -0
  5. package/dist/connectors/outboxChannel.js +170 -0
  6. package/dist/connectors/prospectSources.d.ts +22 -0
  7. package/dist/connectors/prospectSources.js +43 -0
  8. package/dist/connectors/signalSources.d.ts +105 -0
  9. package/dist/connectors/signalSources.js +316 -0
  10. package/dist/connectors/theirstack.d.ts +84 -0
  11. package/dist/connectors/theirstack.js +125 -0
  12. package/dist/icp.d.ts +47 -3
  13. package/dist/icp.js +105 -11
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.js +1 -0
  16. package/dist/init.js +3 -0
  17. package/dist/judgeEval.d.ts +7 -0
  18. package/dist/judgeEval.js +8 -1
  19. package/dist/runReport.d.ts +26 -0
  20. package/dist/runReport.js +15 -0
  21. package/dist/schedule.js +18 -4
  22. package/dist/signals.d.ts +54 -0
  23. package/dist/signals.js +64 -0
  24. package/dist/tam.d.ts +225 -0
  25. package/dist/tam.js +470 -0
  26. package/docs/api.md +18 -4
  27. package/docs/outbox-format.md +92 -0
  28. package/docs/recipes.md +37 -0
  29. package/docs/roadmap-to-1.0.md +31 -1
  30. package/docs/signal-spool-format.md +162 -0
  31. package/docs/tam.md +195 -0
  32. package/llms.txt +68 -5
  33. package/package.json +1 -1
  34. package/skills/fullstackgtm/SKILL.md +3 -2
  35. package/src/cli.ts +714 -51
  36. package/src/connectors/outboxChannel.ts +202 -0
  37. package/src/connectors/prospectSources.ts +57 -0
  38. package/src/connectors/signalSources.ts +363 -0
  39. package/src/connectors/theirstack.ts +170 -0
  40. package/src/icp.ts +113 -11
  41. package/src/index.ts +32 -0
  42. package/src/init.ts +3 -0
  43. package/src/judgeEval.ts +8 -1
  44. package/src/runReport.ts +39 -0
  45. package/src/schedule.ts +20 -4
  46. package/src/signals.ts +90 -0
  47. package/src/tam.ts +654 -0
@@ -0,0 +1,170 @@
1
+ /**
2
+ * TheirStack — technographic company discovery.
3
+ *
4
+ * Explorium can only size a firmographic universe (NAICS/size/geo) and can't even
5
+ * return the list. For a CRM-hygiene/RevOps tool the real buying signal is
6
+ * "company runs a CRM", and the deliverable is an actual list of those companies.
7
+ * TheirStack does both: filter companies by the technology they use (Salesforce,
8
+ * HubSpot, Pipedrive, …) plus firmographics, and return real records (name,
9
+ * domain, employee_count, …). A cheap count sizes the market; a paged search
10
+ * materializes the list. Both cost ~3 credits per company RETURNED — counting
11
+ * still returns 1 row (the API rejects limit:0), so a count is ~3 credits, not
12
+ * free; a list pull is ~3 × the rows.
13
+ *
14
+ * API: POST https://api.theirstack.com/v1/companies/search, Bearer auth.
15
+ * Verified live (2026-06-29): filters company_technology_slug_or /
16
+ * company_country_code_or / min_employee_count / max_employee_count; the total is
17
+ * `metadata.total_results` with include_total_results:true; limit must be ≥ 1
18
+ * (limit:0 → 422, the count gotcha — cf. Explorium's `size`-caps-the-total).
19
+ */
20
+
21
+ type FetchImpl = typeof fetch;
22
+
23
+ /** TheirStack charges per company RETURNED — verified live (a list pull of N
24
+ * companies spends 3N). The count (limit:1) returns 1 row, so it's ~3 credits. */
25
+ export const THEIRSTACK_CREDITS_PER_COMPANY = 3;
26
+
27
+ export type TheirStackCost = {
28
+ companies: number;
29
+ credits: number;
30
+ /** USD estimate — present ONLY when a per-credit rate is supplied (no
31
+ * fabricated default; TheirStack pricing varies ~$0.04–$0.11/credit by tier). */
32
+ usd?: number;
33
+ };
34
+
35
+ /** Deterministic cost of pulling `companies` records (the credits are the hard
36
+ * fact; the dollar figure needs an explicit rate). */
37
+ export function theirStackPullCost(companies: number, usdPerCredit?: number): TheirStackCost {
38
+ const n = Math.max(0, Math.round(companies));
39
+ const credits = n * THEIRSTACK_CREDITS_PER_COMPANY;
40
+ return {
41
+ companies: n,
42
+ credits,
43
+ ...(usdPerCredit && usdPerCredit > 0 ? { usd: Math.round(credits * usdPerCredit) } : {}),
44
+ };
45
+ }
46
+
47
+ async function safeText(res: { text(): Promise<string> }): Promise<string> {
48
+ try {
49
+ return (await res.text()).slice(0, 300);
50
+ } catch {
51
+ return "";
52
+ }
53
+ }
54
+
55
+ /** Firmographic + technographic filter for TheirStack company search. */
56
+ export type TheirStackFilters = {
57
+ /** technology slugs, OR-matched — e.g. ["salesforce","hubspot","pipedrive"]. */
58
+ company_technology_slug_or?: string[];
59
+ min_employee_count?: number;
60
+ max_employee_count?: number;
61
+ /** ISO2 country codes, OR-matched — e.g. ["US"]. */
62
+ company_country_code_or?: string[];
63
+ };
64
+
65
+ /** A real company record from TheirStack (the materialized list element). */
66
+ export type TheirStackCompany = {
67
+ name?: string;
68
+ domain?: string;
69
+ employeeCount?: number;
70
+ countryCode?: string;
71
+ city?: string;
72
+ linkedinUrl?: string;
73
+ /** the matched technology slugs that put this company in the list. */
74
+ technologies?: string[];
75
+ };
76
+
77
+ const BASE = "https://api.theirstack.com";
78
+ const SEARCH_PATH = "/v1/companies/search";
79
+
80
+ function buildBody(filters: TheirStackFilters, limit: number, page: number, includeTotal: boolean): string {
81
+ // Drop undefined keys so we never send empty filters the API rejects.
82
+ const body: Record<string, unknown> = { limit, page };
83
+ if (includeTotal) body.include_total_results = true;
84
+ if (filters.company_technology_slug_or?.length) body.company_technology_slug_or = filters.company_technology_slug_or;
85
+ if (filters.company_country_code_or?.length) body.company_country_code_or = filters.company_country_code_or;
86
+ if (typeof filters.min_employee_count === "number") body.min_employee_count = filters.min_employee_count;
87
+ if (typeof filters.max_employee_count === "number") body.max_employee_count = filters.max_employee_count;
88
+ return JSON.stringify(body);
89
+ }
90
+
91
+ /** Read the total-match count from the response envelope (field name varies). */
92
+ export function readTheirStackTotal(body: unknown): number | null {
93
+ if (!body || typeof body !== "object") return null;
94
+ const obj = body as Record<string, unknown>;
95
+ const meta = (obj.metadata ?? obj.meta ?? obj) as Record<string, unknown>;
96
+ for (const key of ["total_results", "total_companies", "total_count", "total", "count"]) {
97
+ const v = meta[key];
98
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0) return Math.round(v);
99
+ }
100
+ return null;
101
+ }
102
+
103
+ function mapCompany(row: Record<string, unknown>): TheirStackCompany {
104
+ const str = (v: unknown): string | undefined => (typeof v === "string" && v.trim() ? v : undefined);
105
+ const num = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
106
+ const slugs = row.technology_slugs ?? row.technology_names;
107
+ return {
108
+ name: str(row.name),
109
+ domain: str(row.domain),
110
+ employeeCount: num(row.employee_count),
111
+ countryCode: str(row.country_code),
112
+ city: str(row.city),
113
+ linkedinUrl: str(row.linkedin_url),
114
+ technologies: Array.isArray(slugs) ? slugs.filter((s): s is string => typeof s === "string") : undefined,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Count companies matching the technographic + firmographic filter — the real
120
+ * TAM size. Uses `limit: 1` (the API rejects `limit: 0` with a 422 — verified
121
+ * live) + `include_total_results`, and reads `metadata.total_results`. Costs the
122
+ * 1 returned company's credits; the total itself is the point.
123
+ * Returns null if the envelope carries no total.
124
+ */
125
+ export async function theirStackCountCompanies(opts: {
126
+ apiKey: string;
127
+ filters: TheirStackFilters;
128
+ apiBaseUrl?: string;
129
+ fetchImpl?: FetchImpl;
130
+ }): Promise<number | null> {
131
+ const fetchImpl = opts.fetchImpl ?? fetch;
132
+ const base = (opts.apiBaseUrl ?? BASE).replace(/\/$/, "");
133
+ const res = await fetchImpl(`${base}${SEARCH_PATH}`, {
134
+ method: "POST",
135
+ headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
136
+ body: buildBody(opts.filters, 1, 0, true),
137
+ });
138
+ if (!res.ok) {
139
+ throw new Error(`TheirStack count failed: HTTP ${res.status} ${await safeText(res)}`);
140
+ }
141
+ return readTheirStackTotal(await res.json());
142
+ }
143
+
144
+ /**
145
+ * Pull a page of real companies matching the filter — the materialized list.
146
+ * Returns the records plus the total (when the envelope reports it).
147
+ */
148
+ export async function theirStackSearchCompanies(opts: {
149
+ apiKey: string;
150
+ filters: TheirStackFilters;
151
+ limit?: number;
152
+ page?: number;
153
+ apiBaseUrl?: string;
154
+ fetchImpl?: FetchImpl;
155
+ }): Promise<{ companies: TheirStackCompany[]; total: number | null }> {
156
+ const fetchImpl = opts.fetchImpl ?? fetch;
157
+ const base = (opts.apiBaseUrl ?? BASE).replace(/\/$/, "");
158
+ const limit = Math.min(Math.max(opts.limit ?? 25, 1), 100);
159
+ const res = await fetchImpl(`${base}${SEARCH_PATH}`, {
160
+ method: "POST",
161
+ headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
162
+ body: buildBody(opts.filters, limit, opts.page ?? 0, true),
163
+ });
164
+ if (!res.ok) {
165
+ throw new Error(`TheirStack search failed: HTTP ${res.status} ${await safeText(res)}`);
166
+ }
167
+ const body = (await res.json()) as { data?: Array<Record<string, unknown>>; results?: Array<Record<string, unknown>> };
168
+ const rows = body.data ?? body.results ?? [];
169
+ return { companies: rows.map(mapCompany), total: readTheirStackTotal(body) };
170
+ }
package/src/icp.ts CHANGED
@@ -24,6 +24,10 @@ export type Icp = {
24
24
  employeeBands?: string[];
25
25
  /** ISO country codes, lowercased, e.g. ["us"] */
26
26
  geos?: string[];
27
+ /** technographic targeting: technology slugs the account must use, e.g.
28
+ * ["salesforce","hubspot","pipedrive"] — the real CRM/MAP buying signal,
29
+ * consumed by TheirStack company search. OR-matched. */
30
+ technologies?: string[];
27
31
  };
28
32
  persona: {
29
33
  /** seniority: "cxo","vp","director","manager","owner","senior" */
@@ -79,6 +83,81 @@ export function icpToExploriumFilters(icp: Icp): Record<string, { values?: strin
79
83
  return f;
80
84
  }
81
85
 
86
+ /**
87
+ * Explorium /v1/businesses filters from the ICP — the COMPANY (account) side, for
88
+ * sizing the account universe (TAM). Firmographics only, no persona: the count is
89
+ * of matching companies. Field names differ from /v1/prospects (verified live):
90
+ * `country_code` (not company_country_code), `company_size` (same employee bands),
91
+ * `naics_category`. `/v1/businesses` total_results is a real count, capped at
92
+ * 60,000 (see EXPLORIUM_BUSINESS_COUNT_CAP in the connector).
93
+ */
94
+ export function icpToExploriumBusinessFilters(icp: Icp): Record<string, { values?: string[] }> {
95
+ const f: Record<string, { values?: string[] }> = {};
96
+ if (icp.firmographics.geos?.length) f.country_code = { values: icp.firmographics.geos };
97
+ if (icp.firmographics.employeeBands?.length) f.company_size = { values: icp.firmographics.employeeBands };
98
+ if (icp.firmographics.naics?.length) f.naics_category = { values: icp.firmographics.naics };
99
+ return f;
100
+ }
101
+
102
+ /**
103
+ * Collapse provider-agnostic employee bands ("51-200","10001+") into a single
104
+ * {min,max} envelope for APIs that take integer bounds (TheirStack). An open
105
+ * top band ("10001+") leaves max undefined.
106
+ */
107
+ export function employeeBandsToRange(bands: string[] | undefined): { min?: number; max?: number } {
108
+ if (!bands?.length) return {};
109
+ let min: number | undefined;
110
+ let max: number | undefined;
111
+ let openTop = false;
112
+ for (const band of bands) {
113
+ const plus = /^(\d+)\+$/.exec(band.trim());
114
+ if (plus) {
115
+ const lo = Number(plus[1]);
116
+ if (min === undefined || lo < min) min = lo;
117
+ openTop = true;
118
+ continue;
119
+ }
120
+ const range = /^(\d+)\s*-\s*(\d+)$/.exec(band.trim());
121
+ if (range) {
122
+ const lo = Number(range[1]);
123
+ const hi = Number(range[2]);
124
+ if (min === undefined || lo < min) min = lo;
125
+ if (max === undefined || hi > max) max = hi;
126
+ }
127
+ }
128
+ return { min, max: openTop ? undefined : max };
129
+ }
130
+
131
+ /**
132
+ * TheirStack company-search filter from the ICP: the technographic targeting that
133
+ * Explorium can't do. `company_technology_slug_or` is the CRM/MAP buying signal
134
+ * (firmographics.technologies); employee bands become min/max bounds; geos become
135
+ * ISO2 codes (uppercased).
136
+ */
137
+ export function icpToTheirStackFilters(icp: Icp): {
138
+ company_technology_slug_or?: string[];
139
+ min_employee_count?: number;
140
+ max_employee_count?: number;
141
+ company_country_code_or?: string[];
142
+ } {
143
+ const f: {
144
+ company_technology_slug_or?: string[];
145
+ min_employee_count?: number;
146
+ max_employee_count?: number;
147
+ company_country_code_or?: string[];
148
+ } = {};
149
+ if (icp.firmographics.technologies?.length) {
150
+ f.company_technology_slug_or = icp.firmographics.technologies.map((t) => t.trim().toLowerCase());
151
+ }
152
+ if (icp.firmographics.geos?.length) {
153
+ f.company_country_code_or = icp.firmographics.geos.map((g) => g.trim().toUpperCase());
154
+ }
155
+ const range = employeeBandsToRange(icp.firmographics.employeeBands);
156
+ if (range.min !== undefined) f.min_employee_count = range.min;
157
+ if (range.max !== undefined) f.max_employee_count = range.max;
158
+ return f;
159
+ }
160
+
82
161
  /**
83
162
  * Crustdata / LinkedIn seniority vocab. ICP job levels are normalized lowercase;
84
163
  * Crustdata expects these exact capitalized strings (verified: "CXO",
@@ -101,16 +180,31 @@ const CRUSTDATA_SENIORITY: Record<string, string> = {
101
180
  };
102
181
 
103
182
  /**
104
- * ICP industry keyword → LinkedIn industry names Crustdata filters on. Includes
105
- * the current LinkedIn-v2 names; for software we send the cluster (dev + IT
106
- * services + internet) so an OR match isn't overly narrow.
183
+ * ICP industry keyword → LinkedIn industry names Crustdata filters on.
184
+ *
185
+ * LinkedIn renamed its industry taxonomy (v1 v2, ~2022), and data vendors
186
+ * normalize to one generation or the other. Sending ONLY the v2 names risks a
187
+ * zero-match when Crustdata stores v1 (and vice-versa) — observed live: a RevOps
188
+ * ICP whose only firmographic constraint was the v2 names returned 0 even though
189
+ * the title-only search returned plenty. So each cluster sends BOTH generations
190
+ * (OR-matched within the field): v2 ("Software Development", "IT Services and IT
191
+ * Consulting", "Technology, Information and Internet") AND v1 ("Computer
192
+ * Software", "Information Technology & Services", "Internet").
107
193
  */
108
194
  const CRUSTDATA_INDUSTRY: Record<string, string[]> = {
109
- software: ["Software Development", "Information Technology and Services", "Internet"],
110
- saas: ["Software Development", "Information Technology and Services"],
111
- "information technology & services": ["Information Technology and Services"],
112
- "information technology and services": ["Information Technology and Services"],
113
- internet: ["Internet"],
195
+ software: [
196
+ "Software Development",
197
+ "Computer Software",
198
+ "IT Services and IT Consulting",
199
+ "Information Technology & Services",
200
+ "Information Technology and Services",
201
+ "Technology, Information and Internet",
202
+ "Internet",
203
+ ],
204
+ saas: ["Software Development", "Computer Software", "IT Services and IT Consulting", "Information Technology & Services"],
205
+ "information technology & services": ["Information Technology & Services", "IT Services and IT Consulting"],
206
+ "information technology and services": ["Information Technology & Services", "IT Services and IT Consulting"],
207
+ internet: ["Internet", "Technology, Information and Internet"],
114
208
  fintech: ["Financial Services"],
115
209
  "financial services": ["Financial Services"],
116
210
  };
@@ -119,9 +213,17 @@ const CRUSTDATA_INDUSTRY: Record<string, string[]> = {
119
213
  * pipe0 Crustdata people-search config.filters from the ICP. `current_job_titles`
120
214
  * matches real LinkedIn title strings (case-sensitive), so keywords are Title
121
215
  * Cased. Seniority + industry are mapped to Crustdata's controlled vocab via the
122
- * tables above. NOTE: the exact pipe0→Crustdata value set could not be re-run
123
- * live (pipe0 credits were exhausted) — validate when credits refill; fit
124
- * scoring is the safety net for persona precision regardless.
216
+ * tables above.
217
+ *
218
+ * LIVE FINDINGS (2026-06-26): `current_job_titles` is confirmed working (a
219
+ * titles-only RevOps search returns results); `current_title` is rejected (422).
220
+ * The full ICP filter returned 0 — the prime suspect is the industry vocab
221
+ * (LinkedIn v1/v2 taxonomy mismatch), now hedged by sending BOTH generations in
222
+ * CRUSTDATA_INDUSTRY above. NOT yet re-confirmed end-to-end (pipe0 credits were
223
+ * exhausted mid-investigation) — re-run `enrich acquire --source pipe0` once
224
+ * credits refill; if it still returns 0, the next suspects are the
225
+ * `current_seniority_levels` shape/values and `locations`. Note fit-scoring backs
226
+ * up PERSONA precision but NOT industry, so the industry filter is load-bearing.
125
227
  */
126
228
  export function icpToCrustdataFilters(icp: Icp): Record<string, unknown> {
127
229
  const f: Record<string, unknown> = {};
package/src/index.ts CHANGED
@@ -107,6 +107,38 @@ export {
107
107
  type ScaffoldFile,
108
108
  type ScaffoldOptions,
109
109
  } from "./init.ts";
110
+ export {
111
+ appendCoverage,
112
+ classifyAccount,
113
+ classifyCoverage,
114
+ computeCoverage,
115
+ coverageCountsFromSnapshot,
116
+ coveredAccounts,
117
+ coverageToText,
118
+ crmCheckableCriteria,
119
+ deriveAcvFromClosedWon,
120
+ deriveBuyersPerAccount,
121
+ estimateTam,
122
+ loadTamModel,
123
+ projectEta,
124
+ readCoverageTimeline,
125
+ saveTamModel,
126
+ tamDir,
127
+ tamReportToMarkdown,
128
+ type AccountTamClass,
129
+ type AcvBasis,
130
+ type DerivedAcv,
131
+ type DerivedBuyers,
132
+ type EstimateTamInput,
133
+ type TamClassified,
134
+ type TamCoverage,
135
+ type TamCoverageCounts,
136
+ type TamCrossCheck,
137
+ type TamEta,
138
+ type TamModel,
139
+ type TamTargeting,
140
+ type TamUniverse,
141
+ } from "./tam.ts";
110
142
  export {
111
143
  apolloPullKeysForAppend,
112
144
  apolloPullKeysForRefresh,
package/src/init.ts CHANGED
@@ -45,6 +45,9 @@ export function starterIcp(): Icp {
45
45
  industries: ["software", "saas"],
46
46
  employeeBands: ["51-200", "201-500", "501-1000"],
47
47
  geos: ["us"],
48
+ // Technographic targeting (the real RevOps signal): companies that USE a
49
+ // CRM/MAP. Drives `tam estimate|accounts --source theirstack`.
50
+ technologies: ["salesforce", "hubspot", "pipedrive"],
48
51
  },
49
52
  persona: {
50
53
  jobLevels: ["vp", "director", "manager"],
package/src/judgeEval.ts CHANGED
@@ -205,7 +205,14 @@ export function defaultJudgeFn(opts: { config?: SignalsConfig; icp?: Icp; now?:
205
205
  // ---------------------------------------------------------------------------
206
206
  // The default golden set (ships inline so `icp eval --golden default` is free)
207
207
 
208
- const GOLD_NOW_ISO = "2026-06-23T12:00:00.000Z";
208
+ /**
209
+ * The default golden set's clock. Every `firstSeen` below is relative to this
210
+ * instant, so grading MUST pin `now: new Date(DEFAULT_GOLDEN_NOW_ISO)` —
211
+ * grading against wall time lets freshness decay rot the "fresh → send" rows,
212
+ * and the gate starts failing on a calendar date instead of a code change.
213
+ */
214
+ export const DEFAULT_GOLDEN_NOW_ISO = "2026-06-23T12:00:00.000Z";
215
+ const GOLD_NOW_ISO = DEFAULT_GOLDEN_NOW_ISO;
209
216
 
210
217
  function goldSignal(over: {
211
218
  accountDomain: string;
package/src/runReport.ts CHANGED
@@ -11,7 +11,34 @@ import { getCredential } from "./credentials.ts";
11
11
 
12
12
  type RunEvent = { ts: number; type: string; detail?: string };
13
13
 
14
+ /**
15
+ * Per-row finding for the hosted run timeline. IDs + issue type ONLY — no field
16
+ * values ever leave the CLI, keeping the audit's row data on the operator's side
17
+ * while still giving the dashboard navigable, filterable detail.
18
+ */
19
+ export type RunFinding = {
20
+ objectType: string;
21
+ objectId: string;
22
+ severity: string;
23
+ ruleId: string;
24
+ field?: string;
25
+ operation?: string;
26
+ };
27
+
28
+ /**
29
+ * Where a run's findings live in the source CRM, so the dashboard can deep-link
30
+ * each record. `recordUrlBase` is the provider's record URL prefix; the
31
+ * dashboard appends the per-object path. Just an URL prefix — no record data.
32
+ */
33
+ export type RunCrm = { provider: string; recordUrlBase: string };
34
+
35
+ // Bound the fire-and-forget POST: a pathological audit could surface thousands
36
+ // of findings; cap what we ship (counts still carry the true total).
37
+ const MAX_REPORTED_FINDINGS = 2000;
38
+
14
39
  let counts: Record<string, unknown> | undefined;
40
+ let findings: RunFinding[] | undefined;
41
+ let crm: RunCrm | undefined;
15
42
  const events: RunEvent[] = [];
16
43
 
17
44
  /** A command annotates its headline metrics (merged). */
@@ -19,6 +46,16 @@ export function reportCounts(values: Record<string, unknown>): void {
19
46
  counts = { ...(counts ?? {}), ...values };
20
47
  }
21
48
 
49
+ /** A command reports per-row findings (IDs + issue type, no values). */
50
+ export function reportFindings(values: RunFinding[]): void {
51
+ findings = values.slice(0, MAX_REPORTED_FINDINGS);
52
+ }
53
+
54
+ /** A command reports the source CRM's record-URL base for dashboard deep-links. */
55
+ export function reportCrm(value: RunCrm): void {
56
+ crm = value;
57
+ }
58
+
22
59
  /** A command annotates a structured event (plan saved, meter charged, …). */
23
60
  export function reportEvent(type: string, detail?: string): void {
24
61
  events.push({ ts: Date.now(), type, detail });
@@ -65,6 +102,8 @@ export async function flushRunReport(
65
102
  finishedAt,
66
103
  durationMs: finishedAt - startedAt,
67
104
  counts,
105
+ findings: findings?.length ? findings : undefined,
106
+ crm,
68
107
  events: events.length ? events : undefined,
69
108
  error,
70
109
  }),
package/src/schedule.ts CHANGED
@@ -84,7 +84,13 @@ const SCHEDULABLE: Record<string, string[] | null> = {
84
84
  suggest: null,
85
85
  report: null,
86
86
  doctor: null,
87
- enrich: ["append", "refresh"],
87
+ // `enrich append|refresh` fill blanks; `enrich acquire` creates net-new
88
+ // leads — but ONLY as a needs_approval `create_record` plan (`--save`,
89
+ // required below), never a write. This is what lets `tam populate` chip away
90
+ // at a TAM unattended: each firing queues a fresh lead plan, the meter is
91
+ // charged only at apply, and apply stays `apply --plan-id` (re-checked
92
+ // approved). So scheduled acquire accumulates proposals, never surprise leads.
93
+ enrich: ["append", "refresh", "acquire"],
88
94
  market: ["capture", "refresh"],
89
95
  // The GTM brain. `signals fetch` is read-only re: CRM (--save persists only
90
96
  // the local signal ledger). `icp judge`/`icp eval` are read-only/grade-only
@@ -98,9 +104,9 @@ const SCHEDULABLE: Record<string, string[] | null> = {
98
104
  };
99
105
 
100
106
  const ALLOWLIST_SUMMARY =
101
- "audit, snapshot, enrich append|refresh, market capture|refresh, signals fetch, " +
102
- "icp judge|eval, draft (stages a plan), suggest, report, doctor — " +
103
- "plus apply --plan-id <id> (re-checked approved at every firing)";
107
+ "audit, snapshot, enrich append|refresh, enrich acquire --save (stages a lead plan), " +
108
+ "market capture|refresh, signals fetch, icp judge|eval, draft (stages a plan), " +
109
+ "suggest, report, doctor — plus apply --plan-id <id> (re-checked approved at every firing)";
104
110
 
105
111
  /**
106
112
  * Validate that an argv resolves to a schedulable fullstackgtm command.
@@ -158,6 +164,16 @@ export function validateSchedulableArgv(argv: string[]): void {
158
164
  .join(", ")}.`,
159
165
  );
160
166
  }
167
+ // `enrich acquire` is schedulable ONLY in its plan-producing form: without
168
+ // --save it's a dry-run that writes nothing AND queues nothing, so an
169
+ // unattended firing would silently no-op. Require --save so a scheduled
170
+ // acquire always leaves a needs_approval plan behind (apply stays gated).
171
+ if (head === "enrich" && sub === "acquire" && !argv.includes("--save")) {
172
+ throw new Error(
173
+ "Scheduled `enrich acquire` must include --save so each firing queues a needs_approval lead plan " +
174
+ "(without it the run produces nothing). Apply stays a separate human gate (`apply --plan-id <id>`).",
175
+ );
176
+ }
161
177
  }
162
178
  }
163
179
 
package/src/signals.ts CHANGED
@@ -416,6 +416,71 @@ function withinWindow(iso: string, now: Date, windowDays: number): boolean {
416
416
  return now.getTime() - t <= windowDays * DAY_MS;
417
417
  }
418
418
 
419
+ // ---------------------------------------------------------------------------
420
+ // Staged rows -> signals (shared by `--from` ingest and source connectors)
421
+
422
+ /**
423
+ * One staged signal row: the platform-agnostic intake shape every NON-job source
424
+ * produces (a source connector's output, or a `--from` JSON row). It carries the
425
+ * evidence anchor (`quote`) but none of the derived fields (`id`, `weight`,
426
+ * `source`) — `stagedRowToSignal` fills those so there is ONE place that gates
427
+ * evidence and stamps identity, whether the row came from a file or an API.
428
+ */
429
+ export type StagedSignalRow = {
430
+ bucket: SignalBucket;
431
+ accountDomain: string;
432
+ trigger: string;
433
+ /** VERBATIM evidence — required, non-empty. */
434
+ quote: string;
435
+ sourceUrl?: string;
436
+ /** ISO 8601; defaults to run time when absent. */
437
+ firstSeen?: string;
438
+ /** Optional explicit weight; defaults to the bucket's configured weight. */
439
+ weight?: number;
440
+ };
441
+
442
+ /**
443
+ * Validate one loosely-typed staged row and turn it into a `Signal`, applying
444
+ * the verbatim-evidence gate (a row with no quote is rejected, never faked) and
445
+ * the same id/normalization logic the ATS path uses. `errorLabel` lets the
446
+ * caller produce a precise message ("--from f.json: row 3", "serpapi-news row 0")
447
+ * since both the `--from` ingest and the source-connector registry funnel here.
448
+ *
449
+ * Throws on a malformed row; returns the canonical `Signal` on success. Bucket
450
+ * FILTERING (skip rows outside a `--bucket` selection) stays with the caller —
451
+ * this function is per-row validation only.
452
+ */
453
+ export function stagedRowToSignal(
454
+ entry: Record<string, unknown>,
455
+ opts: { now: Date; source: string; errorLabel: string },
456
+ ): Signal {
457
+ const { now, source, errorLabel } = opts;
458
+ const bucket = String(entry.bucket ?? "");
459
+ if (!SIGNAL_BUCKETS.includes(bucket as SignalBucket)) {
460
+ throw new Error(`${errorLabel} has unknown bucket "${bucket}" (one of ${SIGNAL_BUCKETS.join(", ")}).`);
461
+ }
462
+ const accountDomain = normalizeAccountDomain(String(entry.accountDomain ?? entry.domain ?? ""));
463
+ if (!accountDomain) throw new Error(`${errorLabel} is missing accountDomain.`);
464
+ const trigger = String(entry.trigger ?? "").trim();
465
+ if (!trigger) throw new Error(`${errorLabel} is missing trigger.`);
466
+ const quote = String(entry.quote ?? "").trim();
467
+ if (!quote) throw new Error(`${errorLabel} is missing the verbatim quote (the evidence anchor).`);
468
+ const base = { accountDomain, bucket: bucket as SignalBucket, trigger };
469
+ const firstSeen = typeof entry.firstSeen === "string" && entry.firstSeen ? entry.firstSeen : now.toISOString();
470
+ return {
471
+ id: signalId(base),
472
+ accountDomain,
473
+ bucket: bucket as SignalBucket,
474
+ trigger,
475
+ quote,
476
+ sourceUrl: String(entry.sourceUrl ?? ""),
477
+ firstSeen,
478
+ weight: typeof entry.weight === "number" ? entry.weight : DEFAULT_SIGNALS_CONFIG.buckets[bucket as SignalBucket].weight,
479
+ source,
480
+ judgedBy: null,
481
+ };
482
+ }
483
+
419
484
  // ---------------------------------------------------------------------------
420
485
  // Dedup
421
486
 
@@ -533,6 +598,31 @@ export function signalsDir(baseDir?: string): string {
533
598
  return join(baseDir ?? credentialsDir(), "signals");
534
599
  }
535
600
 
601
+ /**
602
+ * Conventional webhook landing zone: `<signals>/spool`, profile-scoped. A
603
+ * webhook receiver (hosted, or the operator's own glue) appends one JSONL row
604
+ * per event to a `*.jsonl` file here; `signals fetch --connector file` reads the
605
+ * whole directory when given no explicit path. The CLI never writes here — the
606
+ * receiver does — so this is just the agreed-upon location, not a managed store.
607
+ * Per-source files (`rb2b.jsonl`, `hubspot.jsonl`, …) coexist. See
608
+ * docs/signal-spool-format.md.
609
+ */
610
+ export function signalsSpoolDir(baseDir?: string): string {
611
+ return join(signalsDir(baseDir), "spool");
612
+ }
613
+
614
+ /**
615
+ * Conventional outbox: `<signals>/outbox`, profile-scoped — the SEND-side mirror
616
+ * of the spool. `apply --channel outbox` renders each APPROVED drafted opener to
617
+ * a `<channel>.jsonl` file here (one row per touch); a downstream sender (hosted,
618
+ * or the operator's own) drains it. The CLI WRITES governed, approved send
619
+ * intents here but TRANSMITS NOTHING — the "drafts everything, transmits nothing"
620
+ * invariant holds. See docs/outbox-format.md.
621
+ */
622
+ export function signalsOutboxDir(baseDir?: string): string {
623
+ return join(signalsDir(baseDir), "outbox");
624
+ }
625
+
536
626
  export type SignalRun = {
537
627
  id: string;
538
628
  runLabel: string;