fullstackgtm 0.43.0 → 0.45.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 (130) hide show
  1. package/CHANGELOG.md +378 -1
  2. package/README.md +37 -14
  3. package/dist/audit.d.ts +3 -1
  4. package/dist/audit.js +29 -2
  5. package/dist/cli/audit.d.ts +15 -0
  6. package/dist/cli/audit.js +392 -0
  7. package/dist/cli/auth.d.ts +80 -0
  8. package/dist/cli/auth.js +500 -0
  9. package/dist/cli/call.d.ts +1 -0
  10. package/dist/cli/call.js +373 -0
  11. package/dist/cli/capabilities.d.ts +30 -0
  12. package/dist/cli/capabilities.js +197 -0
  13. package/dist/cli/draft.d.ts +7 -0
  14. package/dist/cli/draft.js +87 -0
  15. package/dist/cli/enrich.d.ts +8 -0
  16. package/dist/cli/enrich.js +788 -0
  17. package/dist/cli/fix.d.ts +33 -0
  18. package/dist/cli/fix.js +344 -0
  19. package/dist/cli/help.d.ts +25 -0
  20. package/dist/cli/help.js +609 -0
  21. package/dist/cli/icp.d.ts +7 -0
  22. package/dist/cli/icp.js +229 -0
  23. package/dist/cli/init.d.ts +7 -0
  24. package/dist/cli/init.js +58 -0
  25. package/dist/cli/market.d.ts +1 -0
  26. package/dist/cli/market.js +391 -0
  27. package/dist/cli/plans.d.ts +5 -0
  28. package/dist/cli/plans.js +454 -0
  29. package/dist/cli/schedule.d.ts +8 -0
  30. package/dist/cli/schedule.js +479 -0
  31. package/dist/cli/shared.d.ts +70 -0
  32. package/dist/cli/shared.js +331 -0
  33. package/dist/cli/signals.d.ts +7 -0
  34. package/dist/cli/signals.js +412 -0
  35. package/dist/cli/suggest.d.ts +49 -0
  36. package/dist/cli/suggest.js +135 -0
  37. package/dist/cli/tam.d.ts +9 -0
  38. package/dist/cli/tam.js +387 -0
  39. package/dist/cli/ui.d.ts +121 -0
  40. package/dist/cli/ui.js +375 -0
  41. package/dist/cli.d.ts +1 -57
  42. package/dist/cli.js +104 -4556
  43. package/dist/connector.d.ts +15 -0
  44. package/dist/connector.js +35 -0
  45. package/dist/connectors/hubspot.d.ts +3 -1
  46. package/dist/connectors/hubspot.js +10 -3
  47. package/dist/connectors/outboxChannel.d.ts +64 -0
  48. package/dist/connectors/outboxChannel.js +170 -0
  49. package/dist/connectors/prospectSources.d.ts +22 -0
  50. package/dist/connectors/prospectSources.js +43 -0
  51. package/dist/connectors/salesforce.d.ts +3 -1
  52. package/dist/connectors/salesforce.js +12 -5
  53. package/dist/connectors/signalSources.d.ts +105 -0
  54. package/dist/connectors/signalSources.js +264 -0
  55. package/dist/connectors/theirstack.d.ts +84 -0
  56. package/dist/connectors/theirstack.js +125 -0
  57. package/dist/icp.d.ts +52 -4
  58. package/dist/icp.js +112 -35
  59. package/dist/index.d.ts +1 -0
  60. package/dist/index.js +1 -0
  61. package/dist/init.js +3 -0
  62. package/dist/judge.d.ts +2 -0
  63. package/dist/judge.js +6 -0
  64. package/dist/judgeEval.d.ts +7 -0
  65. package/dist/judgeEval.js +8 -1
  66. package/dist/marketClassify.d.ts +2 -0
  67. package/dist/marketClassify.js +7 -1
  68. package/dist/mcp-bin.js +2 -2
  69. package/dist/mcp.js +259 -166
  70. package/dist/runReport.d.ts +26 -0
  71. package/dist/runReport.js +15 -0
  72. package/dist/schedule.d.ts +80 -2
  73. package/dist/schedule.js +272 -5
  74. package/dist/signals.d.ts +54 -0
  75. package/dist/signals.js +64 -0
  76. package/dist/spoolFiles.d.ts +44 -0
  77. package/dist/spoolFiles.js +114 -0
  78. package/dist/tam.d.ts +225 -0
  79. package/dist/tam.js +470 -0
  80. package/dist/types.d.ts +11 -0
  81. package/docs/api.md +91 -11
  82. package/docs/outbox-format.md +92 -0
  83. package/docs/recipes.md +37 -0
  84. package/docs/roadmap-to-1.0.md +31 -1
  85. package/docs/signal-spool-format.md +175 -0
  86. package/docs/tam.md +195 -0
  87. package/llms.txt +83 -11
  88. package/package.json +1 -1
  89. package/skills/fullstackgtm/SKILL.md +4 -3
  90. package/src/audit.ts +27 -1
  91. package/src/cli/audit.ts +447 -0
  92. package/src/cli/auth.ts +549 -0
  93. package/src/cli/call.ts +398 -0
  94. package/src/cli/capabilities.ts +215 -0
  95. package/src/cli/draft.ts +101 -0
  96. package/src/cli/enrich.ts +885 -0
  97. package/src/cli/fix.ts +372 -0
  98. package/src/cli/help.ts +664 -0
  99. package/src/cli/icp.ts +265 -0
  100. package/src/cli/init.ts +65 -0
  101. package/src/cli/market.ts +423 -0
  102. package/src/cli/plans.ts +523 -0
  103. package/src/cli/schedule.ts +526 -0
  104. package/src/cli/shared.ts +375 -0
  105. package/src/cli/signals.ts +434 -0
  106. package/src/cli/suggest.ts +151 -0
  107. package/src/cli/tam.ts +435 -0
  108. package/src/cli/ui.ts +426 -0
  109. package/src/cli.ts +103 -5170
  110. package/src/connector.ts +46 -0
  111. package/src/connectors/hubspot.ts +14 -2
  112. package/src/connectors/outboxChannel.ts +202 -0
  113. package/src/connectors/prospectSources.ts +57 -0
  114. package/src/connectors/salesforce.ts +18 -2
  115. package/src/connectors/signalSources.ts +314 -0
  116. package/src/connectors/theirstack.ts +170 -0
  117. package/src/icp.ts +120 -34
  118. package/src/index.ts +32 -0
  119. package/src/init.ts +3 -0
  120. package/src/judge.ts +7 -0
  121. package/src/judgeEval.ts +8 -1
  122. package/src/marketClassify.ts +8 -1
  123. package/src/mcp-bin.ts +2 -2
  124. package/src/mcp.ts +130 -57
  125. package/src/runReport.ts +39 -0
  126. package/src/schedule.ts +330 -7
  127. package/src/signals.ts +90 -0
  128. package/src/spoolFiles.ts +116 -0
  129. package/src/tam.ts +654 -0
  130. package/src/types.ts +12 -0
