fullstackgtm 0.42.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +273 -0
- package/README.md +18 -7
- package/dist/calls.d.ts +13 -0
- package/dist/calls.js +14 -3
- package/dist/cli.js +718 -60
- package/dist/connectors/hubspot.js +58 -24
- package/dist/connectors/outboxChannel.d.ts +64 -0
- package/dist/connectors/outboxChannel.js +170 -0
- package/dist/connectors/prospectSources.d.ts +22 -0
- package/dist/connectors/prospectSources.js +43 -0
- package/dist/connectors/salesforce.js +40 -15
- package/dist/connectors/signalSources.d.ts +105 -0
- package/dist/connectors/signalSources.js +316 -0
- package/dist/connectors/theirstack.d.ts +84 -0
- package/dist/connectors/theirstack.js +125 -0
- package/dist/draft.js +27 -5
- package/dist/enrich.d.ts +6 -0
- package/dist/enrich.js +47 -1
- package/dist/health.d.ts +12 -0
- package/dist/health.js +29 -2
- package/dist/icp.d.ts +47 -3
- package/dist/icp.js +105 -11
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/init.d.ts +47 -0
- package/dist/init.js +143 -0
- package/dist/judge.d.ts +36 -3
- package/dist/judge.js +46 -10
- package/dist/judgeEval.d.ts +7 -0
- package/dist/judgeEval.js +8 -1
- package/dist/runReport.d.ts +26 -0
- package/dist/runReport.js +15 -0
- package/dist/schedule.js +18 -4
- package/dist/signals.d.ts +58 -0
- package/dist/signals.js +65 -0
- package/dist/tam.d.ts +225 -0
- package/dist/tam.js +470 -0
- package/dist/types.d.ts +12 -0
- package/docs/api.md +26 -4
- package/docs/outbox-format.md +92 -0
- package/docs/recipes.md +195 -0
- package/docs/roadmap-to-1.0.md +31 -1
- package/docs/signal-spool-format.md +162 -0
- package/docs/tam.md +195 -0
- package/llms.txt +89 -1
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +17 -1
- package/src/calls.ts +24 -3
- package/src/cli.ts +809 -55
- package/src/connectors/hubspot.ts +55 -23
- package/src/connectors/outboxChannel.ts +202 -0
- package/src/connectors/prospectSources.ts +57 -0
- package/src/connectors/salesforce.ts +39 -14
- package/src/connectors/signalSources.ts +363 -0
- package/src/connectors/theirstack.ts +170 -0
- package/src/draft.ts +26 -5
- package/src/enrich.ts +51 -1
- package/src/health.ts +47 -2
- package/src/icp.ts +113 -11
- package/src/index.ts +42 -0
- package/src/init.ts +166 -0
- package/src/judge.ts +85 -11
- package/src/judgeEval.ts +8 -1
- package/src/runReport.ts +39 -0
- package/src/schedule.ts +20 -4
- package/src/signals.ts +95 -0
- package/src/tam.ts +654 -0
- package/src/types.ts +12 -0
package/dist/judge.js
CHANGED
|
@@ -223,22 +223,54 @@ export function accountRecentlyTouched(accountDomain, snapshot, now = new Date()
|
|
|
223
223
|
return false;
|
|
224
224
|
}
|
|
225
225
|
/**
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
* fit
|
|
226
|
+
* Resolve a signal's account domain to the CRM account record and the best
|
|
227
|
+
* contact at it (preferring one with a title). The single domain→account→contact
|
|
228
|
+
* join used by both fit scoring and target surfacing — so "who do I message at
|
|
229
|
+
* this account" is computed once and consistently.
|
|
229
230
|
*/
|
|
230
|
-
|
|
231
|
+
function findAccountAndBestContact(accountDomain, snapshot) {
|
|
231
232
|
const domain = normalizeAccountDomain(accountDomain);
|
|
232
233
|
if (!domain)
|
|
233
234
|
return undefined;
|
|
234
235
|
const account = (snapshot.accounts ?? []).find((a) => normalizeAccountDomain(a.domain ?? "") === domain);
|
|
235
236
|
if (!account)
|
|
236
237
|
return undefined;
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
|
|
238
|
+
// Titled contacts first (a title is what fit scores on); the first is primary.
|
|
239
|
+
const contacts = (snapshot.contacts ?? [])
|
|
240
|
+
.filter((c) => c.accountId === account.id)
|
|
241
|
+
.sort((a, b) => (b.title ? 1 : 0) - (a.title ? 1 : 0));
|
|
242
|
+
return { account, contact: contacts[0], contacts };
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Best-matching contact for an account, shaped for fit scoring (the title is
|
|
246
|
+
* what `scoreProspectAgainstIcp` reads). Returns undefined when the
|
|
247
|
+
* account/contact isn't in the snapshot.
|
|
248
|
+
*/
|
|
249
|
+
export function bestContactForAccount(accountDomain, snapshot) {
|
|
250
|
+
const found = findAccountAndBestContact(accountDomain, snapshot);
|
|
251
|
+
if (!found?.contact)
|
|
240
252
|
return undefined;
|
|
241
|
-
return { jobTitle:
|
|
253
|
+
return { jobTitle: found.contact.title };
|
|
254
|
+
}
|
|
255
|
+
/** Cap on candidate contacts surfaced per account (the rest stay in the CRM). */
|
|
256
|
+
const MAX_CANDIDATE_CONTACTS = 10;
|
|
257
|
+
const toContactRef = (c) => ({
|
|
258
|
+
id: c.id,
|
|
259
|
+
...(c.email ? { email: c.email } : {}),
|
|
260
|
+
...(c.title ? { title: c.title } : {}),
|
|
261
|
+
});
|
|
262
|
+
export function resolveAccountTarget(accountDomain, snapshot) {
|
|
263
|
+
const found = findAccountAndBestContact(accountDomain, snapshot);
|
|
264
|
+
if (!found)
|
|
265
|
+
return {};
|
|
266
|
+
const { account, contact, contacts } = found;
|
|
267
|
+
return {
|
|
268
|
+
accountId: account.id,
|
|
269
|
+
...(contact ? { contact: toContactRef(contact) } : {}),
|
|
270
|
+
// The candidate contacts at the account, so an agent can multi-thread
|
|
271
|
+
// beyond the single primary. Capped; primary is contacts[0].
|
|
272
|
+
...(contacts.length ? { contacts: contacts.slice(0, MAX_CANDIDATE_CONTACTS).map(toContactRef) } : {}),
|
|
273
|
+
};
|
|
242
274
|
}
|
|
243
275
|
// ---------------------------------------------------------------------------
|
|
244
276
|
// Deterministic baseline decision
|
|
@@ -406,8 +438,9 @@ export async function judgeSignals(opts) {
|
|
|
406
438
|
}
|
|
407
439
|
const decisions = [];
|
|
408
440
|
for (const [domain, signals] of byAccount) {
|
|
441
|
+
// Memory + fit stay gated on --with-history (scoring semantics unchanged).
|
|
409
442
|
const recentlyTouched = opts.withHistory && opts.snapshot ? accountRecentlyTouched(domain, opts.snapshot, now) : false;
|
|
410
|
-
const bestContact = opts.icp && opts.snapshot ? bestContactForAccount(domain, opts.snapshot) : undefined;
|
|
443
|
+
const bestContact = opts.withHistory && opts.icp && opts.snapshot ? bestContactForAccount(domain, opts.snapshot) : undefined;
|
|
411
444
|
const score = scoreAccount({
|
|
412
445
|
accountDomain: domain,
|
|
413
446
|
signals,
|
|
@@ -422,7 +455,10 @@ export async function judgeSignals(opts) {
|
|
|
422
455
|
const decision = opts.llm && opts.promptTemplate
|
|
423
456
|
? await judgeDecisionLlm(score, opts.promptTemplate, opts.llm)
|
|
424
457
|
: deterministicDecision(score);
|
|
425
|
-
|
|
458
|
+
// Surface the CRM target (accountId + best contact) whenever a snapshot is
|
|
459
|
+
// present — independent of --with-history. This is what makes a decision
|
|
460
|
+
// actionable: draft writes against contact.id / accountId, never the domain.
|
|
461
|
+
decisions.push(opts.snapshot ? { ...decision, ...resolveAccountTarget(domain, opts.snapshot) } : decision);
|
|
426
462
|
}
|
|
427
463
|
return decisions.sort((a, b) => b.score - a.score || a.accountDomain.localeCompare(b.accountDomain));
|
|
428
464
|
}
|
package/dist/judgeEval.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/dist/runReport.d.ts
CHANGED
|
@@ -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
|
|
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,
|
|
45
|
-
"icp judge|eval, draft (stages a plan),
|
|
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
|
@@ -53,6 +53,9 @@ export type SignalOutcome = {
|
|
|
53
53
|
id: string;
|
|
54
54
|
accountDomain: string;
|
|
55
55
|
touchId?: string;
|
|
56
|
+
/** The contact the touch went to (from the judge decision), when known — so an
|
|
57
|
+
* outcome credits the person reached, not just the account domain. */
|
|
58
|
+
contactId?: string;
|
|
56
59
|
result: SignalOutcomeResult;
|
|
57
60
|
recordedAt: string;
|
|
58
61
|
/** Signal ids credited (from the judge decision). */
|
|
@@ -129,6 +132,41 @@ export declare function buildSignalsFromAts(rawJobs: Array<AtsJob & {
|
|
|
129
132
|
now?: Date;
|
|
130
133
|
priorSignals?: Signal[];
|
|
131
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;
|
|
132
170
|
/**
|
|
133
171
|
* Split candidate signals into fresh vs. deduped against prior signals. A
|
|
134
172
|
* candidate is deduped when a prior signal shares its `dedupKey`
|
|
@@ -157,6 +195,25 @@ export declare function dedupeSignals(candidates: Signal[], priorSignals: Signal
|
|
|
157
195
|
*/
|
|
158
196
|
export declare function computeWeights(config: SignalsConfig, outcomes: SignalOutcome[], signalsById?: Map<string, Signal>): Record<SignalBucket, number>;
|
|
159
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;
|
|
160
217
|
export type SignalRun = {
|
|
161
218
|
id: string;
|
|
162
219
|
runLabel: string;
|
|
@@ -191,6 +248,7 @@ export declare function createFileSignalStore(directory?: string): SignalStore;
|
|
|
191
248
|
export declare function makeOutcome(input: {
|
|
192
249
|
accountDomain: string;
|
|
193
250
|
touchId?: string;
|
|
251
|
+
contactId?: string;
|
|
194
252
|
result: SignalOutcomeResult;
|
|
195
253
|
creditedSignals?: string[];
|
|
196
254
|
recordedAt?: 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
|
}
|
|
@@ -508,6 +572,7 @@ export function makeOutcome(input) {
|
|
|
508
572
|
id: outcomeId({ accountDomain, touchId: input.touchId, result: input.result, recordedAt }),
|
|
509
573
|
accountDomain,
|
|
510
574
|
...(input.touchId !== undefined ? { touchId: input.touchId } : {}),
|
|
575
|
+
...(input.contactId !== undefined ? { contactId: input.contactId } : {}),
|
|
511
576
|
result: input.result,
|
|
512
577
|
recordedAt,
|
|
513
578
|
creditedSignals: input.creditedSignals ?? [],
|
package/dist/tam.d.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tam` — Total Addressable Market mapping.
|
|
3
|
+
*
|
|
4
|
+
* Market mapping (`market`) answers "who else is in this category and where are
|
|
5
|
+
* the open fronts." TAM mapping answers a different, longer-horizon question:
|
|
6
|
+
* "how big is the reachable market for *my* ICP, and how far along am I in
|
|
7
|
+
* actually putting it in the CRM?"
|
|
8
|
+
*
|
|
9
|
+
* It is deliberately NOT a one-shot "$10B TAM" headline. The flow is iterative:
|
|
10
|
+
* 1. `tam estimate` — derive a defensible universe from the ICP: a real
|
|
11
|
+
* account count (a discovery provider's total-match for the ICP filter, or
|
|
12
|
+
* an explicit assumption), × ACV for the dollar figure, with buyers/account
|
|
13
|
+
* giving the *contact* population target. Citable cross-checks optional.
|
|
14
|
+
* 2. `tam populate` — schedule governed `enrich acquire --save` runs that chip
|
|
15
|
+
* away at the universe (plan-only; apply stays a separate human gate).
|
|
16
|
+
* 3. `tam status` / `tam report` — coverage over time: accounts/contacts/$
|
|
17
|
+
* in the CRM vs. the universe, what THIS campaign added since baseline, and
|
|
18
|
+
* an ETA at the current burn rate — so "it'll take a while" is quantified.
|
|
19
|
+
*
|
|
20
|
+
* This module is the pure, deterministic core (estimation + coverage + ETA math
|
|
21
|
+
* + a profile-scoped store). The CLI wires it to the ICP, the CRM snapshot, the
|
|
22
|
+
* discovery providers, and the scheduler.
|
|
23
|
+
*/
|
|
24
|
+
import type { CanonicalAccount, CanonicalGtmSnapshot } from "./types.ts";
|
|
25
|
+
/**
|
|
26
|
+
* ACV basis. `account` (per logo / contract — the standard TAM basis) makes
|
|
27
|
+
* TAM = accounts × ACV. `buyer` (per seat) makes TAM = contacts × ACV, for
|
|
28
|
+
* genuinely seat-priced products. Buyers/account always drives the *contact*
|
|
29
|
+
* population target regardless of basis.
|
|
30
|
+
*/
|
|
31
|
+
export type AcvBasis = "account" | "buyer";
|
|
32
|
+
/** A citable market-research figure that cross-checks the bottom-up estimate. */
|
|
33
|
+
export type TamCrossCheck = {
|
|
34
|
+
claim: string;
|
|
35
|
+
/** Parsed dollar value, when the claim is a $ figure — enables a direct compare. */
|
|
36
|
+
valueUsd?: number;
|
|
37
|
+
sourceUrl: string;
|
|
38
|
+
/** Verbatim snippet (same evidence posture as market signals). */
|
|
39
|
+
quote: string;
|
|
40
|
+
asOf?: string;
|
|
41
|
+
};
|
|
42
|
+
/** Real CRM counts that "fill" the TAM — accounts with a domain + their contacts. */
|
|
43
|
+
export type TamCoverageCounts = {
|
|
44
|
+
accounts: number;
|
|
45
|
+
contacts: number;
|
|
46
|
+
};
|
|
47
|
+
export type TamUniverse = {
|
|
48
|
+
accounts: number;
|
|
49
|
+
/** Where the account count came from: "provider:explorium" or "assumption". */
|
|
50
|
+
accountsSource: string;
|
|
51
|
+
buyersPerAccount: number;
|
|
52
|
+
/** how buyersPerAccount was set: "crm:avg-contacts-per-account (N)" or "explicit" or "assumption:...". */
|
|
53
|
+
buyersSource: string;
|
|
54
|
+
/** accounts × buyersPerAccount — the contact population target. */
|
|
55
|
+
contacts: number;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* The ICP criteria the TAM was estimated against — captured at estimate time so
|
|
59
|
+
* `status` can classify CRM accounts as in/out of THIS TAM. geo/technology aren't
|
|
60
|
+
* on a CanonicalAccount, so only `employeeBands`/`industries` are CRM-checkable;
|
|
61
|
+
* geo + technographic verification needs re-enrichment (`--reverify`, future).
|
|
62
|
+
*/
|
|
63
|
+
export type TamTargeting = {
|
|
64
|
+
geos?: string[];
|
|
65
|
+
employeeBands?: string[];
|
|
66
|
+
industries?: string[];
|
|
67
|
+
technologies?: string[];
|
|
68
|
+
};
|
|
69
|
+
export type TamModel = {
|
|
70
|
+
name: string;
|
|
71
|
+
icpName: string;
|
|
72
|
+
universe: TamUniverse;
|
|
73
|
+
/** the ICP filter this TAM was sized against (for coverage classification). */
|
|
74
|
+
targeting?: TamTargeting;
|
|
75
|
+
/** ACV must be REAL, never a fabricated default: `source` says where it came
|
|
76
|
+
* from ("explicit" from the operator, or "crm:closed-won (N deals, median)"). */
|
|
77
|
+
acv: {
|
|
78
|
+
basis: AcvBasis;
|
|
79
|
+
valueUsd: number;
|
|
80
|
+
source: string;
|
|
81
|
+
};
|
|
82
|
+
/** accounts × ACV (account basis) or contacts × ACV (buyer basis). */
|
|
83
|
+
tamUsd: number;
|
|
84
|
+
crossChecks: TamCrossCheck[];
|
|
85
|
+
/** CRM counts at estimate time, so `status` can attribute what the campaign added. */
|
|
86
|
+
baseline: TamCoverageCounts;
|
|
87
|
+
createdAt: string;
|
|
88
|
+
};
|
|
89
|
+
/** CRM accounts classified against the TAM ICP — only the in-TAM bucket counts. */
|
|
90
|
+
export type TamClassified = {
|
|
91
|
+
/** matches every CRM-checkable criterion (no contradiction) — counts toward coverage. */
|
|
92
|
+
inTam: number;
|
|
93
|
+
/** fails a checkable criterion (wrong size/industry) — NOT in this market. */
|
|
94
|
+
outOfTam: number;
|
|
95
|
+
/** missing the data to classify — surfaced, never silently counted. */
|
|
96
|
+
unknown: number;
|
|
97
|
+
/** all domain-bearing CRM accounts (inTam + outOfTam + unknown). */
|
|
98
|
+
totalCrm: number;
|
|
99
|
+
/** which criteria were CRM-checkable, e.g. ["size","industry"]. */
|
|
100
|
+
criteria: string[];
|
|
101
|
+
};
|
|
102
|
+
export type TamCoverage = {
|
|
103
|
+
at: string;
|
|
104
|
+
universe: {
|
|
105
|
+
accounts: number;
|
|
106
|
+
contacts: number;
|
|
107
|
+
tamUsd: number;
|
|
108
|
+
};
|
|
109
|
+
inCrm: TamCoverageCounts;
|
|
110
|
+
/** current − baseline (floored at 0) — what's been added since the TAM was set. */
|
|
111
|
+
addedSinceBaseline: TamCoverageCounts;
|
|
112
|
+
/** in-TAM (or total when unclassified) / universe, capped at 1. */
|
|
113
|
+
accountCoverage: number;
|
|
114
|
+
contactCoverage: number;
|
|
115
|
+
/** accountCoverage × tamUsd. */
|
|
116
|
+
dollarCovered: number;
|
|
117
|
+
/** present when the model carries targeting — the in/out/unknown breakdown. */
|
|
118
|
+
classified?: TamClassified;
|
|
119
|
+
/** max(estimate, inTam): the bottom-up count is a floor on the real universe. */
|
|
120
|
+
reconciledUniverse?: number;
|
|
121
|
+
/** inTam > the top-down estimate — the estimate was low; revise it UP. */
|
|
122
|
+
bottomUpExceedsEstimate?: boolean;
|
|
123
|
+
};
|
|
124
|
+
/** Linear projection of when the universe is filled, from the coverage timeline. */
|
|
125
|
+
export type TamEta = {
|
|
126
|
+
/** accounts added per day, measured across the timeline window. */
|
|
127
|
+
accountsPerDay: number;
|
|
128
|
+
accountsRemaining: number;
|
|
129
|
+
daysRemaining: number;
|
|
130
|
+
/** ISO date (YYYY-MM-DD) the account universe is projected to be covered. */
|
|
131
|
+
etaDate: string;
|
|
132
|
+
/** how the rate was measured — the timeline span used. */
|
|
133
|
+
basis: string;
|
|
134
|
+
};
|
|
135
|
+
export type EstimateTamInput = {
|
|
136
|
+
name: string;
|
|
137
|
+
icpName: string;
|
|
138
|
+
accounts: number;
|
|
139
|
+
accountsSource: string;
|
|
140
|
+
buyersPerAccount?: number;
|
|
141
|
+
buyersSource?: string;
|
|
142
|
+
targeting?: TamTargeting;
|
|
143
|
+
acv: {
|
|
144
|
+
basis?: AcvBasis;
|
|
145
|
+
valueUsd: number;
|
|
146
|
+
source?: string;
|
|
147
|
+
};
|
|
148
|
+
crossChecks?: TamCrossCheck[];
|
|
149
|
+
baseline: TamCoverageCounts;
|
|
150
|
+
createdAt: string;
|
|
151
|
+
};
|
|
152
|
+
export declare function estimateTam(input: EstimateTamInput): TamModel;
|
|
153
|
+
export type DerivedAcv = {
|
|
154
|
+
valueUsd: number;
|
|
155
|
+
dealCount: number;
|
|
156
|
+
meanUsd: number;
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* Derive a real ACV from the CRM's closed-won deals (median amount — robust to
|
|
160
|
+
* outliers). Returns null when there are no usable won deals, so the caller can
|
|
161
|
+
* refuse to fabricate a dollar TAM. The median is the headline; the mean is
|
|
162
|
+
* surfaced too so a skewed pipeline is visible.
|
|
163
|
+
*/
|
|
164
|
+
export declare function deriveAcvFromClosedWon(snapshot: CanonicalGtmSnapshot): DerivedAcv | null;
|
|
165
|
+
export type DerivedBuyers = {
|
|
166
|
+
value: number;
|
|
167
|
+
accountsSampled: number;
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* Derive buyers/account from the CRM: the average number of contacts on accounts
|
|
171
|
+
* that have at least one. A real proxy for the buying group, vs. a hardcoded
|
|
172
|
+
* guess. Returns null when no account has a contact.
|
|
173
|
+
*/
|
|
174
|
+
export declare function deriveBuyersPerAccount(snapshot: CanonicalGtmSnapshot): DerivedBuyers | null;
|
|
175
|
+
/**
|
|
176
|
+
* Count what "fills" the TAM from a CRM snapshot: accounts that carry a domain
|
|
177
|
+
* (real, signal-watchable records — a name-only stub doesn't count) and the
|
|
178
|
+
* contacts at them. Coverage is measured against these, so a TAM you've started
|
|
179
|
+
* filling reads truthfully even for accounts that pre-dated the campaign.
|
|
180
|
+
*/
|
|
181
|
+
export declare function coverageCountsFromSnapshot(snapshot: CanonicalGtmSnapshot): TamCoverageCounts;
|
|
182
|
+
export type AccountTamClass = "in" | "out" | "unknown";
|
|
183
|
+
/** The TAM criteria checkable from a CanonicalAccount — geo/technology are NOT on
|
|
184
|
+
* the record, so they need re-enrichment and are excluded here. */
|
|
185
|
+
export declare function crmCheckableCriteria(t: TamTargeting): string[];
|
|
186
|
+
/**
|
|
187
|
+
* Classify one CRM account against the TAM ICP, using only CRM-checkable criteria
|
|
188
|
+
* (size, industry). "out" if any criterion contradicts (wrong size/industry);
|
|
189
|
+
* "in" if at least one passes and none contradict; "unknown" if nothing could be
|
|
190
|
+
* evaluated (missing fields, or the TAM is defined only by geo/technology). Note:
|
|
191
|
+
* geo + "uses a CRM" can't be confirmed from a CanonicalAccount, so an "in" here
|
|
192
|
+
* means "matches what the CRM lets us check" — not a full technographic match.
|
|
193
|
+
*/
|
|
194
|
+
export declare function classifyAccount(account: CanonicalAccount, t: TamTargeting): AccountTamClass;
|
|
195
|
+
/**
|
|
196
|
+
* Coverage that classifies CRM accounts against THIS TAM's ICP — so accounts
|
|
197
|
+
* loaded from anywhere that DON'T match the target market (wrong size/industry)
|
|
198
|
+
* land in out-of-TAM/unknown and never inflate coverage. Reconciles bottom-up vs
|
|
199
|
+
* top-down: the in-TAM count is a FLOOR on the real universe (you've verified
|
|
200
|
+
* those exist), so when it exceeds the estimate, the estimate was low.
|
|
201
|
+
*/
|
|
202
|
+
export declare function classifyCoverage(model: TamModel, snapshot: CanonicalGtmSnapshot, at: string): TamCoverage;
|
|
203
|
+
/** The covered-account count a coverage reading represents: in-TAM when
|
|
204
|
+
* classified, else the raw CRM count (legacy). Used for ETA + reconciliation. */
|
|
205
|
+
export declare function coveredAccounts(c: TamCoverage): number;
|
|
206
|
+
export declare function computeCoverage(model: TamModel, inCrm: TamCoverageCounts, at: string): TamCoverage;
|
|
207
|
+
/**
|
|
208
|
+
* Project when the account universe is filled, from the coverage timeline. Uses
|
|
209
|
+
* the first and last readings that show real movement. Returns null when there
|
|
210
|
+
* isn't enough signal (fewer than two readings, no elapsed time, or a flat/
|
|
211
|
+
* negative burn rate) — an honest "can't project yet" rather than a fake date.
|
|
212
|
+
*/
|
|
213
|
+
export declare function projectEta(model: TamModel, timeline: TamCoverage[]): TamEta | null;
|
|
214
|
+
/** `$FSGTM_HOME[/profiles/<profile>]/tam/<name>/`. */
|
|
215
|
+
export declare function tamDir(name: string): string;
|
|
216
|
+
export declare function saveTamModel(model: TamModel): string;
|
|
217
|
+
export declare function loadTamModel(name: string): TamModel | null;
|
|
218
|
+
/** Append a coverage reading to the TAM's append-only timeline (0600). */
|
|
219
|
+
export declare function appendCoverage(name: string, coverage: TamCoverage): void;
|
|
220
|
+
/** Read the TAM's coverage timeline; tolerant of partial/corrupt lines. */
|
|
221
|
+
export declare function readCoverageTimeline(name: string): TamCoverage[];
|
|
222
|
+
/** A compact human summary for `tam status`. */
|
|
223
|
+
export declare function coverageToText(model: TamModel, coverage: TamCoverage, eta: TamEta | null): string;
|
|
224
|
+
/** A client-ready markdown deliverable for `tam report`. */
|
|
225
|
+
export declare function tamReportToMarkdown(model: TamModel, timeline: TamCoverage[], eta: TamEta | null): string;
|