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
package/dist/icp.js CHANGED
@@ -54,6 +54,77 @@ export function icpToExploriumFilters(icp) {
54
54
  f.naics_category = { values: icp.firmographics.naics };
55
55
  return f;
56
56
  }
57
+ /**
58
+ * Explorium /v1/businesses filters from the ICP — the COMPANY (account) side, for
59
+ * sizing the account universe (TAM). Firmographics only, no persona: the count is
60
+ * of matching companies. Field names differ from /v1/prospects (verified live):
61
+ * `country_code` (not company_country_code), `company_size` (same employee bands),
62
+ * `naics_category`. `/v1/businesses` total_results is a real count, capped at
63
+ * 60,000 (see EXPLORIUM_BUSINESS_COUNT_CAP in the connector).
64
+ */
65
+ export function icpToExploriumBusinessFilters(icp) {
66
+ const f = {};
67
+ if (icp.firmographics.geos?.length)
68
+ f.country_code = { values: icp.firmographics.geos };
69
+ if (icp.firmographics.employeeBands?.length)
70
+ f.company_size = { values: icp.firmographics.employeeBands };
71
+ if (icp.firmographics.naics?.length)
72
+ f.naics_category = { values: icp.firmographics.naics };
73
+ return f;
74
+ }
75
+ /**
76
+ * Collapse provider-agnostic employee bands ("51-200","10001+") into a single
77
+ * {min,max} envelope for APIs that take integer bounds (TheirStack). An open
78
+ * top band ("10001+") leaves max undefined.
79
+ */
80
+ export function employeeBandsToRange(bands) {
81
+ if (!bands?.length)
82
+ return {};
83
+ let min;
84
+ let max;
85
+ let openTop = false;
86
+ for (const band of bands) {
87
+ const plus = /^(\d+)\+$/.exec(band.trim());
88
+ if (plus) {
89
+ const lo = Number(plus[1]);
90
+ if (min === undefined || lo < min)
91
+ min = lo;
92
+ openTop = true;
93
+ continue;
94
+ }
95
+ const range = /^(\d+)\s*-\s*(\d+)$/.exec(band.trim());
96
+ if (range) {
97
+ const lo = Number(range[1]);
98
+ const hi = Number(range[2]);
99
+ if (min === undefined || lo < min)
100
+ min = lo;
101
+ if (max === undefined || hi > max)
102
+ max = hi;
103
+ }
104
+ }
105
+ return { min, max: openTop ? undefined : max };
106
+ }
107
+ /**
108
+ * TheirStack company-search filter from the ICP: the technographic targeting that
109
+ * Explorium can't do. `company_technology_slug_or` is the CRM/MAP buying signal
110
+ * (firmographics.technologies); employee bands become min/max bounds; geos become
111
+ * ISO2 codes (uppercased).
112
+ */
113
+ export function icpToTheirStackFilters(icp) {
114
+ const f = {};
115
+ if (icp.firmographics.technologies?.length) {
116
+ f.company_technology_slug_or = icp.firmographics.technologies.map((t) => t.trim().toLowerCase());
117
+ }
118
+ if (icp.firmographics.geos?.length) {
119
+ f.company_country_code_or = icp.firmographics.geos.map((g) => g.trim().toUpperCase());
120
+ }
121
+ const range = employeeBandsToRange(icp.firmographics.employeeBands);
122
+ if (range.min !== undefined)
123
+ f.min_employee_count = range.min;
124
+ if (range.max !== undefined)
125
+ f.max_employee_count = range.max;
126
+ return f;
127
+ }
57
128
  /**
58
129
  * Crustdata / LinkedIn seniority vocab. ICP job levels are normalized lowercase;
59
130
  * Crustdata expects these exact capitalized strings (verified: "CXO",
@@ -75,16 +146,31 @@ const CRUSTDATA_SENIORITY = {
75
146
  entry: "Entry",
76
147
  };
77
148
  /**
78
- * ICP industry keyword → LinkedIn industry names Crustdata filters on. Includes
79
- * the current LinkedIn-v2 names; for software we send the cluster (dev + IT
80
- * services + internet) so an OR match isn't overly narrow.
149
+ * ICP industry keyword → LinkedIn industry names Crustdata filters on.
150
+ *
151
+ * LinkedIn renamed its industry taxonomy (v1 v2, ~2022), and data vendors
152
+ * normalize to one generation or the other. Sending ONLY the v2 names risks a
153
+ * zero-match when Crustdata stores v1 (and vice-versa) — observed live: a RevOps
154
+ * ICP whose only firmographic constraint was the v2 names returned 0 even though
155
+ * the title-only search returned plenty. So each cluster sends BOTH generations
156
+ * (OR-matched within the field): v2 ("Software Development", "IT Services and IT
157
+ * Consulting", "Technology, Information and Internet") AND v1 ("Computer
158
+ * Software", "Information Technology & Services", "Internet").
81
159
  */