@@ -24,6 +24,21 @@ export type ApplyPatchPlanOptions = {
24
24
  * edited mid-apply is conflicted out instead of overwritten. Default 25.
25
25
  */
26
26
  recheckEvery?: number;
27
+ /**
28
+ * Per-operation progress as the run executes (presentation only — a
29
+ * throwing callback never affects the run). `completed` counts every
30
+ * resolved operation including skips and conflicts; `total` is the plan's
31
+ * full operation count.
32
+ */
33
+ onOperation?: (progress: ApplyProgress) => void;
34
+ };
35
+ export type ApplyProgress = {
36
+ completed: number;
37
+ total: number;
38
+ applied: number;
39
+ failed: number;
40
+ conflicts: number;
41
+ skipped: number;
27
42
  };
28
43
  /**
29
44
  * Apply an approved subset of a patch plan through a connector.
package/dist/connector.js CHANGED
@@ -253,7 +253,41 @@ export async function applyPatchPlan(connector, plan, options) {
253
253
  batched.set(result.operationId, result);
254
254
  }
255
255
  }
256
+ // Progress ticker state: every loop iteration below pushes at most one
257
+ // result; `lastNotified` guards the rare early-throw path so an iteration
258
+ // that pushed nothing reports nothing.
259
+ const progressCounts = { applied: 0, failed: 0, conflicts: 0, skipped: 0 };
260
+ const resultsBefore = results.length;
261
+ let lastNotified = results.length;
262
+ const notifyProgress = () => {
263
+ if (!options.onOperation || results.length === lastNotified)
264
+ return;
265
+ lastNotified = results.length;
266
+ const last = results[results.length - 1];
267
+ if (last.status === "applied")
268
+ progressCounts.applied += 1;
269
+ else if (last.status === "failed")
270
+ progressCounts.failed += 1;
271
+ else if (last.status === "conflict")
272
+ progressCounts.conflicts += 1;
273
+ else
274
+ progressCounts.skipped += 1;
275
+ try {
276
+ options.onOperation({
277
+ completed: results.length - resultsBefore,
278
+ total: plan.operations.length,
279
+ ...progressCounts,
280
+ });
281
+ }
282
+ catch {
283
+ // progress is presentation-only
284
+ }
285
+ };
256
286
  for (const operation of plan.operations) {
287
+ // Report the previous iteration's result (guarded: no-op if it pushed
288
+ // nothing). One-iteration lag keeps this a two-line hook instead of a
289
+ // notify after all ten push sites; the loop-exit call below flushes the last.
290
+ notifyProgress();
257
291
  const batchedResult = batched.get(operation.id);
258
292
  if (batchedResult) {
259
293
  results.push(batchedResult);
@@ -342,6 +376,7 @@ export async function applyPatchPlan(connector, plan, options) {
342
376
  });
343
377
  }
344
378
  }
379
+ notifyProgress();
345
380
  return {
346
381
  planId: plan.id,
347
382
  provider: connector.provider,
@@ -1,5 +1,5 @@
1
1
  import { type FieldMappings } from "../mappings.ts";
2
- import type { GtmConnector } from "../types.ts";
2
+ import type { GtmConnector, SnapshotProgress } from "../types.ts";
3
3
  export type HubspotConnectorOptions = {
4
4
  /** Returns a HubSpot access token (private app token or OAuth access token). */
