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
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
@@ -387,6 +387,17 @@ export type PatchPlanRun = {
387
387
  status: PatchPlanRunStatus;
388
388
  results: PatchOperationResult[];
389
389
  };
390
+ /**
391
+ * Per-page progress during a snapshot pull, emitted by connectors that accept
392
+ * an `onProgress` option. `fetched` is the running total for the object type
393
+ * currently streaming (it resets when the pull moves on to the next type).
394
+ * Presentation-only: a throwing callback must never fail a pull, so emitters
395
+ * call it best-effort.
396
+ */
397
+ export type SnapshotProgress = {
398
+ objectType: "user" | "account" | "contact" | "deal";
399
+ fetched: number;
400
+ };
390
401
  /**
391
402
  * The provider contract. Reads produce a canonical snapshot; writes accept a
392
403
  * single patch operation and report the outcome. Connectors without
package/docs/api.md CHANGED
@@ -64,18 +64,82 @@ release.
64
64
 
65
65
  ## CLI
66
66
 
67
- Commands: `login` / `logout`, `snapshot`, `audit`, `report`, `diff`, `merge`, `plans`,
68
- `apply`, `suggest`, `call` (`parse` / `score` / `link` / `plan`), `resolve`,
69
- `bulk-update`, `dedupe`, `reassign`, `fix`,
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
- `install` / `uninstall` / `status`), `rules`, `profiles`, `doctor`.
78
+ `install` / `uninstall` / `status`), `rules`, `profiles`, `doctor`,
79
+ `capabilities`, `robot-docs`.
76
80
  Exit codes: `0` success · `1` error · `2` findings/regressions at the requested gate
77
81
  (`--fail-on`, `--fail-on-new-findings`). `--json` everywhere; JSON output shapes are stable.
78
82
 
83
+ Agent discovery: `capabilities` prints the machine-readable CLI contract —
84
+ the command inventory with read-only vs write-shaped access (derived from the
85
+ help table, not a parallel list), this exit-code contract, and safety
86
+ defaults. `<command> --help --json` (and `help <command> --json`) prints
87
+ per-command help as JSON. Unknown commands with `--json` return a structured
88
+ `UNKNOWN_COMMAND` envelope with a did-you-mean hint; the plain-text path
89
+ prints the same hint plus pointers to `--help` and `capabilities --json`.
90
+ `robot-docs` prints the packaged agent guide (skills/fullstackgtm/SKILL.md).
91
+
92
+ Intent inference: a `--flag` that is documented nowhere in the help reference
93
+ but is within one edit of a documented flag (lowercased, `_`→`-`) is treated
94
+ as a typo — exit 1 with `Did you mean` and the exact corrected command
95
+ (structured `UNKNOWN_FLAG` envelope under `--json`). GNU-style `--flag=value`
96
+ spellings of documented flags get the space-separated correction this CLI
97
+ parses. Corrections are suggest-only, never auto-executed. Unknown flags with
98
+ no near-miss are ignored, as before. Unknown `--provider` values and subverb
99
+ typos on the multi-verb commands (`enrich apend` → `enrich append`) get
100
+ nearest-match suggestions; a flag-shaped first token is diagnosed as
101
+ flag-before-command.
102
+
103
+ Triage: `doctor --json` returns environment status plus a `workspace` slice
104
+ (`healthScore`, `scoreDelta`, `lastAuditAt`, `auditCount`, `pendingPlans`)
105
+ and state-aware `nextSteps` — when a plan awaits approval, the exact
106
+ `plans show <id>` → `plans approve <id> --operations <ids|all>` →
107
+ `apply --plan-id <id> --provider <name>` chain. One call orients an agent.
108
+
109
+ Reproducibility: with `SOURCE_DATE_EPOCH` set (seconds since the epoch), the
110
+ audit plan's `createdAt` is pinned and `audit --demo --json` is
111
+ byte-deterministic across re-runs.
112
+
113
+ ### Flag lexicon: `--provider` vs `--source` vs `--connector` vs `--channel`
114
+
115
+ Four flags name "where data comes from / goes to"; they are not interchangeable:
116
+
117
+ | Flag | Meaning | Values |
118
+ |---|---|---|
119
+ | `--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) |
120
+ | `--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` |
121
+ | `--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` |
122
+ | `--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` |
123
+
124
+ ### Standardized flag aliases
125
+
126
+ Additive; every legacy flag keeps working:
127
+
128
+ - `--dry-run` — accepted on the plan-spine verbs (`audit`, `suggest`,
129
+ `bulk-update`, `dedupe`, `reassign`, `fix`, `call plan`,
130
+ `enrich append`/`refresh`/`acquire`, `signals fetch`, `icp judge`, `draft`,
131
+ `tam status`, `market overlay`). These verbs are already preview-by-default
132
+ (plans are dry-run; nothing writes to a CRM outside approve → apply), so the
133
+ flag asserts that default rather than changing it: it additionally suppresses
134
+ `--save` (nothing is persisted to the plan store / run ledgers; a stderr note
135
+ is printed when both are passed) and locks `fix` to its stop-before-apply
136
+ path even when `--yes` is present. Omitting `--dry-run` changes nothing.
137
+ (`tam accounts` had `--dry-run` before this convention — there it prices the
138
+ credit pull; same spirit, spend nothing.)
139
+ - `--confirm` — accepted wherever an ad-hoc confirmation flag already gates a
140
+ write stage: alias for `fix --yes` (`tam accounts` already used `--confirm`
141
+ for its spend guard). `--dry-run` beats `--confirm`/`--yes`.
142
+
79
143
  Credential resolution ladder: explicit `--token-env` → ambient env
80
144
  (`HUBSPOT_ACCESS_TOKEN`, `SALESFORCE_ACCESS_TOKEN`+`SALESFORCE_INSTANCE_URL`,
81
145
  `STRIPE_SECRET_KEY`) → stored login (`~/.fullstackgtm`, `FSGTM_HOME` override)
@@ -131,6 +195,13 @@ dependency-free CSV intake; the Apollo client (`createApolloClient`,
131
195
  `pullApolloRecords`, 429-aware with `Retry-After`) is the first `api`-kind
132
196
  source.
133
197
 
198
+ Staged-file intake follows one container convention (`spoolFiles.ts`, shared
199
+ with `signals fetch --from` and `market observe --from`): `enrich ingest`
200
+ accepts — in addition to its original `.csv` / `.json` forms, which parse
201
+ exactly as before — a `*.jsonl` spool file (one JSON row per line) or a
202
+ directory of `*.jsonl` / `*.json` spool files (name-sorted), the Phase-2
203
+ webhook landing-zone format (docs/signal-spool-format.md).
204
+
134
205
  ## Acquire (net-new lead generation)
135
206
 
136
207
  `buildAcquirePlan` turns sourced-but-unmatched prospects into `create_record`
@@ -212,10 +283,17 @@ firing — an unapproved plan records a `plan_not_approved` no-op run
212
283
  - Cron: `parseCron` → `CronExpression`, `cronMatches`, `nextCronFiring`,
213
284
  `expectedFirings`, `computeMissedFirings` (status and missed-firing
214
285
  visibility — local cron has no catch-up).
215
- - Local provider: `schedule install` renders enabled entries into a
216
- sentinel-managed crontab block `crontabSentinels`, `renderManagedBlock`,
286
+ - Local provider: `schedule install` materializes enabled entries into the
287
+ platform timer. crontab (Linux default, `--timer crontab`): a
288
+ sentinel-managed block — `crontabSentinels`, `renderManagedBlock`,
217
289
  `replaceManagedBlock`, and `systemCrontabIo` behind the injectable
218
- `CrontabIo` seam (tests never touch a real crontab).
290
+ `CrontabIo` seam (tests never touch a real crontab). launchd (macOS
291
+ default, no Full Disk Access needed): one LaunchAgent per entry —
292
+ `cronToCalendarIntervals` (→ `LaunchdCalendarInterval[]`),
293
+ `renderLaunchdPlist`, `launchdLabel` / `launchdAgentPrefix`,
294
+ `installLaunchdAgents` / `uninstallLaunchdAgents`, and `systemLaunchdIo`
295
+ behind the injectable `LaunchdIo` seam (tests never touch launchctl);
296
+ `isDuplicateCronFiring` backs the `duplicate_firing` run no-op.
219
297
 
220
298
  ## Market map
221
299
 
@@ -247,9 +325,11 @@ the stored capture it cites before a set is accepted; failed captures read as
247
325
 
248
326
  ## MCP
249
327
 
250
- Tools: `fullstackgtm_audit`, `fullstackgtm_rules`, `fullstackgtm_apply`
251
- (requires explicit `approvedOperationIds`), `fullstackgtm_suggest`,
252
- `fullstackgtm_call_parse`, `fullstackgtm_resolve`,
328
+ Tools: `fullstackgtm_capabilities` (the machine-readable server contract —
329
+ tool inventory with per-tool CRM-write flags, derived from the server's own
330
+ registration table), `fullstackgtm_audit`, `fullstackgtm_rules`,
331
+ `fullstackgtm_apply` (requires explicit `approvedOperationIds`),
332
+ `fullstackgtm_suggest`, `fullstackgtm_call_parse`, `fullstackgtm_resolve`,
253
333
  `fullstackgtm_market_worksheet`, `fullstackgtm_market_observe` (validates,
254
334
  verifies quoted spans against the stored captures, appends, returns front
255
335
  states). Input schemas are stable.