82
160
  const CRUSTDATA_INDUSTRY = {
83
- software: ["Software Development", "Information Technology and Services", "Internet"],
84
- saas: ["Software Development", "Information Technology and Services"],
85
- "information technology & services": ["Information Technology and Services"],
86
- "information technology and services": ["Information Technology and Services"],
87
- internet: ["Internet"],
161
+ software: [
162
+ "Software Development",
163
+ "Computer Software",
164
+ "IT Services and IT Consulting",
165
+ "Information Technology & Services",
166
+ "Information Technology and Services",
167
+ "Technology, Information and Internet",
168
+ "Internet",
169
+ ],
170
+ saas: ["Software Development", "Computer Software", "IT Services and IT Consulting", "Information Technology & Services"],
171
+ "information technology & services": ["Information Technology & Services", "IT Services and IT Consulting"],
172
+ "information technology and services": ["Information Technology & Services", "IT Services and IT Consulting"],
173
+ internet: ["Internet", "Technology, Information and Internet"],
88
174
  fintech: ["Financial Services"],
89
175
  "financial services": ["Financial Services"],
90
176
  };
@@ -92,9 +178,17 @@ const CRUSTDATA_INDUSTRY = {
92
178
  * pipe0 Crustdata people-search config.filters from the ICP. `current_job_titles`
93
179
  * matches real LinkedIn title strings (case-sensitive), so keywords are Title
94
180
  * Cased. Seniority + industry are mapped to Crustdata's controlled vocab via the
95
- * tables above. NOTE: the exact pipe0→Crustdata value set could not be re-run
96
- * live (pipe0 credits were exhausted) — validate when credits refill; fit
97
- * scoring is the safety net for persona precision regardless.
181
+ * tables above.
182
+ *
183
+ * LIVE FINDINGS (2026-06-26): `current_job_titles` is confirmed working (a
184
+ * titles-only RevOps search returns results); `current_title` is rejected (422).
185
+ * The full ICP filter returned 0 — the prime suspect is the industry vocab
186
+ * (LinkedIn v1/v2 taxonomy mismatch), now hedged by sending BOTH generations in
187
+ * CRUSTDATA_INDUSTRY above. NOT yet re-confirmed end-to-end (pipe0 credits were
188
+ * exhausted mid-investigation) — re-run `enrich acquire --source pipe0` once
189
+ * credits refill; if it still returns 0, the next suspects are the
190
+ * `current_seniority_levels` shape/values and `locations`. Note fit-scoring backs
191
+ * up PERSONA precision but NOT industry, so the industry filter is load-bearing.
98
192
  */
99
193
  export function icpToCrustdataFilters(icp) {
100
194
  const f = {};
package/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export { activeProfile, credentialsDir, credentialsPath, DEFAULT_PROFILE, delete
14
14
  export { generateDemoSnapshot, type DemoSnapshotOptions } from "./demo.ts";
15
15
  export { buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, enrichRunId, inferIngestObjectType, ingestKeyValue, latestStamps, loadEnrichConfig, matchSourceRecord, parseCsv, parseEnrichConfig, resolveCrmField, selectStaleWork, sourceValueAt, stagedSourceRecords, staleDaysFor, type BuildEnrichPlanOptions, type EnrichAmbiguity, type EnrichConfig, type EnrichCounts, type EnrichFieldConfig, type EnrichMatchConfig, type EnrichMode, type EnrichObjectType, type EnrichPlanResult, type EnrichRun, type EnrichRunStore, type EnrichSourceConfig, type EnrichSourceRecord, type EnrichStamp, type EnrichWorkItem, type MatchOutcome, } from "./enrich.ts";
16
16
  export { scaffoldWorkspace, starterEnrichConfig, starterIcp, starterPlaybook, type InitProvider, type InitSource, type ScaffoldFile, type ScaffoldOptions, } from "./init.ts";
17
+ export { appendCoverage, classifyAccount, classifyCoverage, computeCoverage, coverageCountsFromSnapshot, coveredAccounts, coverageToText, crmCheckableCriteria, deriveAcvFromClosedWon, deriveBuyersPerAccount, estimateTam, loadTamModel, projectEta, readCoverageTimeline, saveTamModel, tamDir, tamReportToMarkdown, type AccountTamClass, type AcvBasis, type DerivedAcv, type DerivedBuyers, type EstimateTamInput, type TamClassified, type TamCoverage, type TamCoverageCounts, type TamCrossCheck, type TamEta, type TamModel, type TamTargeting, type TamUniverse, } from "./tam.ts";
17
18
  export { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, type ApolloClient, type ApolloClientOptions, type ApolloPullKey, type ApolloPullResult, } from "./enrichApollo.ts";
18
19
  export { diffFindings, diffSnapshots, diffToMarkdown, type CollectionDiff, type FieldChange, type FindingsDrift, type RecordChange, type SnapshotDiff, } from "./diff.ts";
19
20
  export { mergeSnapshots, type MergeConflict, type MergeMatch, type MergeReport, type MergeSuggestion, } from "./merge.ts";
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ export { activeProfile, credentialsDir, credentialsPath, DEFAULT_PROFILE, delete
14
14
  export { generateDemoSnapshot } from "./demo.js";
15
15
  export { buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, enrichRunId, inferIngestObjectType, ingestKeyValue, latestStamps, loadEnrichConfig, matchSourceRecord, parseCsv, parseEnrichConfig, resolveCrmField, selectStaleWork, sourceValueAt, stagedSourceRecords, staleDaysFor, } from "./enrich.js";
16
16
  export { scaffoldWorkspace, starterEnrichConfig, starterIcp, starterPlaybook, } from "./init.js";
17
+ export { appendCoverage, classifyAccount, classifyCoverage, computeCoverage, coverageCountsFromSnapshot, coveredAccounts, coverageToText, crmCheckableCriteria, deriveAcvFromClosedWon, deriveBuyersPerAccount, estimateTam, loadTamModel, projectEta, readCoverageTimeline, saveTamModel, tamDir, tamReportToMarkdown, } from "./tam.js";
17
18
  export { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, } from "./enrichApollo.js";
18
19
  export { diffFindings, diffSnapshots, diffToMarkdown, } from "./diff.js";
19
20
  export { mergeSnapshots, } from "./merge.js";
package/dist/init.js CHANGED
@@ -28,6 +28,9 @@ export function starterIcp() {
28
28
  industries: ["software", "saas"],
29
29
  employeeBands: ["51-200", "201-500", "501-1000"],
30
30
  geos: ["us"],
31
+ // Technographic targeting (the real RevOps signal): companies that USE a
32
+ // CRM/MAP. Drives `tam estimate|accounts --source theirstack`.
33
+ technologies: ["salesforce", "hubspot", "pipedrive"],
31
34
  },
32
35
  persona: {
33
36
  jobLevels: ["vp", "director", "manager"],
@@ -110,6 +110,13 @@ export declare function defaultJudgeFn(opts?: {
110
110
  icp?: Icp;
111
111
  now?: Date;
112
112
  }): EvalJudgeFn;
113
+ /**
114
+ * The default golden set's clock. Every `firstSeen` below is relative to this
115
+ * instant, so grading MUST pin `now: new Date(DEFAULT_GOLDEN_NOW_ISO)` —
116
+ * grading against wall time lets freshness decay rot the "fresh → send" rows,
117
+ * and the gate starts failing on a calendar date instead of a code change.
118
+ */
119
+ export declare const DEFAULT_GOLDEN_NOW_ISO = "2026-06-23T12:00:00.000Z";
113
120
  /**
114
121
  * A starter labeled set covering the four rubric corners, so `icp eval --golden
115
122
  * default` runs offline and the deterministic baseline passes it (>= the default
package/dist/judgeEval.js CHANGED
@@ -85,7 +85,14 @@ export function defaultJudgeFn(opts = {}) {
85
85
  }
86
86
  // ---------------------------------------------------------------------------
87
87
  // The default golden set (ships inline so `icp eval --golden default` is free)
88
- const GOLD_NOW_ISO = "2026-06-23T12:00:00.000Z";
88
+ /**
89
+ * The default golden set's clock. Every `firstSeen` below is relative to this
90
+ * instant, so grading MUST pin `now: new Date(DEFAULT_GOLDEN_NOW_ISO)` —
91
+ * grading against wall time lets freshness decay rot the "fresh → send" rows,
92
+ * and the gate starts failing on a calendar date instead of a code change.
93
+ */
94
+ export const DEFAULT_GOLDEN_NOW_ISO = "2026-06-23T12:00:00.000Z";
95
+ const GOLD_NOW_ISO = DEFAULT_GOLDEN_NOW_ISO;
89
96
  function goldSignal(over) {
90
97
  const domain = normalizeAccountDomain(over.accountDomain);
91
98
  return {
@@ -1,5 +1,31 @@
1
+ /**
2
+ * Per-row finding for the hosted run timeline. IDs + issue type ONLY — no field
3
+ * values ever leave the CLI, keeping the audit's row data on the operator's side
4
+ * while still giving the dashboard navigable, filterable detail.
5
+ */
6
+ export type RunFinding = {
7
+ objectType: string;
8
+ objectId: string;
9
+ severity: string;
10
+ ruleId: string;
11
+ field?: string;
12
+ operation?: string;
13
+ };
14
+ /**
15
+ * Where a run's findings live in the source CRM, so the dashboard can deep-link
16
+ * each record. `recordUrlBase` is the provider's record URL prefix; the
17
+ * dashboard appends the per-object path. Just an URL prefix — no record data.
18
+ */
19
+ export type RunCrm = {
20
+ provider: string;
21
+ recordUrlBase: string;
22
+ };
1
23
  /** A command annotates its headline metrics (merged). */
2
24
  export declare function reportCounts(values: Record<string, unknown>): void;
25
+ /** A command reports per-row findings (IDs + issue type, no values). */
26
+ export declare function reportFindings(values: RunFinding[]): void;
27
+ /** A command reports the source CRM's record-URL base for dashboard deep-links. */
28
+ export declare function reportCrm(value: RunCrm): void;
3
29
  /** A command annotates a structured event (plan saved, meter charged, …). */
4
30
  export declare function reportEvent(type: string, detail?: string): void;
5
31
  /**
package/dist/runReport.js CHANGED
@@ -8,12 +8,25 @@
8
8
  * pure added value for users who granted it.
9
9
  */
10
10
  import { getCredential } from "./credentials.js";
11
+ // Bound the fire-and-forget POST: a pathological audit could surface thousands
12
+ // of findings; cap what we ship (counts still carry the true total).
13
+ const MAX_REPORTED_FINDINGS = 2000;
11
14
  let counts;
15
+ let findings;
16
+ let crm;
12
17
  const events = [];
13
18
  /** A command annotates its headline metrics (merged). */
14
19
  export function reportCounts(values) {
15
20
  counts = { ...(counts ?? {}), ...values };
16
21
  }
22
+ /** A command reports per-row findings (IDs + issue type, no values). */
23
+ export function reportFindings(values) {
24
+ findings = values.slice(0, MAX_REPORTED_FINDINGS);
25
+ }
26
+ /** A command reports the source CRM's record-URL base for dashboard deep-links. */
27
+ export function reportCrm(value) {
28
+ crm = value;
29
+ }
17
30
  /** A command annotates a structured event (plan saved, meter charged, …). */
18
31
  export function reportEvent(type, detail) {
19
32
  events.push({ ts: Date.now(), type, detail });
@@ -54,6 +67,8 @@ export async function flushRunReport(args, status, startedAt, error) {
54
67
  finishedAt,
55
68
  durationMs: finishedAt - startedAt,
56
69
  counts,
70
+ findings: findings?.length ? findings : undefined,
71
+ crm,
57
72
  events: events.length ? events : undefined,
58
73
  error,
59
74
  }),
package/dist/schedule.js CHANGED
@@ -29,7 +29,13 @@ const SCHEDULABLE = {
29
29
  suggest: null,
30
30
  report: null,
31
31
  doctor: null,
32
- enrich: ["append", "refresh"],
32
+ // `enrich append|refresh` fill blanks; `enrich acquire` creates net-new
33
+ // leads — but ONLY as a needs_approval `create_record` plan (`--save`,
34
+ // required below), never a write. This is what lets `tam populate` chip away
35
+ // at a TAM unattended: each firing queues a fresh lead plan, the meter is
36
+ // charged only at apply, and apply stays `apply --plan-id` (re-checked
37
+ // approved). So scheduled acquire accumulates proposals, never surprise leads.
38
+ enrich: ["append", "refresh", "acquire"],
33
39
  market: ["capture", "refresh"],
34
40
  // The GTM brain. `signals fetch` is read-only re: CRM (--save persists only
35
41
  // the local signal ledger). `icp judge`/`icp eval` are read-only/grade-only
@@ -41,9 +47,9 @@ const SCHEDULABLE = {
41
47
  icp: ["judge", "eval"],
42
48
  draft: null,
43
49
  };
44
- const ALLOWLIST_SUMMARY = "audit, snapshot, enrich append|refresh, market capture|refresh, signals fetch, " +
45
- "icp judge|eval, draft (stages a plan), suggest, report, doctor — " +
46
- "plus apply --plan-id <id> (re-checked approved at every firing)";
50
+ const ALLOWLIST_SUMMARY = "audit, snapshot, enrich append|refresh, enrich acquire --save (stages a lead plan), " +
51
+ "market capture|refresh, signals fetch, icp judge|eval, draft (stages a plan), " +
52
+ "suggest, report, doctor — plus apply --plan-id <id> (re-checked approved at every firing)";
47
53
  /**
48
54
  * Validate that an argv resolves to a schedulable fullstackgtm command.
49
55
  * Enforced at `schedule add` time AND re-checked at `schedule run` time (the
@@ -89,6 +95,14 @@ export function validateSchedulableArgv(argv) {
89
95
  .map((name) => `${head} ${name}`)
90
96
  .join(", ")}.`);
91
97
  }
98
+ // `enrich acquire` is schedulable ONLY in its plan-producing form: without
99
+ // --save it's a dry-run that writes nothing AND queues nothing, so an
100
+ // unattended firing would silently no-op. Require --save so a scheduled
101
+ // acquire always leaves a needs_approval plan behind (apply stays gated).
102
+ if (head === "enrich" && sub === "acquire" && !argv.includes("--save")) {
103
+ throw new Error("Scheduled `enrich acquire` must include --save so each firing queues a needs_approval lead plan " +
104
+ "(without it the run produces nothing). Apply stays a separate human gate (`apply --plan-id <id>`).");
105
+ }
92
106
  }
93
107
  }
94
108
  /**
package/dist/signals.d.ts CHANGED
@@ -132,6 +132,41 @@ export declare function buildSignalsFromAts(rawJobs: Array<AtsJob & {
132
132
  now?: Date;
133
133
  priorSignals?: Signal[];
134
134
  }): Signal[];
135
+ /**
136
+ * One staged signal row: the platform-agnostic intake shape every NON-job source
137
+ * produces (a source connector's output, or a `--from` JSON row). It carries the
138
+ * evidence anchor (`quote`) but none of the derived fields (`id`, `weight`,
139
+ * `source`) — `stagedRowToSignal` fills those so there is ONE place that gates
140
+ * evidence and stamps identity, whether the row came from a file or an API.
141
+ */
142
+ export type StagedSignalRow = {
143
+ bucket: SignalBucket;
144
+ accountDomain: string;
145
+ trigger: string;
146
+ /** VERBATIM evidence — required, non-empty. */
147
+ quote: string;
148
+ sourceUrl?: string;
149
+ /** ISO 8601; defaults to run time when absent. */
150
+ firstSeen?: string;
151
+ /** Optional explicit weight; defaults to the bucket's configured weight. */
152
+ weight?: number;
153
+ };
154
+ /**
155
+ * Validate one loosely-typed staged row and turn it into a `Signal`, applying
156
+ * the verbatim-evidence gate (a row with no quote is rejected, never faked) and
157
+ * the same id/normalization logic the ATS path uses. `errorLabel` lets the
158
+ * caller produce a precise message ("--from f.json: row 3", "serpapi-news row 0")
159
+ * since both the `--from` ingest and the source-connector registry funnel here.
160
+ *
161
+ * Throws on a malformed row; returns the canonical `Signal` on success. Bucket
162
+ * FILTERING (skip rows outside a `--bucket` selection) stays with the caller —
163
+ * this function is per-row validation only.
164
+ */
165
+ export declare function stagedRowToSignal(entry: Record<string, unknown>, opts: {
166
+ now: Date;
167
+ source: string;
168
+ errorLabel: string;
169
+ }): Signal;
135
170
  /**
136
171
  * Split candidate signals into fresh vs. deduped against prior signals. A
137
172
  * candidate is deduped when a prior signal shares its `dedupKey`
@@ -160,6 +195,25 @@ export declare function dedupeSignals(candidates: Signal[], priorSignals: Signal
160
195
  */
161
196
  export declare function computeWeights(config: SignalsConfig, outcomes: SignalOutcome[], signalsById?: Map<string, Signal>): Record<SignalBucket, number>;
162
197
  export declare function signalsDir(baseDir?: string): string;
198
+ /**
199
+ * Conventional webhook landing zone: `<signals>/spool`, profile-scoped. A
200
+ * webhook receiver (hosted, or the operator's own glue) appends one JSONL row
201
+ * per event to a `*.jsonl` file here; `signals fetch --connector file` reads the
202
+ * whole directory when given no explicit path. The CLI never writes here — the
203
+ * receiver does — so this is just the agreed-upon location, not a managed store.
204
+ * Per-source files (`rb2b.jsonl`, `hubspot.jsonl`, …) coexist. See
205
+ * docs/signal-spool-format.md.
206
+ */
207
+ export declare function signalsSpoolDir(baseDir?: string): string;
208
+ /**
209
+ * Conventional outbox: `<signals>/outbox`, profile-scoped — the SEND-side mirror
210
+ * of the spool. `apply --channel outbox` renders each APPROVED drafted opener to
211
+ * a `<channel>.jsonl` file here (one row per touch); a downstream sender (hosted,
212
+ * or the operator's own) drains it. The CLI WRITES governed, approved send
213
+ * intents here but TRANSMITS NOTHING — the "drafts everything, transmits nothing"
214
+ * invariant holds. See docs/outbox-format.md.
215
+ */
216
+ export declare function signalsOutboxDir(baseDir?: string): string;
163
217
  export type SignalRun = {
164
218
  id: string;
165
219
  runLabel: string;
package/dist/signals.js CHANGED
@@ -292,6 +292,47 @@ function withinWindow(iso, now, windowDays) {
292
292
  return false;
293
293
  return now.getTime() - t <= windowDays * DAY_MS;
294
294
  }
295
+ /**
296
+ * Validate one loosely-typed staged row and turn it into a `Signal`, applying
297
+ * the verbatim-evidence gate (a row with no quote is rejected, never faked) and
298
+ * the same id/normalization logic the ATS path uses. `errorLabel` lets the
299
+ * caller produce a precise message ("--from f.json: row 3", "serpapi-news row 0")
300
+ * since both the `--from` ingest and the source-connector registry funnel here.
301
+ *
302
+ * Throws on a malformed row; returns the canonical `Signal` on success. Bucket
303
+ * FILTERING (skip rows outside a `--bucket` selection) stays with the caller —
304
+ * this function is per-row validation only.
305
+ */
306
+ export function stagedRowToSignal(entry, opts) {
307
+ const { now, source, errorLabel } = opts;
308
+ const bucket = String(entry.bucket ?? "");
309
+ if (!SIGNAL_BUCKETS.includes(bucket)) {
310
+ throw new Error(`${errorLabel} has unknown bucket "${bucket}" (one of ${SIGNAL_BUCKETS.join(", ")}).`);
311
+ }
312
+ const accountDomain = normalizeAccountDomain(String(entry.accountDomain ?? entry.domain ?? ""));
313
+ if (!accountDomain)
314
+ throw new Error(`${errorLabel} is missing accountDomain.`);
315
+ const trigger = String(entry.trigger ?? "").trim();
316
+ if (!trigger)
317
+ throw new Error(`${errorLabel} is missing trigger.`);
318
+ const quote = String(entry.quote ?? "").trim();
319
+ if (!quote)
320
+ throw new Error(`${errorLabel} is missing the verbatim quote (the evidence anchor).`);
321
+ const base = { accountDomain, bucket: bucket, trigger };
322
+ const firstSeen = typeof entry.firstSeen === "string" && entry.firstSeen ? entry.firstSeen : now.toISOString();
323
+ return {
324
+ id: signalId(base),
325
+ accountDomain,
326
+ bucket: bucket,
327
+ trigger,
328
+ quote,
329
+ sourceUrl: String(entry.sourceUrl ?? ""),
330
+ firstSeen,
331
+ weight: typeof entry.weight === "number" ? entry.weight : DEFAULT_SIGNALS_CONFIG.buckets[bucket].weight,
332
+ source,
333
+ judgedBy: null,
334
+ };
335
+ }
295
336
  // ---------------------------------------------------------------------------
296
337
  // Dedup
297
338
  /**
@@ -392,6 +433,29 @@ function blankCounts() {
392
433
  export function signalsDir(baseDir) {
393
434
  return join(baseDir ?? credentialsDir(), "signals");
394
435
  }
436
+ /**
437
+ * Conventional webhook landing zone: `<signals>/spool`, profile-scoped. A
438
+ * webhook receiver (hosted, or the operator's own glue) appends one JSONL row
439
+ * per event to a `*.jsonl` file here; `signals fetch --connector file` reads the
440
+ * whole directory when given no explicit path. The CLI never writes here — the
441
+ * receiver does — so this is just the agreed-upon location, not a managed store.
442
+ * Per-source files (`rb2b.jsonl`, `hubspot.jsonl`, …) coexist. See
443
+ * docs/signal-spool-format.md.
444
+ */
445
+ export function signalsSpoolDir(baseDir) {
446
+ return join(signalsDir(baseDir), "spool");
447
+ }
448
+ /**
449
+ * Conventional outbox: `<signals>/outbox`, profile-scoped — the SEND-side mirror
450
+ * of the spool. `apply --channel outbox` renders each APPROVED drafted opener to
451
+ * a `<channel>.jsonl` file here (one row per touch); a downstream sender (hosted,
452
+ * or the operator's own) drains it. The CLI WRITES governed, approved send
453
+ * intents here but TRANSMITS NOTHING — the "drafts everything, transmits nothing"
454
+ * invariant holds. See docs/outbox-format.md.
455
+ */
456
+ export function signalsOutboxDir(baseDir) {
457
+ return join(signalsDir(baseDir), "outbox");
458
+ }
395
459
  export function signalRunId(runLabel) {
396
460
  return `sigr_${fnv1a(runLabel)}`;
397
461
  }