5
5
  getAccessToken: () => string | Promise<string>;
@@ -8,6 +8,8 @@ export type HubspotConnectorOptions = {
8
8
  apiBaseUrl?: string;
9
9
  /** Injectable fetch for testing. */
10
10
  fetchImpl?: typeof fetch;
11
+ /** Per-page snapshot-pull progress (presentation only — errors are swallowed). */
12
+ onProgress?: (progress: SnapshotProgress) => void;
11
13
  };
12
14
  /**
13
15
  * Reference connector for HubSpot.
@@ -89,7 +89,7 @@ export function createHubspotConnector(options) {
89
89
  }
90
90
  return map;
91
91
  }
92
- async function list(path) {
92
+ async function list(path, onPage) {
93
93
  const results = [];
94
94
  let after;
95
95
  const seen = new Set();
@@ -104,12 +104,18 @@ export function createHubspotConnector(options) {
104
104
  const separator = path.includes("?") ? "&" : "?";
105
105
  const data = await request(`${path}${after ? `${separator}after=${encodeURIComponent(after)}` : ""}`);
106
106
  results.push(...(data.results ?? []));
107
+ try {
108
+ onPage?.(results.length);
109
+ }
110
+ catch {
111
+ // progress is presentation-only; never let it fail a pull
112
+ }
107
113
  after = data.paging?.next?.after;
108
114
  } while (after);
109
115
  return results;
110
116
  }
111
117
  async function assembleSnapshot(fetchObjects) {
112
- const owners = await list("/crm/v3/owners?limit=100");
118
+ const owners = await list("/crm/v3/owners?limit=100", (fetched) => options.onProgress?.({ objectType: "user", fetched }));
113
119
  const users = owners
114
120
  .filter((owner) => owner.id)
115
121
  .map((owner) => ({
@@ -235,7 +241,8 @@ export function createHubspotConnector(options) {
235
241
  };
236
242
  }
237
243
  async function fetchSnapshot() {
238
- return assembleSnapshot((objectType, properties, withAssociations) => list(`/crm/v3/objects/${objectType}?limit=100&properties=${properties}${withAssociations ? "&associations=companies" : ""}`));
244
+ const canonicalType = { companies: "account", contacts: "contact", deals: "deal" };
245
+ return assembleSnapshot((objectType, properties, withAssociations) => list(`/crm/v3/objects/${objectType}?limit=100&properties=${properties}${withAssociations ? "&associations=companies" : ""}`, (fetched) => options.onProgress?.({ objectType: canonicalType[objectType], fetched })));
239
246
  }
240
247
  const MODIFIED_DATE_PROPERTIES = {
241
248
  companies: "hs_lastmodifieddate",
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Outbox channel connector — the governed SEND-side terminus of the outbound
3
+ * loop (signal → judge → draft → approve → apply → outbox), and the mirror image
4
+ * of the webhook spool. It is a `GtmConnector` whose `applyOperation` RENDERS an
5
+ * approved drafted opener to a local outbox artifact instead of writing a CRM —
6
+ * so it reuses the entire governed apply machinery (approval set, integrity/HMAC
7
+ * verification, idempotency, run recording) with no change to the apply engine.
8
+ *
9
+ * THE INVARIANT IT KEEPS: the CLI **transmits nothing**. Rendering an approved
10
+ * opener to `<home>/signals/outbox/<channel>.jsonl` is a local file write, never
11
+ * an SMTP/API connection. A downstream sender (the hosted product, or the
12
+ * operator's own MTA) drains the outbox and does the actual transmission. This
13
+ * is the send-side half of the open-core boundary: the governed artifact + its
14
+ * format are open; always-on transmission infrastructure is hosted/opt-in.
15
+ *
16
+ * Governance: it only renders ops that came from `draft` (a `create_task` op
17
+ * whose policy is `draft:<channel>`); any other op is `skipped` (it is not a
18
+ * general CRM writer). Idempotent on the operation id — re-applying an approved
19
+ * plan never duplicates an outbox row. Read paths (`fetchSnapshot`) intentionally
20
+ * throw: a channel has no snapshot, and `applyPatchPlan` never calls it for a
21
+ * draft plan (no guards/filter/irreversible ops → no snapshot needed).
22
+ */
23
+ import type { GtmConnector } from "../types.ts";
24
+ /** One approved, ready-to-send touch — a governed send INTENT, not a sent message. */
25
+ export type OutboxEntry = {
26
+ /** Idempotency key = the source operation id (stable per draft op). */
27
+ id: string;
28
+ /** email | linkedin | task — from the draft op's `draft:<channel>` policy. */
29
+ channel: string;
30
+ /** The CRM target the opener is addressed to (a sender resolves id → address). */
31
+ objectType: string;
32
+ objectId: string;
33
+ /** The APPROVED opener, verbatim as it was signed in the plan op. */
34
+ body: string;
35
+ /** The draft op's human-readable reason (carries the account + trigger). */
36
+ reason?: string;
37
+ /** Evidence ids the opener was grounded in (the verbatim signal quote). */
38
+ evidenceIds: string[];
39
+ /** ISO 8601 — when the CLI rendered this to the outbox (NOT a send time). */
40
+ renderedAt: string;
41
+ };
42
+ /** Channel ids this build can render to. */
43
+ export declare const CHANNELS: readonly ["outbox"];
44
+ export type OutboxChannelOptions = {
45
+ /** Outbox directory; defaults to the profile-scoped `signalsOutboxDir()`. */
46
+ outboxDir?: string;
47
+ /** Injectable clock for deterministic tests. */
48
+ now?: () => Date;
49
+ };
50
+ /**
51
+ * Build the outbox channel connector. With no `outboxDir`, writes to the
52
+ * profile-scoped conventional outbox and locks the home down (0700/0600) like
53
+ * the signal store; with an explicit `outboxDir` (tests), it writes there
54
+ * without touching the real home.
55
+ */
56
+ export declare function createOutboxChannelConnector(options?: OutboxChannelOptions): GtmConnector;
57
+ /** Resolve a channel connector by id (mirrors the source-connector registry). */
58
+ export declare function createChannelConnector(id: string, options?: OutboxChannelOptions): GtmConnector;
59
+ /**
60
+ * Read every outbox entry across all channel files in `dir` (default: the
61
+ * conventional outbox), newest-appended last. The reader a downstream sender (or
62
+ * a future `signals outbox` command) uses to drain the queue.
63
+ */
64
+ export declare function listOutbox(dir?: string): OutboxEntry[];
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Outbox channel connector — the governed SEND-side terminus of the outbound
3
+ * loop (signal → judge → draft → approve → apply → outbox), and the mirror image
4
+ * of the webhook spool. It is a `GtmConnector` whose `applyOperation` RENDERS an
5
+ * approved drafted opener to a local outbox artifact instead of writing a CRM —
6
+ * so it reuses the entire governed apply machinery (approval set, integrity/HMAC
7
+ * verification, idempotency, run recording) with no change to the apply engine.
8
+ *
9
+ * THE INVARIANT IT KEEPS: the CLI **transmits nothing**. Rendering an approved
10
+ * opener to `<home>/signals/outbox/<channel>.jsonl` is a local file write, never
11
+ * an SMTP/API connection. A downstream sender (the hosted product, or the
12
+ * operator's own MTA) drains the outbox and does the actual transmission. This
13
+ * is the send-side half of the open-core boundary: the governed artifact + its
14
+ * format are open; always-on transmission infrastructure is hosted/opt-in.
15
+ *
16
+ * Governance: it only renders ops that came from `draft` (a `create_task` op
17
+ * whose policy is `draft:<channel>`); any other op is `skipped` (it is not a
18
+ * general CRM writer). Idempotent on the operation id — re-applying an approved
19
+ * plan never duplicates an outbox row. Read paths (`fetchSnapshot`) intentionally
20
+ * throw: a channel has no snapshot, and `applyPatchPlan` never calls it for a
21
+ * draft plan (no guards/filter/irreversible ops → no snapshot needed).
22
+ */
23
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { ensureSecureHomeDir, writeSecureFile } from "../credentials.js";
26
+ import { signalsOutboxDir } from "../signals.js";
27
+ /** Channel ids this build can render to. */
28
+ export const CHANNELS = ["outbox"];
29
+ const DRAFT_POLICY_PREFIX = "draft:";
30
+ /**
31
+ * Build the outbox channel connector. With no `outboxDir`, writes to the
32
+ * profile-scoped conventional outbox and locks the home down (0700/0600) like
33
+ * the signal store; with an explicit `outboxDir` (tests), it writes there
34
+ * without touching the real home.
35
+ */
36
+ export function createOutboxChannelConnector(options = {}) {
37
+ const usingDefaultHome = options.outboxDir === undefined;
38
+ const dir = options.outboxDir ?? signalsOutboxDir();
39
+ const now = options.now ?? (() => new Date());
40
+ function fileFor(channel) {
41
+ if (!/^[\w.-]+$/.test(channel))
42
+ throw new Error(`Invalid outbox channel name: ${channel}`);
43
+ return join(dir, `${channel}.jsonl`);
44
+ }
45
+ /** Existing entry ids in a channel file (for idempotent re-apply). */
46
+ function existingIds(channel) {
47
+ const ids = new Set();
48
+ let raw;
49
+ try {
50
+ raw = readFileSync(fileFor(channel), "utf8");
51
+ }
52
+ catch {
53
+ return ids; // no file yet
54
+ }
55
+ for (const line of raw.split("\n")) {
56
+ const t = line.trim();
57
+ if (!t)
58
+ continue;
59
+ try {
60
+ const id = JSON.parse(t).id;
61
+ if (typeof id === "string")
62
+ ids.add(id);
63
+ }
64
+ catch {
65
+ // Skip a corrupt line rather than fail the whole apply.
66
+ }
67
+ }
68
+ return ids;
69
+ }
70
+ function append(channel, entry) {
71
+ if (usingDefaultHome)
72
+ ensureSecureHomeDir();
73
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
74
+ const path = fileFor(channel);
75
+ // Owner-only like the signal store: outbox carries opener text + CRM ids.
76
+ if (!existsSync(path))
77
+ writeSecureFile(path, "");
78
+ appendFileSync(path, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
79
+ }
80
+ async function applyOperation(operation) {
81
+ const policy = String(operation.sourceRuleOrPolicy ?? "");
82
+ if (operation.operation !== "create_task" || !policy.startsWith(DRAFT_POLICY_PREFIX)) {
83
+ return {
84
+ operationId: operation.id,
85
+ status: "skipped",
86
+ detail: "The outbox channel only renders drafted openers (create_task ops from `draft`). " +
87
+ "Apply other operations through a CRM connector (--provider hubspot|salesforce).",
88
+ };
89
+ }
90
+ const channel = policy.slice(DRAFT_POLICY_PREFIX.length) || "task";
91
+ // Idempotent: a re-applied approved plan must not duplicate the row.
92
+ if (existingIds(channel).has(operation.id)) {
93
+ return {
94
+ operationId: operation.id,
95
+ status: "applied",
96
+ detail: `Already in outbox (${channel}.jsonl); idempotent — not duplicated. Nothing transmitted.`,
97
+ };
98
+ }
99
+ const entry = {
100
+ id: operation.id,
101
+ channel,
102
+ objectType: operation.objectType,
103
+ objectId: operation.objectId,
104
+ body: typeof operation.afterValue === "string" ? operation.afterValue : String(operation.afterValue ?? ""),
105
+ ...(operation.reason ? { reason: operation.reason } : {}),
106
+ evidenceIds: operation.evidenceIds ?? [],
107
+ renderedAt: now().toISOString(),
108
+ };
109
+ append(channel, entry);
110
+ return {
111
+ operationId: operation.id,
112
+ status: "applied",
113
+ detail: `Rendered to outbox (${channel}.jsonl) for a downstream sender. Nothing was transmitted.`,
114
+ };
115
+ }
116
+ async function fetchSnapshot() {
117
+ // A channel has no readable state. apply never calls this for a draft plan
118
+ // (no guards/filter/irreversible ops); make the misuse explicit if it does.
119
+ throw new Error("The outbox channel has no snapshot to read — it is a send-side render target. " +
120
+ "Use it only to `apply` an approved draft plan (`apply --plan-id <id> --channel outbox`).");
121
+ }
122
+ return {
123
+ provider: "outbox",
124
+ fetchSnapshot,
125
+ applyOperation,
126
+ };
127
+ }
128
+ /** Resolve a channel connector by id (mirrors the source-connector registry). */
129
+ export function createChannelConnector(id, options = {}) {
130
+ if (id === "outbox")
131
+ return createOutboxChannelConnector(options);
132
+ throw new Error(`Unknown channel: ${id} (one of: ${CHANNELS.join(", ")}).`);
133
+ }
134
+ /**
135
+ * Read every outbox entry across all channel files in `dir` (default: the
136
+ * conventional outbox), newest-appended last. The reader a downstream sender (or
137
+ * a future `signals outbox` command) uses to drain the queue.
138
+ */
139
+ export function listOutbox(dir) {
140
+ const outboxDir = dir ?? signalsOutboxDir();
141
+ let names;
142
+ try {
143
+ names = readdirSync(outboxDir).filter((name) => name.endsWith(".jsonl")).sort();
144
+ }
145
+ catch {
146
+ return [];
147
+ }
148
+ const out = [];
149
+ for (const name of names) {
150
+ let raw;
151
+ try {
152
+ raw = readFileSync(join(outboxDir, name), "utf8");
153
+ }
154
+ catch {
155
+ continue;
156
+ }
157
+ for (const line of raw.split("\n")) {
158
+ const t = line.trim();
159
+ if (!t)
160
+ continue;
161
+ try {
162
+ out.push(JSON.parse(t));
163
+ }
164
+ catch {
165
+ // Skip corrupt lines.
166
+ }
167
+ }
168
+ }
169
+ return out;
170
+ }
@@ -56,6 +56,28 @@ export declare function fetchPipe0CrustdataProspects(opts: {
56
56
  apiBaseUrl?: string;
57
57
  fetchImpl?: FetchImpl;
58
58
  }): Promise<Prospect[]>;
59
+ export declare const EXPLORIUM_BUSINESS_COUNT_CAP = 60000;
60
+ export type BusinessCountProbe = {
61
+ /** matching companies (the account universe); == cap when saturated. */
62
+ total: number;
63
+ /** true when total hit EXPLORIUM_BUSINESS_COUNT_CAP — treat total as a lower bound. */
64
+ capped: boolean;
65
+ };
66
+ /**
67
+ * Count the COMPANIES (accounts) matching an ICP firmographic via Explorium
68
+ * /v1/businesses. Returns the real total (capped at 60k — `capped` flags the
69
+ * ceiling) or null if the response carries no `total_results`. Use
70
+ * `icpToExploriumBusinessFilters` to build `filters` (firmographic field names
71
+ * differ from /v1/prospects).
72
+ */
73
+ export declare function probeExploriumBusinessCount(opts: {
74
+ apiKey: string;
75
+ filters: Record<string, {
76
+ values?: string[];
77
+ }>;
78
+ apiBaseUrl?: string;
79
+ fetchImpl?: FetchImpl;
80
+ }): Promise<BusinessCountProbe | null>;
59
81
  export declare function pipe0ResolveWorkEmails(opts: {
60
82
  apiKey: string;
61
83
  prospects: Prospect[];
@@ -85,6 +85,49 @@ function strField(field) {
85
85
  const v = field?.value;
86
86
  return typeof v === "string" && v.trim() ? v : undefined;
87
87
  }
88
+ // ---------------------------------------------------------------------------
89
+ // TAM universe count — "how many ACCOUNTS match this ICP firmographic?"
90
+ //
91
+ // Verified empirically (2026-06-25) against the live providers, because the
92
+ // obvious endpoints lie about totals:
93
+ // - Explorium /v1/prospects `total_results` == the PAGE size (1→1, 10→10), not
94
+ // the universe — useless for sizing. So is /v1/businesses for tiny pages? No:
95
+ // - Explorium /v1/businesses `total_results` IS a real COUNT of matching
96
+ // companies (US+10000-emp → 9,754; Liechtenstein+10000 → 11), capped at
97
+ // EXPLORIUM_BUSINESS_COUNT_CAP. At/above the cap it saturates at exactly that
98
+ // number, so the caller must treat a capped reading as a FLOOR, not a count.
99
+ // - pipe0/Crustdata people search returns only a pagination cursor, NO total —
100
+ // it cannot size a universe at all (callers use it for discovery, not counting).
101
+ // So the TAM count source is Explorium /v1/businesses (a company/account count).
102
+ export const EXPLORIUM_BUSINESS_COUNT_CAP = 60_000;
103
+ /**
104
+ * Count the COMPANIES (accounts) matching an ICP firmographic via Explorium
105
+ * /v1/businesses. Returns the real total (capped at 60k — `capped` flags the
106
+ * ceiling) or null if the response carries no `total_results`. Use
107
+ * `icpToExploriumBusinessFilters` to build `filters` (firmographic field names
108
+ * differ from /v1/prospects).
109
+ */
110
+ export async function probeExploriumBusinessCount(opts) {
111
+ const fetchImpl = opts.fetchImpl ?? fetch;
112
+ const base = (opts.apiBaseUrl ?? "https://api.explorium.ai").replace(/\/$/, "");
113
+ const response = await fetchImpl(`${base}/v1/businesses`, {
114
+ method: "POST",
115
+ headers: { "api_key": opts.apiKey, "Content-Type": "application/json" },
116
+ // page_size 1: we want the envelope's total_results, not the rows. CRITICAL:
117
+ // do NOT send `size` — Explorium caps total_results to `size` when present
118
+ // (verified: size:1 → total_results:1; omitted → the real count, e.g. 19,058).
119
+ body: JSON.stringify({ mode: "full", page_size: 1, page: 1, filters: opts.filters }),
120
+ });
121
+ if (!response.ok) {
122
+ throw new Error(`Explorium /v1/businesses count failed: HTTP ${response.status} ${await safeText(response)}`);
123
+ }
124
+ const body = (await response.json());
125
+ const total = body.total_results;
126
+ if (typeof total !== "number" || !Number.isFinite(total) || total < 0)
127
+ return null;
128
+ const rounded = Math.round(total);
129
+ return { total: rounded, capped: rounded >= EXPLORIUM_BUSINESS_COUNT_CAP };
130
+ }
88
131
  function normalizeLinkedin(value) {
89
132
  if (!value)
90
133
  return undefined;
@@ -1,5 +1,5 @@
1
1
  import { type FieldMappings } from "../mappings.ts";
2
- import type { GtmConnector } from "../types.ts";
2
+ import type { GtmConnector, SnapshotProgress } from "../types.ts";
3
3
  export type SalesforceConnection = {
4
4
  accessToken: string;
5
5
  /** e.g. https://yourorg.my.salesforce.com */
@@ -13,6 +13,8 @@ export type SalesforceConnectorOptions = {
13
13
  apiVersion?: string;
14
14
  /** Injectable fetch for testing. */
15
15
  fetchImpl?: typeof fetch;
16
+ /** Per-page snapshot-pull progress (presentation only — errors are swallowed). */
17
+ onProgress?: (progress: SnapshotProgress) => void;
16
18
  };
17
19
  /**
18
20
  * Reference connector for Salesforce.
@@ -116,7 +116,7 @@ export function createSalesforceConnector(options) {
116
116
  const text = await response.text();
117
117
  return text ? JSON.parse(text) : null;
118
118
  }
119
- async function query(soql) {
119
+ async function query(soql, onPage) {
120
120
  const records = [];
121
121
  let next = `/services/data/${apiVersion}/query?q=${encodeURIComponent(soql)}`;
122
122
  const seen = new Set();
@@ -127,6 +127,12 @@ export function createSalesforceConnector(options) {
127
127
  seen.add(next);
128
128
  const data = await request(next);
129
129
  records.push(...(data?.records ?? []));
130
+ try {
131
+ onPage?.(records.length);
132
+ }
133
+ catch {
134
+ // progress is presentation-only; never let it fail a pull
135
+ }
130
136
  next = data?.nextRecordsUrl ?? undefined;
131
137
  }
132
138
  return records;
@@ -138,7 +144,8 @@ export function createSalesforceConnector(options) {
138
144
  return mappedFields(mappings, objectType, SALESFORCE_DEFAULT_FIELD_MAPPINGS[objectType]).join(", ");
139
145
  }
140
146
  async function assembleSnapshot(whereClause) {
141
- const sfUsers = await query(`SELECT ${selectFields("owners")} FROM User${whereClause}`);
147
+ const progressFor = (objectType) => (fetched) => options.onProgress?.({ objectType, fetched });
148
+ const sfUsers = await query(`SELECT ${selectFields("owners")} FROM User${whereClause}`, progressFor("user"));
142
149
  const users = sfUsers.map((user) => {
143
150
  const id = String(readMapped(user, "owners", "id", "Id"));
144
151
  const email = stringOrUndefined(readMapped(user, "owners", "email", "Email"));
@@ -153,7 +160,7 @@ export function createSalesforceConnector(options) {
153
160
  active: Boolean(readMapped(user, "owners", "isActive", "IsActive")),
154
161
  };
155
162
  });
156
- const sfAccounts = await query(`SELECT ${selectFields("accounts")} FROM Account${whereClause}`);
163
+ const sfAccounts = await query(`SELECT ${selectFields("accounts")} FROM Account${whereClause}`, progressFor("account"));
157
164
  const accounts = sfAccounts.map((account) => {
158
165
  const id = String(readMapped(account, "accounts", "id", "Id"));
159
166
  return {
@@ -170,7 +177,7 @@ export function createSalesforceConnector(options) {
170
177
  raw: account,
171
178
  };
172
179
  });
173
- const sfContacts = await query(`SELECT ${selectFields("contacts")} FROM Contact${whereClause}`);
180
+ const sfContacts = await query(`SELECT ${selectFields("contacts")} FROM Contact${whereClause}`, progressFor("contact"));
174
181
  const contacts = sfContacts.map((contact) => {
175
182
  const id = String(readMapped(contact, "contacts", "id", "Id"));
176
183
  return {
@@ -188,7 +195,7 @@ export function createSalesforceConnector(options) {
188
195
  raw: contact,
189
196
  };
190
197
  });
191
- const sfOpportunities = await query(`SELECT ${selectFields("deals")} FROM Opportunity${whereClause}`);
198
+ const sfOpportunities = await query(`SELECT ${selectFields("deals")} FROM Opportunity${whereClause}`, progressFor("deal"));
192
199
  const deals = sfOpportunities.map((opportunity) => {
193
200
  const id = String(readMapped(opportunity, "deals", "id", "Id"));
194
201
  const probability = numberOrUndefined(readMapped(opportunity, "deals", "probability", "Probability"));
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Source connectors for the `signals` layer — the connection-based intake that
3
+ * generalizes the no-auth ATS adapters (`atsBoards.ts`) and the hand-staged
4
+ * `--from <file.json>` path into one contract: a platform connection in, a list
5
+ * of evidence-bearing staged signal rows out.
6
+ *
7
+ * Design (see docs/spec-connectors-signals-outbound.md):
8
+ * - Zero runtime deps: global `fetch` only, injectable for tests.
9
+ * - Read-only: a source never writes a CRM record and never emits a
10
+ * PatchOperation. `signals fetch` stays read-only re: the CRM.
11
+ * - Verbatim evidence: every row carries a non-empty `quote`; a row that
12
+ * cannot ground a why-now is dropped, never faked. The central
13
+ * `stagedRowToSignal` gate enforces this again.
14
+ * - Secrets via the credential ladder: API keys come from
15
+ * `ctx.getApiKey(provider)` (login store -> env -> broker), NEVER from argv.
16
+ * `ctx.options` carries non-secret knobs only (a file path, a query term).
17
+ * - Per-source resilience: a connector's own failure yields `[]` (logged by
18
+ * the caller), it must never sink a multi-source run — the per-provider
19
+ * try/catch idiom of atsBoards/prospectSources.
20
+ */
21
+ import type { SignalBucket, StagedSignalRow } from "../signals.ts";
22
+ export type SignalSourceShape = "pull" | "push";
23
+ export type SignalSourceAuth = "none" | "api_key" | "oauth";
24
+ /** Everything a source connector needs for one `fetch`, supplied by the CLI. */
25
+ export type SignalSourceContext = {
26
+ /** Accounts to scope a pull. May be empty (a file/spool source ignores it). */
27
+ watchlist: {
28
+ domain: string;
29
+ }[];
30
+ /** Evidence keywords for job/listing-style sources; may be empty. */
31
+ keywords: string[];
32
+ now: Date;
33
+ /** Injectable fetch for tests; global `fetch` by default. */
34
+ fetchImpl?: typeof fetch;
35
+ /**
36
+ * Credential-ladder lookup. Returns a usable secret for `provider`, or null
37
+ * when nothing is configured (the connector then returns []). Secrets NEVER
38
+ * arrive via argv — only through this.
39
+ */
40
+ getApiKey?: (provider: string) => Promise<string | null>;
41
+ /** Non-secret per-connector knobs from `--connector-opt k=v` (paths, queries). */
42
+ options?: Record<string, string>;
43
+ };
44
+ export type SignalSourceConnector = {
45
+ id: string;
46
+ /** Default bucket this source feeds (a row may still override it). */
47
+ bucket: SignalBucket;
48
+ shape: SignalSourceShape;
49
+ auth: SignalSourceAuth;
50
+ /**
51
+ * Produce staged rows now. Resilient by contract: the connector's own
52
+ * failures (offline, non-2xx, malformed payload, missing key) resolve to []
53
+ * rather than throwing, so one source's outage never aborts a multi-source
54
+ * `signals fetch`. Validation/evidence-gating of the returned rows happens
55
+ * centrally in `stagedRowToSignal`.
56
+ */
57
+ fetch(ctx: SignalSourceContext): Promise<StagedSignalRow[]>;
58
+ };
59
+ /**
60
+ * Read staged rows from a local path — the webhook landing-zone reader. The path
61
+ * (from `options.path`/`options.file`) may be:
62
+ * - a single FILE: a JSON array, or newline-delimited JSON (one row per line);
63
+ * - a DIRECTORY (the conventional spool): every `*.jsonl` / `*.json` file in
64
+ * it is read and concatenated (sorted by name), so multiple receivers can
65
+ * each append their own file (`rb2b.jsonl`, `hubspot.jsonl`) and they all
66
+ * land in one fetch.
67
+ * No auth. This is how every push platform (RB2B, Trigify, HubSpot webhooks)
68
+ * reaches the CLI: a receiver appends a row to the spool, this reads it on the
69
+ * next `signals fetch`. The CLI defaults the path to the conventional spool dir
70
+ * (`signalsSpoolDir`) when `--connector file` is used with no path; a bare
71
+ * library call with no path is inactive (returns []).
72
+ *
73
+ * Resilient: a missing path / unreadable file yields [] (an empty source, not a
74
+ * crash), and one unreadable file in a spool directory is skipped. A file that
75
+ * IS present but malformed throws — a corrupt spool is a real error to surface,
76
+ * not silent data loss. The central `stagedRowToSignal` gate validates rows.
77
+ */
78
+ export declare const fileSource: SignalSourceConnector;
79
+ /**
80
+ * Pull recent news per watchlist account and stage funding/company signals. One
81
+ * query per account (`q="<domain>"`, Google News engine); each result becomes a
82
+ * row whose verbatim `quote` is the headline (+ source), so the downstream judge
83
+ * can ground a why-now on a real, linkable article. API key via the credential
84
+ * ladder (provider "serpapi"); no key -> [] (the source is simply inactive).
85
+ *
86
+ * Bucket defaults to `funding`; override per run with `--connector-opt bucket=company`.
87
+ */
88
+ export declare const serpapiNewsSource: SignalSourceConnector;
89
+ /**
90
+ * Stage `demand` signals from recent HubSpot form submissions — the first real
91
+ * `demand`-bucket producer (a form fill is first-party demand). Reuses the
92
+ * EXISTING HubSpot credential via the ladder (provider "hubspot"); no separate
93
+ * login. Each submission whose email carries a company domain becomes a row
94
+ * whose verbatim `quote` is the form name + submitted email (the evidence a rep
95
+ * can verify). Submissions without a corporate domain (free-mail) are dropped —
96
+ * no account to attach demand to.
97
+ *
98
+ * Phase 1 is a pull over the Forms submissions API; the form-submission webhook
99
+ * (push) lands in Phase 2 via the spool + `file` source.
100
+ */
101
+ export declare const hubspotFormsSource: SignalSourceConnector;
102
+ /** All registered source connectors (stable order). */
103
+ export declare function listSignalSources(): SignalSourceConnector[];
104
+ /** Resolve a connector by id, or null when unknown. */
105
+ export declare function getSignalSource(id: string): SignalSourceConnector | null;