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/tam.js
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
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 { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.js";
|
|
27
|
+
export function estimateTam(input) {
|
|
28
|
+
if (!Number.isFinite(input.accounts) || input.accounts < 0) {
|
|
29
|
+
throw new Error(`tam: account universe must be a non-negative number (got ${input.accounts})`);
|
|
30
|
+
}
|
|
31
|
+
if (!Number.isFinite(input.acv.valueUsd) || input.acv.valueUsd <= 0) {
|
|
32
|
+
throw new Error(`tam: ACV must be a positive number (got ${input.acv.valueUsd}) — a TAM needs a real ACV`);
|
|
33
|
+
}
|
|
34
|
+
const buyersPerAccount = input.buyersPerAccount ?? 1;
|
|
35
|
+
if (!Number.isFinite(buyersPerAccount) || buyersPerAccount <= 0) {
|
|
36
|
+
throw new Error(`tam: buyers-per-account must be positive (got ${buyersPerAccount})`);
|
|
37
|
+
}
|
|
38
|
+
const accounts = Math.round(input.accounts);
|
|
39
|
+
const contacts = Math.round(accounts * buyersPerAccount);
|
|
40
|
+
const basis = input.acv.basis ?? "account";
|
|
41
|
+
const tamUsd = Math.round((basis === "buyer" ? contacts : accounts) * input.acv.valueUsd);
|
|
42
|
+
return {
|
|
43
|
+
name: input.name,
|
|
44
|
+
icpName: input.icpName,
|
|
45
|
+
universe: {
|
|
46
|
+
accounts,
|
|
47
|
+
accountsSource: input.accountsSource,
|
|
48
|
+
buyersPerAccount,
|
|
49
|
+
buyersSource: input.buyersSource ?? "explicit",
|
|
50
|
+
contacts,
|
|
51
|
+
},
|
|
52
|
+
...(input.targeting ? { targeting: input.targeting } : {}),
|
|
53
|
+
acv: { basis, valueUsd: input.acv.valueUsd, source: input.acv.source ?? "explicit" },
|
|
54
|
+
tamUsd,
|
|
55
|
+
crossChecks: input.crossChecks ?? [],
|
|
56
|
+
baseline: input.baseline,
|
|
57
|
+
createdAt: input.createdAt,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// ── Deriving real signal from the CRM (no fabricated defaults) ─────────────────
|
|
61
|
+
function median(values) {
|
|
62
|
+
const s = [...values].sort((a, b) => a - b);
|
|
63
|
+
const mid = Math.floor(s.length / 2);
|
|
64
|
+
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Derive a real ACV from the CRM's closed-won deals (median amount — robust to
|
|
68
|
+
* outliers). Returns null when there are no usable won deals, so the caller can
|
|
69
|
+
* refuse to fabricate a dollar TAM. The median is the headline; the mean is
|
|
70
|
+
* surfaced too so a skewed pipeline is visible.
|
|
71
|
+
*/
|
|
72
|
+
export function deriveAcvFromClosedWon(snapshot) {
|
|
73
|
+
const amounts = (snapshot.deals ?? [])
|
|
74
|
+
.filter((d) => d.isWon === true && typeof d.amount === "number" && Number.isFinite(d.amount) && d.amount > 0)
|
|
75
|
+
.map((d) => d.amount);
|
|
76
|
+
if (amounts.length === 0)
|
|
77
|
+
return null;
|
|
78
|
+
const meanUsd = Math.round(amounts.reduce((a, b) => a + b, 0) / amounts.length);
|
|
79
|
+
return { valueUsd: Math.round(median(amounts)), dealCount: amounts.length, meanUsd };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Derive buyers/account from the CRM: the average number of contacts on accounts
|
|
83
|
+
* that have at least one. A real proxy for the buying group, vs. a hardcoded
|
|
84
|
+
* guess. Returns null when no account has a contact.
|
|
85
|
+
*/
|
|
86
|
+
export function deriveBuyersPerAccount(snapshot) {
|
|
87
|
+
const perAccount = new Map();
|
|
88
|
+
for (const c of snapshot.contacts ?? []) {
|
|
89
|
+
if (c.accountId)
|
|
90
|
+
perAccount.set(c.accountId, (perAccount.get(c.accountId) ?? 0) + 1);
|
|
91
|
+
}
|
|
92
|
+
if (perAccount.size === 0)
|
|
93
|
+
return null;
|
|
94
|
+
const total = [...perAccount.values()].reduce((a, b) => a + b, 0);
|
|
95
|
+
return { value: Math.round((total / perAccount.size) * 10) / 10, accountsSampled: perAccount.size };
|
|
96
|
+
}
|
|
97
|
+
// ── Coverage (pure) ───────────────────────────────────────────────────────────
|
|
98
|
+
/**
|
|
99
|
+
* Count what "fills" the TAM from a CRM snapshot: accounts that carry a domain
|
|
100
|
+
* (real, signal-watchable records — a name-only stub doesn't count) and the
|
|
101
|
+
* contacts at them. Coverage is measured against these, so a TAM you've started
|
|
102
|
+
* filling reads truthfully even for accounts that pre-dated the campaign.
|
|
103
|
+
*/
|
|
104
|
+
export function coverageCountsFromSnapshot(snapshot) {
|
|
105
|
+
const accounts = (snapshot.accounts ?? []).filter((a) => Boolean(a.domain && a.domain.trim())).length;
|
|
106
|
+
const contacts = (snapshot.contacts ?? []).length;
|
|
107
|
+
return { accounts, contacts };
|
|
108
|
+
}
|
|
109
|
+
const cap1 = (n) => (n <= 0 ? 0 : n >= 1 ? 1 : n);
|
|
110
|
+
function parseBand(band) {
|
|
111
|
+
const plus = /^(\d+)\+$/.exec(band.trim());
|
|
112
|
+
if (plus)
|
|
113
|
+
return [Number(plus[1]), Number.POSITIVE_INFINITY];
|
|
114
|
+
const range = /^(\d+)\s*-\s*(\d+)$/.exec(band.trim());
|
|
115
|
+
if (range)
|
|
116
|
+
return [Number(range[1]), Number(range[2])];
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
function employeeCountInBands(count, bands) {
|
|
120
|
+
return bands.some((b) => {
|
|
121
|
+
const r = parseBand(b);
|
|
122
|
+
return r ? count >= r[0] && count <= r[1] : false;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
function industryMatches(accountIndustry, tamIndustries) {
|
|
126
|
+
const a = accountIndustry.toLowerCase();
|
|
127
|
+
return tamIndustries.some((i) => {
|
|
128
|
+
const t = i.toLowerCase().trim();
|
|
129
|
+
return t.length > 0 && (a.includes(t) || t.includes(a));
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/** The TAM criteria checkable from a CanonicalAccount — geo/technology are NOT on
|
|
133
|
+
* the record, so they need re-enrichment and are excluded here. */
|
|
134
|
+
export function crmCheckableCriteria(t) {
|
|
135
|
+
const c = [];
|
|
136
|
+
if (t.employeeBands?.length)
|
|
137
|
+
c.push("size");
|
|
138
|
+
if (t.industries?.length)
|
|
139
|
+
c.push("industry");
|
|
140
|
+
return c;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Classify one CRM account against the TAM ICP, using only CRM-checkable criteria
|
|
144
|
+
* (size, industry). "out" if any criterion contradicts (wrong size/industry);
|
|
145
|
+
* "in" if at least one passes and none contradict; "unknown" if nothing could be
|
|
146
|
+
* evaluated (missing fields, or the TAM is defined only by geo/technology). Note:
|
|
147
|
+
* geo + "uses a CRM" can't be confirmed from a CanonicalAccount, so an "in" here
|
|
148
|
+
* means "matches what the CRM lets us check" — not a full technographic match.
|
|
149
|
+
*/
|
|
150
|
+
export function classifyAccount(account, t) {
|
|
151
|
+
let pass = 0;
|
|
152
|
+
let fail = 0;
|
|
153
|
+
let checkable = 0;
|
|
154
|
+
if (t.employeeBands?.length) {
|
|
155
|
+
checkable++;
|
|
156
|
+
if (typeof account.employeeCount === "number" && Number.isFinite(account.employeeCount)) {
|
|
157
|
+
if (employeeCountInBands(account.employeeCount, t.employeeBands))
|
|
158
|
+
pass++;
|
|
159
|
+
else
|
|
160
|
+
fail++;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (t.industries?.length) {
|
|
164
|
+
checkable++;
|
|
165
|
+
if (account.industry && account.industry.trim()) {
|
|
166
|
+
if (industryMatches(account.industry, t.industries))
|
|
167
|
+
pass++;
|
|
168
|
+
else
|
|
169
|
+
fail++;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (checkable === 0)
|
|
173
|
+
return "unknown"; // TAM is geo/technology-only → not CRM-checkable
|
|
174
|
+
if (fail > 0)
|
|
175
|
+
return "out";
|
|
176
|
+
if (pass > 0)
|
|
177
|
+
return "in";
|
|
178
|
+
return "unknown"; // criteria specified but the account had no data for them
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Coverage that classifies CRM accounts against THIS TAM's ICP — so accounts
|
|
182
|
+
* loaded from anywhere that DON'T match the target market (wrong size/industry)
|
|
183
|
+
* land in out-of-TAM/unknown and never inflate coverage. Reconciles bottom-up vs
|
|
184
|
+
* top-down: the in-TAM count is a FLOOR on the real universe (you've verified
|
|
185
|
+
* those exist), so when it exceeds the estimate, the estimate was low.
|
|
186
|
+
*/
|
|
187
|
+
export function classifyCoverage(model, snapshot, at) {
|
|
188
|
+
const accounts = (snapshot.accounts ?? []).filter((a) => Boolean(a.domain && a.domain.trim()));
|
|
189
|
+
const totalCrm = accounts.length;
|
|
190
|
+
const allContacts = (snapshot.contacts ?? []).length;
|
|
191
|
+
const t = model.targeting;
|
|
192
|
+
const criteria = t ? crmCheckableCriteria(t) : [];
|
|
193
|
+
let inTam = totalCrm;
|
|
194
|
+
let outOfTam = 0;
|
|
195
|
+
let unknown = 0;
|
|
196
|
+
const inTamIds = new Set(accounts.map((a) => a.id)); // legacy default: all in
|
|
197
|
+
if (t && criteria.length > 0) {
|
|
198
|
+
inTam = 0;
|
|
199
|
+
inTamIds.clear();
|
|
200
|
+
for (const a of accounts) {
|
|
201
|
+
const klass = classifyAccount(a, t);
|
|
202
|
+
if (klass === "in") {
|
|
203
|
+
inTam++;
|
|
204
|
+
inTamIds.add(a.id);
|
|
205
|
+
}
|
|
206
|
+
else if (klass === "out") {
|
|
207
|
+
outOfTam++;
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
unknown++;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const inTamContacts = criteria.length > 0
|
|
215
|
+
? (snapshot.contacts ?? []).filter((c) => c.accountId && inTamIds.has(c.accountId)).length
|
|
216
|
+
: allContacts;
|
|
217
|
+
const accountCoverage = model.universe.accounts > 0 ? cap1(inTam / model.universe.accounts) : 0;
|
|
218
|
+
const contactCoverage = model.universe.contacts > 0 ? cap1(inTamContacts / model.universe.contacts) : 0;
|
|
219
|
+
return {
|
|
220
|
+
at,
|
|
221
|
+
universe: { accounts: model.universe.accounts, contacts: model.universe.contacts, tamUsd: model.tamUsd },
|
|
222
|
+
inCrm: { accounts: totalCrm, contacts: allContacts },
|
|
223
|
+
addedSinceBaseline: {
|
|
224
|
+
accounts: Math.max(0, totalCrm - model.baseline.accounts),
|
|
225
|
+
contacts: Math.max(0, allContacts - model.baseline.contacts),
|
|
226
|
+
},
|
|
227
|
+
accountCoverage,
|
|
228
|
+
contactCoverage,
|
|
229
|
+
dollarCovered: Math.round(accountCoverage * model.tamUsd),
|
|
230
|
+
classified: { inTam, outOfTam, unknown, totalCrm, criteria },
|
|
231
|
+
reconciledUniverse: Math.max(model.universe.accounts, inTam),
|
|
232
|
+
bottomUpExceedsEstimate: inTam > model.universe.accounts,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/** The covered-account count a coverage reading represents: in-TAM when
|
|
236
|
+
* classified, else the raw CRM count (legacy). Used for ETA + reconciliation. */
|
|
237
|
+
export function coveredAccounts(c) {
|
|
238
|
+
return c.classified ? c.classified.inTam : c.inCrm.accounts;
|
|
239
|
+
}
|
|
240
|
+
export function computeCoverage(model, inCrm, at) {
|
|
241
|
+
const accountCoverage = model.universe.accounts > 0 ? cap1(inCrm.accounts / model.universe.accounts) : 0;
|
|
242
|
+
const contactCoverage = model.universe.contacts > 0 ? cap1(inCrm.contacts / model.universe.contacts) : 0;
|
|
243
|
+
return {
|
|
244
|
+
at,
|
|
245
|
+
universe: { accounts: model.universe.accounts, contacts: model.universe.contacts, tamUsd: model.tamUsd },
|
|
246
|
+
inCrm,
|
|
247
|
+
addedSinceBaseline: {
|
|
248
|
+
accounts: Math.max(0, inCrm.accounts - model.baseline.accounts),
|
|
249
|
+
contacts: Math.max(0, inCrm.contacts - model.baseline.contacts),
|
|
250
|
+
},
|
|
251
|
+
accountCoverage,
|
|
252
|
+
contactCoverage,
|
|
253
|
+
dollarCovered: Math.round(accountCoverage * model.tamUsd),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const MS_PER_DAY = 86_400_000;
|
|
257
|
+
/**
|
|
258
|
+
* Project when the account universe is filled, from the coverage timeline. Uses
|
|
259
|
+
* the first and last readings that show real movement. Returns null when there
|
|
260
|
+
* isn't enough signal (fewer than two readings, no elapsed time, or a flat/
|
|
261
|
+
* negative burn rate) — an honest "can't project yet" rather than a fake date.
|
|
262
|
+
*/
|
|
263
|
+
export function projectEta(model, timeline) {
|
|
264
|
+
if (timeline.length < 2)
|
|
265
|
+
return null;
|
|
266
|
+
const sorted = [...timeline].sort((a, b) => a.at.localeCompare(b.at));
|
|
267
|
+
const first = sorted[0];
|
|
268
|
+
const last = sorted[sorted.length - 1];
|
|
269
|
+
const elapsedDays = (Date.parse(last.at) - Date.parse(first.at)) / MS_PER_DAY;
|
|
270
|
+
if (!Number.isFinite(elapsedDays) || elapsedDays <= 0)
|
|
271
|
+
return null;
|
|
272
|
+
// Pace is measured on IN-TAM accounts (when classified) — off-ICP accounts
|
|
273
|
+
// loaded from elsewhere don't count as progress toward THIS universe.
|
|
274
|
+
const gained = coveredAccounts(last) - coveredAccounts(first);
|
|
275
|
+
if (gained <= 0)
|
|
276
|
+
return null;
|
|
277
|
+
const accountsPerDay = gained / elapsedDays;
|
|
278
|
+
const accountsRemaining = Math.max(0, model.universe.accounts - coveredAccounts(last));
|
|
279
|
+
const daysRemaining = Math.ceil(accountsRemaining / accountsPerDay);
|
|
280
|
+
const etaDate = new Date(Date.parse(last.at) + daysRemaining * MS_PER_DAY).toISOString().slice(0, 10);
|
|
281
|
+
return {
|
|
282
|
+
accountsPerDay: Math.round(accountsPerDay * 10) / 10,
|
|
283
|
+
accountsRemaining,
|
|
284
|
+
daysRemaining,
|
|
285
|
+
etaDate,
|
|
286
|
+
basis: `${gained} accounts over ${Math.round(elapsedDays)}d (${first.at.slice(0, 10)}→${last.at.slice(0, 10)})`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
// ── Profile-scoped store ──────────────────────────────────────────────────────
|
|
290
|
+
function safeName(name) {
|
|
291
|
+
if (!/^[\w.-]+$/.test(name))
|
|
292
|
+
throw new Error(`tam: invalid name "${name}" (use letters, digits, . _ -)`);
|
|
293
|
+
return name;
|
|
294
|
+
}
|
|
295
|
+
/** `$FSGTM_HOME[/profiles/<profile>]/tam/<name>/`. */
|
|
296
|
+
export function tamDir(name) {
|
|
297
|
+
return join(credentialsDir(), "tam", safeName(name));
|
|
298
|
+
}
|
|
299
|
+
export function saveTamModel(model) {
|
|
300
|
+
ensureSecureHomeDir();
|
|
301
|
+
const dir = tamDir(model.name);
|
|
302
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
303
|
+
const path = join(dir, "model.json");
|
|
304
|
+
writeSecureFile(path, `${JSON.stringify(model, null, 2)}\n`);
|
|
305
|
+
return path;
|
|
306
|
+
}
|
|
307
|
+
export function loadTamModel(name) {
|
|
308
|
+
try {
|
|
309
|
+
return JSON.parse(readFileSync(join(tamDir(name), "model.json"), "utf8"));
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/** Append a coverage reading to the TAM's append-only timeline (0600). */
|
|
316
|
+
export function appendCoverage(name, coverage) {
|
|
317
|
+
ensureSecureHomeDir();
|
|
318
|
+
const dir = tamDir(name);
|
|
319
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
320
|
+
appendFileSync(join(dir, "coverage.jsonl"), `${JSON.stringify(coverage)}\n`, { mode: 0o600 });
|
|
321
|
+
}
|
|
322
|
+
/** Read the TAM's coverage timeline; tolerant of partial/corrupt lines. */
|
|
323
|
+
export function readCoverageTimeline(name) {
|
|
324
|
+
let raw;
|
|
325
|
+
try {
|
|
326
|
+
raw = readFileSync(join(tamDir(name), "coverage.jsonl"), "utf8");
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
const out = [];
|
|
332
|
+
for (const line of raw.split("\n")) {
|
|
333
|
+
const t = line.trim();
|
|
334
|
+
if (!t)
|
|
335
|
+
continue;
|
|
336
|
+
try {
|
|
337
|
+
out.push(JSON.parse(t));
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
// skip a torn line rather than failing the rollup
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
// ── Rendering ─────────────────────────────────────────────────────────────────
|
|
346
|
+
function usd(n) {
|
|
347
|
+
if (n >= 1_000_000_000)
|
|
348
|
+
return `$${(n / 1_000_000_000).toFixed(2)}B`;
|
|
349
|
+
if (n >= 1_000_000)
|
|
350
|
+
return `$${(n / 1_000_000).toFixed(1)}M`;
|
|
351
|
+
if (n >= 1_000)
|
|
352
|
+
return `$${(n / 1_000).toFixed(0)}K`;
|
|
353
|
+
return `$${Math.round(n)}`;
|
|
354
|
+
}
|
|
355
|
+
const pct = (x) => `${Math.round(x * 100)}%`;
|
|
356
|
+
/** A compact human summary for `tam status`. */
|
|
357
|
+
export function coverageToText(model, coverage, eta) {
|
|
358
|
+
const c = coverage.classified;
|
|
359
|
+
const lines = [
|
|
360
|
+
`TAM "${model.name}" (ICP: ${model.icpName}) — ${model.acv.basis}-basis ACV ${usd(model.acv.valueUsd)} ` +
|
|
361
|
+
`[${model.acv.source}]`,
|
|
362
|
+
`Universe: ${model.universe.accounts.toLocaleString()} accounts (${model.universe.accountsSource}) / ` +
|
|
363
|
+
`${model.universe.contacts.toLocaleString()} contacts ` +
|
|
364
|
+
`(${model.universe.buyersPerAccount} buyers/account [${model.universe.buyersSource}]) / ${usd(model.tamUsd)}`,
|
|
365
|
+
];
|
|
366
|
+
if (c && c.criteria.length > 0) {
|
|
367
|
+
// Classified coverage: only in-TAM accounts count; junk is bucketed out.
|
|
368
|
+
lines.push(`In CRM: ${c.totalCrm.toLocaleString()} accounts → ${c.inTam.toLocaleString()} in-TAM (${pct(coverage.accountCoverage)}), ` +
|
|
369
|
+
`${c.outOfTam.toLocaleString()} out-of-TAM, ${c.unknown.toLocaleString()} unknown`, ` (classified on ${c.criteria.join(" + ")}; geo + "uses-CRM" not CRM-verified)`, `$ covered: ${usd(coverage.dollarCovered)} of ${usd(model.tamUsd)} (${pct(coverage.accountCoverage)} of estimate)`);
|
|
370
|
+
if (coverage.bottomUpExceedsEstimate) {
|
|
371
|
+
lines.push(`⚠ Bottom-up exceeds the estimate: ${c.inTam.toLocaleString()} in-TAM accounts in CRM > ` +
|
|
372
|
+
`${model.universe.accounts.toLocaleString()} estimated. The estimate was a FLOOR — your real market is ` +
|
|
373
|
+
`at least ${(coverage.reconciledUniverse ?? c.inTam).toLocaleString()}. Re-estimate (broader ICP / better ` +
|
|
374
|
+
`source) to find the remaining headroom.`);
|
|
375
|
+
}
|
|
376
|
+
if (c.unknown > 0) {
|
|
377
|
+
lines.push(` ${c.unknown.toLocaleString()} accounts couldn't be classified (missing ${c.criteria.join("/")} on the record) ` +
|
|
378
|
+
"— enrich them, or they may be in- or out-of-TAM.");
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
// Unclassified (legacy model with no targeting, or geo/tech-only ICP).
|
|
383
|
+
lines.push(`In CRM: ${coverage.inCrm.accounts.toLocaleString()} accounts (${pct(coverage.accountCoverage)}) / ` +
|
|
384
|
+
`${coverage.inCrm.contacts.toLocaleString()} contacts (${pct(coverage.contactCoverage)})`, ` └ added since baseline: ${coverage.addedSinceBaseline.accounts.toLocaleString()} accounts / ` +
|
|
385
|
+
`${coverage.addedSinceBaseline.contacts.toLocaleString()} contacts`, `$ covered: ${usd(coverage.dollarCovered)} of ${usd(model.tamUsd)} (${pct(coverage.accountCoverage)})`);
|
|
386
|
+
if (coverage.classified) {
|
|
387
|
+
lines.push(` (counting ALL CRM accounts — this TAM has no CRM-checkable criteria (geo/technology only); ` +
|
|
388
|
+
"re-estimate with size/industry in the ICP to classify in/out-of-TAM)");
|
|
389
|
+
}
|
|
390
|
+
else if (!model.targeting) {
|
|
391
|
+
lines.push(" (counting ALL CRM accounts — re-run `tam estimate` to store the ICP filter and classify in/out-of-TAM)");
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (eta) {
|
|
395
|
+
lines.push(`Pace: ${eta.accountsPerDay}/day → ~${eta.daysRemaining} days to fill ` +
|
|
396
|
+
`(${eta.accountsRemaining.toLocaleString()} accounts left, ETA ${eta.etaDate}; ${eta.basis})`);
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
lines.push(`Pace: not enough history to project an ETA yet — save another reading after some population runs.`);
|
|
400
|
+
}
|
|
401
|
+
if (model.crossChecks.length) {
|
|
402
|
+
lines.push(`Cross-checks:`);
|
|
403
|
+
for (const cc of model.crossChecks) {
|
|
404
|
+
lines.push(` • ${cc.claim}${cc.valueUsd ? ` (${usd(cc.valueUsd)})` : ""} — ${cc.sourceUrl}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return lines.join("\n");
|
|
408
|
+
}
|
|
409
|
+
/** A client-ready markdown deliverable for `tam report`. */
|
|
410
|
+
export function tamReportToMarkdown(model, timeline, eta) {
|
|
411
|
+
const latest = timeline.length ? timeline[timeline.length - 1] : computeCoverage(model, model.baseline, model.createdAt);
|
|
412
|
+
const lines = [
|
|
413
|
+
`# TAM map — ${model.name}`,
|
|
414
|
+
"",
|
|
415
|
+
`**ICP:** ${model.icpName} · **Created:** ${model.createdAt.slice(0, 10)}`,
|
|
416
|
+
"",
|
|
417
|
+
"## The universe",
|
|
418
|
+
"",
|
|
419
|
+
`| | Estimate |`,
|
|
420
|
+
`| --- | --- |`,
|
|
421
|
+
`| Accounts | ${model.universe.accounts.toLocaleString()} (${model.universe.accountsSource}) |`,
|
|
422
|
+
`| Buyers / account | ${model.universe.buyersPerAccount} (${model.universe.buyersSource}) |`,
|
|
423
|
+
`| Contacts (population target) | ${model.universe.contacts.toLocaleString()} |`,
|
|
424
|
+
`| ACV (${model.acv.basis} basis) | ${usd(model.acv.valueUsd)} (${model.acv.source}) |`,
|
|
425
|
+
`| **TAM** | **${usd(model.tamUsd)}** |`,
|
|
426
|
+
"",
|
|
427
|
+
];
|
|
428
|
+
if (model.crossChecks.length) {
|
|
429
|
+
lines.push("### Cross-checks (citable)", "");
|
|
430
|
+
for (const c of model.crossChecks) {
|
|
431
|
+
lines.push(`- **${c.claim}**${c.valueUsd ? ` — ${usd(c.valueUsd)}` : ""} `);
|
|
432
|
+
lines.push(` > ${c.quote} `);
|
|
433
|
+
lines.push(` [source](${c.sourceUrl})${c.asOf ? ` · ${c.asOf}` : ""}`);
|
|
434
|
+
}
|
|
435
|
+
lines.push("");
|
|
436
|
+
}
|
|
437
|
+
const cl = latest.classified;
|
|
438
|
+
const inTamAccounts = cl && cl.criteria.length > 0 ? cl.inTam : latest.inCrm.accounts;
|
|
439
|
+
lines.push("## Coverage", "", `| Dimension | In CRM (in-TAM) | Universe | Covered |`, `| --- | --- | --- | --- |`, `| Accounts | ${inTamAccounts.toLocaleString()} | ${latest.universe.accounts.toLocaleString()} | ${pct(latest.accountCoverage)} |`, `| Dollars | ${usd(latest.dollarCovered)} | ${usd(latest.universe.tamUsd)} | ${pct(latest.accountCoverage)} |`, "");
|
|
440
|
+
if (cl && cl.criteria.length > 0) {
|
|
441
|
+
lines.push(`Of **${cl.totalCrm.toLocaleString()}** CRM accounts: **${cl.inTam.toLocaleString()} in-TAM**, ` +
|
|
442
|
+
`${cl.outOfTam.toLocaleString()} out-of-TAM, ${cl.unknown.toLocaleString()} unknown ` +
|
|
443
|
+
`(classified on ${cl.criteria.join(" + ")}; geo + "uses-CRM" not CRM-verified).`, "");
|
|
444
|
+
if (latest.bottomUpExceedsEstimate) {
|
|
445
|
+
lines.push(`> ⚠ **Bottom-up exceeds the estimate.** ${cl.inTam.toLocaleString()} in-TAM accounts already in CRM > ` +
|
|
446
|
+
`${model.universe.accounts.toLocaleString()} estimated — the estimate was a floor; the real universe is ` +
|
|
447
|
+
`at least ${(latest.reconciledUniverse ?? cl.inTam).toLocaleString()}. Re-estimate to find the headroom.`, "");
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
lines.push(`Added since baseline: **${latest.addedSinceBaseline.accounts.toLocaleString()} accounts**, ` +
|
|
452
|
+
`**${latest.addedSinceBaseline.contacts.toLocaleString()} contacts**. ` +
|
|
453
|
+
"_(All CRM accounts counted — no ICP filter stored; re-estimate to classify in/out-of-TAM.)_", "");
|
|
454
|
+
}
|
|
455
|
+
if (eta) {
|
|
456
|
+
lines.push("## Pace & ETA", "", `At **${eta.accountsPerDay} accounts/day** (${eta.basis}), ~**${eta.daysRemaining} days** remain ` +
|
|
457
|
+
`to cover the account universe (${eta.accountsRemaining.toLocaleString()} left) — projected **${eta.etaDate}**.`, "");
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
lines.push("## Pace & ETA", "", "_Not enough history to project yet._ Save another `tam status --save` reading after a few population runs.", "");
|
|
461
|
+
}
|
|
462
|
+
if (timeline.length >= 2) {
|
|
463
|
+
lines.push("### Burn-up", "", `| Date | Accounts in CRM | Covered |`, `| --- | --- | --- |`);
|
|
464
|
+
for (const c of timeline) {
|
|
465
|
+
lines.push(`| ${c.at.slice(0, 10)} | ${c.inCrm.accounts.toLocaleString()} | ${pct(c.accountCoverage)} |`);
|
|
466
|
+
}
|
|
467
|
+
lines.push("");
|
|
468
|
+
}
|
|
469
|
+
return lines.join("\n");
|
|
470
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -24,6 +24,13 @@ export type CreateRecordPayload = {
|
|
|
24
24
|
source: string;
|
|
25
25
|
estCostUsd?: number;
|
|
26
26
|
associateCompanyName?: string;
|
|
27
|
+
/**
|
|
28
|
+
* The company's domain, when known. The connector resolves the account by
|
|
29
|
+
* DOMAIN first (the accurate key), creates it with the domain, and fills the
|
|
30
|
+
* domain on an existing account that lacks one — so the lead's account is a
|
|
31
|
+
* real, signal-watchable record (`signals`/`icp judge` key on account domain).
|
|
32
|
+
*/
|
|
33
|
+
associateCompanyDomain?: string;
|
|
27
34
|
/**
|
|
28
35
|
* Owner to stamp on the new record (canonical owner id). The connector maps
|
|
29
36
|
* it to the CRM's owner property (HubSpot `hubspot_owner_id`) at create time
|
|
@@ -291,7 +298,12 @@ export type PatchPlan = {
|
|
|
291
298
|
status: ApprovalStatus;
|
|
292
299
|
dryRun: true;
|
|
293
300
|
summary: string;
|
|
301
|
+
/** The COMPLETE, object-typed findings list — every account/contact/deal
|
|
302
|
+
* finding, each carrying its `objectType`. This is the canonical set. */
|
|
294
303
|
findings: AuditFinding[];
|
|
304
|
+
/** A deal/sales-PIPELINE projection of `findings` (deal-level + the
|
|
305
|
+
* call-next-step type) for the pipeline-trust queue — intentionally a subset.
|
|
306
|
+
* Account/contact hygiene findings are NOT here; read `findings` for those. */
|
|
295
307
|
pipelineFindings?: PipelineFinding[];
|
|
296
308
|
evidence?: GtmEvidence[];
|
|
297
309
|
operations: PatchOperation[];
|
package/docs/api.md
CHANGED
|
@@ -64,18 +64,32 @@ release.
|
|
|
64
64
|
|
|
65
65
|
## CLI
|
|
66
66
|
|
|
67
|
-
Commands: `login` / `logout`, `snapshot`, `audit`, `report`, `diff`, `merge`, `plans`,
|
|
68
|
-
`apply`, `suggest`, `
|
|
69
|
-
`
|
|
67
|
+
Commands: `init`, `login` / `logout`, `snapshot`, `audit`, `report`, `diff`, `merge`, `plans`,
|
|
68
|
+
`apply`, `suggest`, `audit-log` (`export` / `verify`),
|
|
69
|
+
`call` (`parse` / `classify` / `score` / `link` / `plan`), `resolve`,
|
|
70
|
+
`bulk-update`, `dedupe`, `reassign`, `fix`, `health`,
|
|
70
71
|
`market` (`init` / `capture` / `classify` / `worksheet` / `observe` / `fronts` /
|
|
71
72
|
`axes` / `overlay` / `scale` / `report` / `refresh`),
|
|
73
|
+
`tam` (`estimate` / `accounts` / `status` / `report` / `populate`),
|
|
72
74
|
`enrich` (`append` / `refresh` / `ingest` / `acquire` / `status`),
|
|
73
|
-
`icp` (`interview` / `set` / `show`),
|
|
75
|
+
`icp` (`interview` / `set` / `show` / `judge` / `eval`),
|
|
76
|
+
`signals` (`fetch` / `list` / `outcome` / `weights`), `draft`,
|
|
74
77
|
`schedule` (`add` / `list` / `remove` / `enable` / `disable` / `run` /
|
|
75
78
|
`install` / `uninstall` / `status`), `rules`, `profiles`, `doctor`.
|
|
76
79
|
Exit codes: `0` success · `1` error · `2` findings/regressions at the requested gate
|
|
77
80
|
(`--fail-on`, `--fail-on-new-findings`). `--json` everywhere; JSON output shapes are stable.
|
|
78
81
|
|
|
82
|
+
### Flag lexicon: `--provider` vs `--source` vs `--connector` vs `--channel`
|
|
83
|
+
|
|
84
|
+
Four flags name "where data comes from / goes to"; they are not interchangeable:
|
|
85
|
+
|
|
86
|
+
| Flag | Meaning | Values |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `--provider` | The CRM the data lives in — the system a snapshot is read from and an approved plan is applied to. | `hubspot`, `salesforce` (plus `stripe`, read-only) |
|
|
89
|
+
| `--source` | The external data source feeding a verb: an enrichment/discovery vendor on `enrich`/`tam` (`--source apollo`, `acquire --source pipe0`), a staged-ingest label on `enrich ingest`, or — on `signals fetch` — which no-auth ATS board adapters to scan. | `apollo`, `clay`, `pipe0`, `explorium`, `theirstack`, `linkedin` (HeyReach); `greenhouse` / `lever` / `ashby` on `signals fetch` |
|
|
90
|
+
| `--connector` | A signal-intake source connector on `signals fetch`: pulls candidate signals from a connected platform or the local webhook spool into the signal ledger. | `file`, `serpapi-news`, `hubspot-forms` |
|
|
91
|
+
| `--channel` | Where a plan's output is delivered. On `apply`, the delivery terminus — a plan applies to `--provider <crm>` *or* `--channel outbox` (render approved openers to a local outbox file; transmits nothing), never both. On `draft`, which outreach channel the opener is drafted for (shapes the emitted op). | `apply`: `outbox` · `draft`: `email`, `linkedin`, `task` |
|
|
92
|
+
|
|
79
93
|
Credential resolution ladder: explicit `--token-env` → ambient env
|
|
80
94
|
(`HUBSPOT_ACCESS_TOKEN`, `SALESFORCE_ACCESS_TOKEN`+`SALESFORCE_INSTANCE_URL`,
|
|
81
95
|
`STRIPE_SECRET_KEY`) → stored login (`~/.fullstackgtm`, `FSGTM_HOME` override)
|
|
@@ -139,6 +153,14 @@ a possible dup), capped by the meter's headroom. `builtinAcquirePreset` is the
|
|
|
139
153
|
zero-config `EnrichConfig.acquire` (budget, provider, create-mapping) so
|
|
140
154
|
`enrich acquire` runs with just an `icp.json`.
|
|
141
155
|
|
|
156
|
+
**Contacts or accounts.** `acquire.create` is keyed by object type, so the same
|
|
157
|
+
spine acquires net-new **accounts** for ABM, not just contacts: configure
|
|
158
|
+
`acquire.create.company` (match key `domain`, properties → `name`/`domain`) and
|
|
159
|
+
feed company rows (`enrich ingest companies.csv --objects companies`). Each
|
|
160
|
+
unmatched company becomes a `create_record` op of `objectType: account` with its
|
|
161
|
+
domain stamped — so the acquired account is immediately signal-watchable
|
|
162
|
+
(`signals`/`icp judge` key on account domain).
|
|
163
|
+
|
|
142
164
|
- **ICP** (`icp.ts`): `Icp` type, `parseIcp`, `icpToExploriumFilters` /
|
|
143
165
|
`icpToCrustdataFilters` (per-provider discovery filters), `scoreProspectAgainstIcp`
|
|
144
166
|
(persona fit 0..1), `fitThreshold`, `INTERVIEW_SPEC` + `icpFromAnswers` (the
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Outbox format (governed send queue)
|
|
2
|
+
|
|
3
|
+
The **outbox** is the send-side mirror of the [signal spool](./signal-spool-format.md).
|
|
4
|
+
It is how an *approved* drafted opener leaves the CLI — as a governed artifact a
|
|
5
|
+
downstream sender picks up, **not** as a transmitted message.
|
|
6
|
+
|
|
7
|
+
`fullstackgtm` **drafts everything and transmits nothing.** The CLI never opens
|
|
8
|
+
an SMTP or messaging-API connection. `apply --channel outbox` renders each
|
|
9
|
+
approved opener to a local JSONL file; a sender you run (the hosted product, or
|
|
10
|
+
your own MTA / ESP integration) drains the outbox and performs the actual send.
|
|
11
|
+
This is the send-side half of the open-core boundary: the governed artifact and
|
|
12
|
+
its format are open; always-on transmission infrastructure is hosted/opt-in.
|
|
13
|
+
|
|
14
|
+
## The loop
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
signals fetch → icp judge → draft → plans approve → apply --channel outbox → <sender>
|
|
18
|
+
(detect) (decide) (write) (human gate) (render, no send) (transmit)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`draft` stages a `needs_approval` plan of `create_task` operations, each carrying
|
|
22
|
+
one opener grounded in a verbatim signal quote. After `plans approve`, applying
|
|
23
|
+
the plan **through the outbox channel** (instead of a CRM provider) renders each
|
|
24
|
+
approved operation to the outbox. Nothing is sent, and no CRM record is written.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
fullstackgtm draft --from-judge latest --channel email --save
|
|
28
|
+
fullstackgtm plans approve <planId> --operations all
|
|
29
|
+
fullstackgtm apply --plan-id <planId> --channel outbox # renders; transmits nothing
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Applying the same plan through a CRM provider instead (`--provider hubspot`)
|
|
33
|
+
logs the opener as a CRM task — the existing behavior. The two are alternatives:
|
|
34
|
+
a plan applies to one target.
|
|
35
|
+
|
|
36
|
+
## Where the outbox lives
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
<profile home>/signals/outbox/<channel>.jsonl (default: ~/.fullstackgtm/signals/outbox/)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
One file per channel (`email.jsonl`, `linkedin.jsonl`, `task.jsonl`), matching the
|
|
43
|
+
draft op's channel. Profile-scoped, owner-only (0600). A sender reads the file(s)
|
|
44
|
+
for the channel it handles and drains them on its own schedule.
|
|
45
|
+
|
|
46
|
+
## The format
|
|
47
|
+
|
|
48
|
+
Newline-delimited JSON (JSONL), one **outbox entry** per approved opener:
|
|
49
|
+
|
|
50
|
+
| Field | Meaning |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `id` | The source operation id — the **idempotency key**. Re-rendering the same op never duplicates a row. |
|
|
53
|
+
| `channel` | `email` \| `linkedin` \| `task` — from the draft op. |
|
|
54
|
+
| `objectType` | `contact` \| `account` — the CRM object the opener is addressed to. |
|
|
55
|
+
| `objectId` | The CRM record id. A sender with CRM access resolves it to an email / profile. |
|
|
56
|
+
| `body` | The **approved opener, verbatim** as it was signed in the plan operation. |
|
|
57
|
+
| `reason` | The draft op's human-readable reason (carries the account and the trigger). |
|
|
58
|
+
| `evidenceIds` | Ids of the evidence the opener was grounded in (the verbatim signal quote). |
|
|
59
|
+
| `renderedAt` | ISO 8601 — when the CLI rendered this to the outbox. **Not** a send time. |
|
|
60
|
+
|
|
61
|
+
Example (`email.jsonl`):
|
|
62
|
+
|
|
63
|
+
```jsonl
|
|
64
|
+
{"id":"op_draft_abc","channel":"email","objectType":"contact","objectId":"C123","body":"Saw the Series B — congrats. Worth a quick chat on revops?","reason":"Signal-grounded opener for globex.com -> contact vp@globex.com","evidenceIds":["ev_globex_funding"],"renderedAt":"2026-06-25T13:08:17.176Z"}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The entry carries the CRM `objectId`, not a resolved email address — the CLI does
|
|
68
|
+
not resolve recipients (that is the sender's job, against the live CRM). The
|
|
69
|
+
`body` is exactly what a human approved; a sender may template around it but the
|
|
70
|
+
approved span is the source of truth.
|
|
71
|
+
|
|
72
|
+
## Governance and what the channel will not do
|
|
73
|
+
|
|
74
|
+
- **Only drafted openers.** The outbox channel renders `create_task` operations
|
|
75
|
+
produced by `draft` (policy `draft:<channel>`). Any other operation is
|
|
76
|
+
`skipped` — it is not a general CRM writer. Apply CRM changes through a CRM
|
|
77
|
+
provider.
|
|
78
|
+
- **Approval-gated and integrity-checked.** Rendering reuses the same apply path
|
|
79
|
+
as a CRM write: only operations whose plan is `approved` are applied, and a
|
|
80
|
+
stored plan's approval signatures are verified before anything is rendered.
|
|
81
|
+
- **Idempotent.** Re-rendering an operation already in the outbox is a no-op.
|
|
82
|
+
- **Draining and retention are the sender's job.** The CLI appends; it never
|
|
83
|
+
truncates the outbox. Your sender removes or archives entries after it
|
|
84
|
+
transmits them.
|
|
85
|
+
|
|
86
|
+
## See also
|
|
87
|
+
|
|
88
|
+
- [signal-spool-format.md](./signal-spool-format.md) — the receive-side mirror.
|
|
89
|
+
- `docs/api.md` — the `apply`/`draft` verbs and the connector contract.
|
|
90
|
+
- The connector taxonomy and the hosted/open split (the managed sender stays
|
|
91
|
+
closed) are in the maintainer design doc
|
|
92
|
+
(monorepo `docs/spec-connectors-signals-outbound.md`).
|