fullstackgtm 0.19.0 → 0.21.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 +69 -0
- package/dist/cli.js +89 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/market.d.ts +42 -0
- package/dist/marketOverlay.d.ts +116 -0
- package/dist/marketOverlay.js +258 -0
- package/dist/marketReport.js +19 -3
- package/dist/marketScale.d.ts +71 -0
- package/dist/marketScale.js +168 -0
- package/package.json +1 -1
- package/src/cli.ts +108 -1
- package/src/index.ts +24 -0
- package/src/market.ts +43 -0
- package/src/marketOverlay.ts +410 -0
- package/src/marketReport.ts +23 -4
- package/src/marketScale.ts +251 -0
package/dist/marketReport.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { computeFrontStates } from "./market.js";
|
|
2
2
|
import { assessAxes, messageBreadth } from "./marketAxes.js";
|
|
3
|
+
import { computeScaleIndex } from "./marketScale.js";
|
|
3
4
|
/**
|
|
4
5
|
* Render a market map as a client-ready deliverable: markdown for terminals
|
|
5
6
|
* and PRs, and a self-contained printable HTML "field report" — front
|
|
@@ -98,7 +99,8 @@ function svgScatter(points, ax, ay, anchor, mini) {
|
|
|
98
99
|
const e = escapeHtml;
|
|
99
100
|
const dots = points
|
|
100
101
|
.map((p) => {
|
|
101
|
-
|
|
102
|
+
// Area-proportional: perceived bubble area tracks the size metric.
|
|
103
|
+
const r = (mini ? 4 + 14 * Math.sqrt(p.size) : 8 + 26 * Math.sqrt(p.size));
|
|
102
104
|
const cls = p.vendorId === anchor ? "dot-anchor" : "dot";
|
|
103
105
|
return (`<circle class="${cls}" cx="${sx(p.x).toFixed(1)}" cy="${sy(p.y).toFixed(1)}" r="${r.toFixed(1)}"/>` +
|
|
104
106
|
`<text class="dot-label" style="font-size:${fsLabel}px" x="${sx(p.x).toFixed(1)}" y="${(sy(p.y) - r - 4).toFixed(1)}">${e(p.name)}</text>`);
|
|
@@ -121,7 +123,21 @@ function axisSectionsHtml(config, set) {
|
|
|
121
123
|
const e = escapeHtml;
|
|
122
124
|
const report = assessAxes(config, set);
|
|
123
125
|
const vendorNames = new Map(config.vendors.map((vendor) => [vendor.id, vendor.name]));
|
|
126
|
+
// Bubble size: scale index (relative market scale from citable signals)
|
|
127
|
+
// when every placeable vendor has one; LOUD count otherwise — never mix
|
|
128
|
+
// the two semantics on one chart.
|
|
129
|
+
const scale = computeScaleIndex(config);
|
|
130
|
+
const scaleIndex = new Map(scale.vendors.map((vendor) => [vendor.vendorId, vendor.index]));
|
|
131
|
+
const useScale = report.vendors.length > 0 && report.vendors.every((vendorId) => scaleIndex.get(vendorId) !== null && scaleIndex.get(vendorId) !== undefined);
|
|
124
132
|
const loudCounts = new Map(report.vendors.map((vendorId) => [vendorId, messageBreadth(vendorId, set.observations).loudCount]));
|
|
133
|
+
const maxLoud = Math.max(1, ...loudCounts.values());
|
|
134
|
+
// Bubble areas stay proportional to the metric; dividing by the max just
|
|
135
|
+
// spends the full visual range without distorting any ratio.
|
|
136
|
+
const maxShare = Math.max(1e-9, ...report.vendors.map((vendorId) => scaleIndex.get(vendorId) ?? 0));
|
|
137
|
+
const sizeOf = (vendorId) => useScale ? scaleIndex.get(vendorId) / maxShare : (loudCounts.get(vendorId) ?? 0) / maxLoud;
|
|
138
|
+
const sizeCaption = useScale
|
|
139
|
+
? `Dot area ∝ estimated revenue share of this vendor set (signals: ${e(scale.metricsUsed.join(", "))}; calibrated within-set, ACV-band stratified, citable but NOT audited — see \`market scale\` for estimates and spreads)`
|
|
140
|
+
: "Dot area ∝ LOUD count";
|
|
125
141
|
const breadthAxis = {
|
|
126
142
|
id: "breadth",
|
|
127
143
|
label: "Message breadth",
|
|
@@ -158,7 +174,7 @@ function axisSectionsHtml(config, set) {
|
|
|
158
174
|
name: vendorNames.get(vendorId) ?? vendorId,
|
|
159
175
|
x: xs.get(vendorId),
|
|
160
176
|
y: ys.get(vendorId),
|
|
161
|
-
|
|
177
|
+
size: sizeOf(vendorId),
|
|
162
178
|
}));
|
|
163
179
|
};
|
|
164
180
|
const [px, py] = config.primaryAxes ?? [axes[0].id, axes[1]?.id ?? "breadth"];
|
|
@@ -170,7 +186,7 @@ function axisSectionsHtml(config, set) {
|
|
|
170
186
|
<figure>${svgScatter(pointsFor(px, py), axInfo, ayInfo, config.anchorVendor, false)}
|
|
171
187
|
<figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
|
|
172
188
|
in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=½) of the claims it
|
|
173
|
-
voices.
|
|
189
|
+
voices. ${sizeCaption}. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
|
|
174
190
|
</figure>
|
|
175
191
|
</section>`;
|
|
176
192
|
// Deliberately no axis-pairing gallery here: the report is the client-facing
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { MarketConfig, ScaleSignal } from "./market.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Relative scale estimation over the mapped vendor set — v2, dimensional.
|
|
4
|
+
*
|
|
5
|
+
* v1 normalized every signal onto [0,1] and averaged, which quietly mixed
|
|
6
|
+
* dimensions: review/customer counts proxy CUSTOMER COUNT (N), while
|
|
7
|
+
* employees and revenue proxy REVENUE (N × ACV). Averaging the two inflates
|
|
8
|
+
* many-small-customer vendors against few-big-customer ones — the SMB bias.
|
|
9
|
+
*
|
|
10
|
+
* v2 converts every signal into REVENUE SPACE before combining:
|
|
11
|
+
*
|
|
12
|
+
* 1. Signals are classed by dimension: revenue (used directly),
|
|
13
|
+
* headcount (× revenue-per-employee), customers (× revenue-per-customer).
|
|
14
|
+
* 2. Conversion ratios are CALIBRATED within the set, per metric, as the
|
|
15
|
+
* median ratio over vendors that have both the metric and a revenue
|
|
16
|
+
* signal — and customer-dimension ratios are stratified by each
|
|
17
|
+
* vendor's `acvBand` (smb / mid / enterprise), because revenue-per-
|
|
18
|
+
* review spans ~75× between SMB tools and enterprise suites. A band
|
|
19
|
+
* without calibration pairs falls back to the global median; a metric
|
|
20
|
+
* with no pairs anywhere is unusable and reported as skipped.
|
|
21
|
+
* 3. A vendor's estimated revenue is the weighted geometric mean of its
|
|
22
|
+
* per-signal estimates (revenue weight 3, headcount 2, customers 1 —
|
|
23
|
+
* reliability order), with an uncertainty band = max/min estimate
|
|
24
|
+
* ratio, reported, never hidden.
|
|
25
|
+
* 4. index = share of the set's summed estimated revenue; bubbles render
|
|
26
|
+
* area-proportional to it. Labeled "estimated revenue share" with the
|
|
27
|
+
* calibration disclosed — still never "market share" unqualified:
|
|
28
|
+
* it is revenue share OF THE MAPPED SET, from citable-but-unaudited
|
|
29
|
+
* signals.
|
|
30
|
+
*
|
|
31
|
+
* Deterministic and auditable end to end: same config, same estimates.
|
|
32
|
+
*/
|
|
33
|
+
export type ScaleDimension = "revenue" | "headcount" | "customers";
|
|
34
|
+
export declare function dimensionForMetric(metric: string): ScaleDimension;
|
|
35
|
+
export type SignalEstimate = {
|
|
36
|
+
metric: string;
|
|
37
|
+
dimension: ScaleDimension;
|
|
38
|
+
rawValue: number;
|
|
39
|
+
/** Revenue-per-unit ratio applied (1 for revenue signals); null = unusable. */
|
|
40
|
+
ratio: number | null;
|
|
41
|
+
estimatedRevenue: number | null;
|
|
42
|
+
/** What calibrated the ratio: "direct", "band:<name>", "global", "fallback". */
|
|
43
|
+
calibration: string;
|
|
44
|
+
};
|
|
45
|
+
export type VendorScale = {
|
|
46
|
+
vendorId: string;
|
|
47
|
+
acvBand?: string;
|
|
48
|
+
estimates: SignalEstimate[];
|
|
49
|
+
/** Weighted geometric mean of usable estimates; null with no usable signals. */
|
|
50
|
+
estimatedRevenue: number | null;
|
|
51
|
+
/** max/min across usable estimates — 1 = perfect agreement among signals. */
|
|
52
|
+
uncertainty: number | null;
|
|
53
|
+
/** Share of the set's summed estimated revenue; drives bubble area. */
|
|
54
|
+
index: number | null;
|
|
55
|
+
signals: ScaleSignal[];
|
|
56
|
+
};
|
|
57
|
+
export type ScaleReport = {
|
|
58
|
+
vendors: VendorScale[];
|
|
59
|
+
metricsUsed: string[];
|
|
60
|
+
metricsSkipped: string[];
|
|
61
|
+
/** Calibrated ratios for the appendix: metric × stratum → revenue-per-unit. */
|
|
62
|
+
calibrations: Array<{
|
|
63
|
+
metric: string;
|
|
64
|
+
stratum: string;
|
|
65
|
+
revenuePerUnit: number;
|
|
66
|
+
pairs: number;
|
|
67
|
+
}>;
|
|
68
|
+
complete: boolean;
|
|
69
|
+
};
|
|
70
|
+
export declare function computeScaleIndex(config: MarketConfig): ScaleReport;
|
|
71
|
+
export declare function scaleReportToText(config: MarketConfig, report: ScaleReport): string;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const DIMENSION_WEIGHT = { revenue: 3, headcount: 2, customers: 1 };
|
|
2
|
+
/** Used only when headcount has zero calibration pairs in the whole set. */
|
|
3
|
+
const FALLBACK_REVENUE_PER_EMPLOYEE = 200_000;
|
|
4
|
+
export function dimensionForMetric(metric) {
|
|
5
|
+
const name = metric.toLowerCase();
|
|
6
|
+
if (name.includes("revenue") || name.includes("arr"))
|
|
7
|
+
return "revenue";
|
|
8
|
+
if (name.includes("employee") || name.includes("headcount"))
|
|
9
|
+
return "headcount";
|
|
10
|
+
return "customers"; // reviews, customers, installs — count-of-customers proxies
|
|
11
|
+
}
|
|
12
|
+
function median(values) {
|
|
13
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
14
|
+
const mid = Math.floor(sorted.length / 2);
|
|
15
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
16
|
+
}
|
|
17
|
+
export function computeScaleIndex(config) {
|
|
18
|
+
const rows = [];
|
|
19
|
+
for (const vendor of config.vendors) {
|
|
20
|
+
for (const signal of vendor.scaleSignals ?? []) {
|
|
21
|
+
if (!Number.isFinite(signal.value) || signal.value <= 0)
|
|
22
|
+
continue;
|
|
23
|
+
rows.push({
|
|
24
|
+
vendorId: vendor.id,
|
|
25
|
+
band: vendor.acvBand ?? "unknown",
|
|
26
|
+
metric: signal.metric,
|
|
27
|
+
dimension: signal.dimension ?? dimensionForMetric(signal.metric),
|
|
28
|
+
value: signal.value,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// A vendor's reference revenue for calibration: median of its revenue signals.
|
|
33
|
+
const revenueByVendor = new Map();
|
|
34
|
+
for (const vendor of config.vendors) {
|
|
35
|
+
const revenues = rows
|
|
36
|
+
.filter((row) => row.vendorId === vendor.id && row.dimension === "revenue")
|
|
37
|
+
.map((row) => row.value);
|
|
38
|
+
if (revenues.length > 0)
|
|
39
|
+
revenueByVendor.set(vendor.id, median(revenues));
|
|
40
|
+
}
|
|
41
|
+
// Per-metric calibration. Customer-dimension metrics stratify by acvBand;
|
|
42
|
+
// headcount calibrates globally (revenue-per-employee is the most stable
|
|
43
|
+
// ratio in B2B software, which is also why headcount outweighs customers).
|
|
44
|
+
const calibrations = [];
|
|
45
|
+
const ratioFor = new Map();
|
|
46
|
+
const nonRevenueMetrics = [...new Set(rows.filter((row) => row.dimension !== "revenue").map((row) => row.metric))].sort();
|
|
47
|
+
for (const metric of nonRevenueMetrics) {
|
|
48
|
+
const pairs = rows
|
|
49
|
+
.filter((row) => row.metric === metric && revenueByVendor.has(row.vendorId))
|
|
50
|
+
.map((row) => ({ band: row.band, ratio: revenueByVendor.get(row.vendorId) / row.value }));
|
|
51
|
+
const byBand = new Map();
|
|
52
|
+
if (dimensionForMetric(metric) === "customers") {
|
|
53
|
+
for (const band of [...new Set(pairs.map((pair) => pair.band))]) {
|
|
54
|
+
const bandRatios = pairs.filter((pair) => pair.band === band).map((pair) => pair.ratio);
|
|
55
|
+
if (bandRatios.length >= 1) {
|
|
56
|
+
byBand.set(band, median(bandRatios));
|
|
57
|
+
calibrations.push({ metric, stratum: `band:${band}`, revenuePerUnit: Math.round(median(bandRatios)), pairs: bandRatios.length });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const global = pairs.length > 0 ? median(pairs.map((pair) => pair.ratio)) : null;
|
|
62
|
+
if (global !== null)
|
|
63
|
+
calibrations.push({ metric, stratum: "global", revenuePerUnit: Math.round(global), pairs: pairs.length });
|
|
64
|
+
ratioFor.set(metric, { global, byBand });
|
|
65
|
+
}
|
|
66
|
+
const metricsUsed = new Set();
|
|
67
|
+
const metricsSkipped = new Set();
|
|
68
|
+
const vendors = config.vendors.map((vendor) => {
|
|
69
|
+
const vendorRows = rows.filter((row) => row.vendorId === vendor.id);
|
|
70
|
+
const estimates = vendorRows.map((row) => {
|
|
71
|
+
if (row.dimension === "revenue") {
|
|
72
|
+
metricsUsed.add(row.metric);
|
|
73
|
+
return { metric: row.metric, dimension: row.dimension, rawValue: row.value, ratio: 1, estimatedRevenue: row.value, calibration: "direct" };
|
|
74
|
+
}
|
|
75
|
+
const calibration = ratioFor.get(row.metric);
|
|
76
|
+
let ratio = null;
|
|
77
|
+
let stratum = "fallback";
|
|
78
|
+
if (calibration) {
|
|
79
|
+
if (row.dimension === "customers" && calibration.byBand.has(row.band)) {
|
|
80
|
+
ratio = calibration.byBand.get(row.band);
|
|
81
|
+
stratum = `band:${row.band}`;
|
|
82
|
+
}
|
|
83
|
+
else if (calibration.global !== null) {
|
|
84
|
+
ratio = calibration.global;
|
|
85
|
+
stratum = "global";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (ratio === null && row.dimension === "headcount") {
|
|
89
|
+
ratio = FALLBACK_REVENUE_PER_EMPLOYEE;
|
|
90
|
+
stratum = "fallback";
|
|
91
|
+
}
|
|
92
|
+
if (ratio === null) {
|
|
93
|
+
metricsSkipped.add(row.metric);
|
|
94
|
+
return { metric: row.metric, dimension: row.dimension, rawValue: row.value, ratio: null, estimatedRevenue: null, calibration: "uncalibratable" };
|
|
95
|
+
}
|
|
96
|
+
metricsUsed.add(row.metric);
|
|
97
|
+
return { metric: row.metric, dimension: row.dimension, rawValue: row.value, ratio, estimatedRevenue: row.value * ratio, calibration: stratum };
|
|
98
|
+
});
|
|
99
|
+
const usable = estimates.filter((estimate) => estimate.estimatedRevenue !== null);
|
|
100
|
+
let estimatedRevenue = null;
|
|
101
|
+
let uncertainty = null;
|
|
102
|
+
if (usable.length > 0) {
|
|
103
|
+
let weightSum = 0;
|
|
104
|
+
let logSum = 0;
|
|
105
|
+
for (const estimate of usable) {
|
|
106
|
+
const weight = DIMENSION_WEIGHT[estimate.dimension];
|
|
107
|
+
weightSum += weight;
|
|
108
|
+
logSum += weight * Math.log(estimate.estimatedRevenue);
|
|
109
|
+
}
|
|
110
|
+
estimatedRevenue = Math.exp(logSum / weightSum);
|
|
111
|
+
const values = usable.map((estimate) => estimate.estimatedRevenue);
|
|
112
|
+
uncertainty = Number((Math.max(...values) / Math.min(...values)).toFixed(2));
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
vendorId: vendor.id,
|
|
116
|
+
acvBand: vendor.acvBand,
|
|
117
|
+
estimates,
|
|
118
|
+
estimatedRevenue,
|
|
119
|
+
uncertainty,
|
|
120
|
+
index: null,
|
|
121
|
+
signals: vendor.scaleSignals ?? [],
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
const total = vendors.reduce((sum, vendor) => sum + (vendor.estimatedRevenue ?? 0), 0);
|
|
125
|
+
for (const vendor of vendors) {
|
|
126
|
+
vendor.index = vendor.estimatedRevenue !== null && total > 0 ? Number((vendor.estimatedRevenue / total).toFixed(4)) : null;
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
vendors,
|
|
130
|
+
metricsUsed: [...metricsUsed].sort(),
|
|
131
|
+
metricsSkipped: [...metricsSkipped].filter((metric) => !metricsUsed.has(metric)).sort(),
|
|
132
|
+
calibrations,
|
|
133
|
+
complete: vendors.every((vendor) => vendor.index !== null),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function money(value) {
|
|
137
|
+
if (value >= 1e9)
|
|
138
|
+
return `$${(value / 1e9).toFixed(1)}B`;
|
|
139
|
+
if (value >= 1e6)
|
|
140
|
+
return `$${(value / 1e6).toFixed(1)}M`;
|
|
141
|
+
return `$${Math.round(value / 1e3)}K`;
|
|
142
|
+
}
|
|
143
|
+
export function scaleReportToText(config, report) {
|
|
144
|
+
const names = new Map(config.vendors.map((vendor) => [vendor.id, vendor.name]));
|
|
145
|
+
const lines = [];
|
|
146
|
+
lines.push(`Estimated revenue share (of this ${config.vendors.length}-vendor set; calibrated from citable signals, NOT audited):`);
|
|
147
|
+
lines.push(`metrics: ${report.metricsUsed.join(", ") || "none"}${report.metricsSkipped.length ? ` · uncalibratable: ${report.metricsSkipped.join(", ")}` : ""}`);
|
|
148
|
+
lines.push("");
|
|
149
|
+
const ranked = [...report.vendors].sort((a, b) => (b.estimatedRevenue ?? -1) - (a.estimatedRevenue ?? -1));
|
|
150
|
+
for (const vendor of ranked) {
|
|
151
|
+
if (vendor.estimatedRevenue === null) {
|
|
152
|
+
lines.push(` n/a ${(names.get(vendor.vendorId) ?? vendor.vendorId).padEnd(22)} no usable signals`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const share = `${((vendor.index ?? 0) * 100).toFixed(1)}%`.padStart(6);
|
|
156
|
+
const spread = vendor.uncertainty !== null && vendor.uncertainty > 1 ? ` (×${vendor.uncertainty.toFixed(1)} signal spread)` : "";
|
|
157
|
+
lines.push(`${share} ${(names.get(vendor.vendorId) ?? vendor.vendorId).padEnd(22)} ~${money(vendor.estimatedRevenue)}${spread} [${vendor.estimates
|
|
158
|
+
.filter((estimate) => estimate.estimatedRevenue !== null)
|
|
159
|
+
.map((estimate) => `${estimate.metric}→${money(estimate.estimatedRevenue)}`)
|
|
160
|
+
.join(", ")}]`);
|
|
161
|
+
}
|
|
162
|
+
lines.push("");
|
|
163
|
+
lines.push("calibrations (median revenue-per-unit):");
|
|
164
|
+
for (const calibration of report.calibrations) {
|
|
165
|
+
lines.push(` ${calibration.metric.padEnd(26)} ${calibration.stratum.padEnd(16)} ${money(calibration.revenuePerUnit)}/unit (${calibration.pairs} pair${calibration.pairs === 1 ? "" : "s"})`);
|
|
166
|
+
}
|
|
167
|
+
return `${lines.join("\n")}\n`;
|
|
168
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM",
|
package/src/cli.ts
CHANGED
|
@@ -52,6 +52,14 @@ import {
|
|
|
52
52
|
type ObservationSet,
|
|
53
53
|
} from "./market.ts";
|
|
54
54
|
import { assessAxes, axesReportToText } from "./marketAxes.ts";
|
|
55
|
+
import {
|
|
56
|
+
computeDirectives,
|
|
57
|
+
computeOverlayStats,
|
|
58
|
+
directivesToPlan,
|
|
59
|
+
overlayToMarkdown,
|
|
60
|
+
type CallDocument,
|
|
61
|
+
} from "./marketOverlay.ts";
|
|
62
|
+
import { computeScaleIndex, scaleReportToText } from "./marketScale.ts";
|
|
55
63
|
import { buildWorksheet, classifyMarket } from "./marketClassify.ts";
|
|
56
64
|
import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
|
|
57
65
|
import {
|
|
@@ -118,6 +126,8 @@ Usage:
|
|
|
118
126
|
fullstackgtm market fronts [--run <label>] [--diff <prior-run>] [--json]
|
|
119
127
|
fullstackgtm market axes [--run <label>] [--json]
|
|
120
128
|
fullstackgtm market report [--run <label>] [--format md|html] [--out <path>]
|
|
129
|
+
fullstackgtm market overlay --snapshot <crm.json> [--calls <files>] [--save]
|
|
130
|
+
fullstackgtm market scale [--json]
|
|
121
131
|
fullstackgtm market refresh [--run <label>] [--model m]
|
|
122
132
|
the live competitive map: capture vendor pages (content-addressed),
|
|
123
133
|
classify intensity per claim (LLM bring-your-own-key, or fill the
|
|
@@ -875,9 +885,24 @@ market worksheet --vendor <id> [--capture-run <label>] [--out <path>]
|
|
|
875
885
|
market observe --from <observations.json> [--unverified]
|
|
876
886
|
market fronts [--config <path>] [--run <label>] [--diff <prior-run>] [--json]
|
|
877
887
|
market axes [--config <path>] [--run <label>] [--json]
|
|
888
|
+
market overlay --snapshot <crm.json> [--calls <parsed.json|manifest.json>]... [--prior-run <label>]
|
|
889
|
+
[--min-mentions N] [--promote-lift X] [--json] [--save --task-account <id>|--task-deal <id>]
|
|
890
|
+
market scale [--config <path>] [--json]
|
|
878
891
|
market report [--config <path>] [--run <label>] [--format md|html] [--out <path>]
|
|
879
892
|
market refresh [--run <label>] [--model m] capture → classify → fronts drift → HTML report
|
|
880
893
|
|
|
894
|
+
overlay is the directive layer: joins the map to YOUR CRM ground truth and
|
|
895
|
+
emits OCCUPY / PROMOTE / URGENT / RETREAT directives, each carrying ≥1
|
|
896
|
+
observation and ≥1 CRM statistic with its sample size. Claim mentions are
|
|
897
|
+
deterministic word-boundary matches of each claim's "terms" against call
|
|
898
|
+
documents (call parse output); small samples refuse to become strategy
|
|
899
|
+
(--min-mentions, default 3). --save turns directives into approval-gated
|
|
900
|
+
create_task operations through the normal plans → approve → apply gate.
|
|
901
|
+
|
|
902
|
+
scale prints the relative scale index that sizes the report's bubbles when
|
|
903
|
+
vendors carry scaleSignals (citable review counts / headcount / revenue —
|
|
904
|
+
a within-set index, never "market share" unqualified).
|
|
905
|
+
|
|
881
906
|
axes runs the axis-discovery math: PCA over the vendor × claim intensity
|
|
882
907
|
matrix (PC1 = the category's primary axis, PC2 = the max-differentiation
|
|
883
908
|
direction orthogonal to it), triangulation of configured axes against the
|
|
@@ -1089,8 +1114,90 @@ recomputed deterministically on every invocation — never stored.`);
|
|
|
1089
1114
|
return;
|
|
1090
1115
|
}
|
|
1091
1116
|
|
|
1117
|
+
if (subcommand === "scale") {
|
|
1118
|
+
const report = computeScaleIndex(config);
|
|
1119
|
+
if (rest.includes("--json")) {
|
|
1120
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
console.log(scaleReportToText(config, report));
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
if (subcommand === "overlay") {
|
|
1128
|
+
const set = await loadSet();
|
|
1129
|
+
const snapshotPath = option(rest, "--snapshot");
|
|
1130
|
+
if (!snapshotPath) {
|
|
1131
|
+
throw new Error(
|
|
1132
|
+
"market overlay requires --snapshot <canonical-snapshot.json> (fullstackgtm snapshot --out it first) — directives need CRM ground truth",
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
const snapshot = JSON.parse(readFileSync(resolve(process.cwd(), snapshotPath), "utf8")) as CanonicalGtmSnapshot;
|
|
1136
|
+
|
|
1137
|
+
// --calls accepts ParsedCall JSON files (from `call parse --out`) and/or
|
|
1138
|
+
// manifest arrays [{path, dealId?}] linking calls to deals. Repeatable.
|
|
1139
|
+
const documents: CallDocument[] = [];
|
|
1140
|
+
const addParsedCall = (parsedPath: string, dealId?: string) => {
|
|
1141
|
+
const parsed = JSON.parse(readFileSync(resolve(process.cwd(), parsedPath), "utf8")) as ParsedCall & {
|
|
1142
|
+
segments?: Array<{ text?: string }>;
|
|
1143
|
+
};
|
|
1144
|
+
const text = [
|
|
1145
|
+
...(parsed.segments ?? []).map((segment) => segment.text ?? ""),
|
|
1146
|
+
...(parsed.insights ?? []).map((insight) => `${insight.text ?? ""} ${insight.evidence ?? ""}`),
|
|
1147
|
+
].join("\n");
|
|
1148
|
+
documents.push({ id: parsed.id ?? parsedPath, text, dealId, occurredAt: parsed.evidence?.[0]?.capturedAt });
|
|
1149
|
+
};
|
|
1150
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
1151
|
+
if (rest[i] !== "--calls") continue;
|
|
1152
|
+
const callsPath = rest[i + 1];
|
|
1153
|
+
if (!callsPath) throw new Error("--calls needs a path");
|
|
1154
|
+
const raw = JSON.parse(readFileSync(resolve(process.cwd(), callsPath), "utf8"));
|
|
1155
|
+
if (Array.isArray(raw)) {
|
|
1156
|
+
for (const entry of raw as Array<{ path: string; dealId?: string }>) addParsedCall(entry.path, entry.dealId);
|
|
1157
|
+
} else {
|
|
1158
|
+
addParsedCall(callsPath);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const priorLabel = option(rest, "--prior-run");
|
|
1163
|
+
const priorSet = priorLabel ? await store.get(priorLabel) : null;
|
|
1164
|
+
if (priorLabel && !priorSet) throw new Error(`No observation run "${priorLabel}" for URGENT drift`);
|
|
1165
|
+
|
|
1166
|
+
const stats = computeOverlayStats(config, snapshot, documents);
|
|
1167
|
+
const directives = computeDirectives(config, set, stats, {
|
|
1168
|
+
minMentions: numericOption(rest, "--min-mentions") ?? undefined,
|
|
1169
|
+
promoteLift: numericOption(rest, "--promote-lift") ?? undefined,
|
|
1170
|
+
priorSet: priorSet ?? undefined,
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
if (rest.includes("--json")) {
|
|
1174
|
+
console.log(JSON.stringify({ stats, directives }, null, 2));
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
console.log(overlayToMarkdown(stats, directives));
|
|
1178
|
+
|
|
1179
|
+
if (rest.includes("--save")) {
|
|
1180
|
+
const taskAccount = option(rest, "--task-account");
|
|
1181
|
+
const taskDeal = option(rest, "--task-deal");
|
|
1182
|
+
if (!taskAccount && !taskDeal) {
|
|
1183
|
+
throw new Error(
|
|
1184
|
+
"--save needs --task-account <id> or --task-deal <id>: directives become approval-gated create_task operations, and the CRM needs a record to hang them on (your own company's account record works well)",
|
|
1185
|
+
);
|
|
1186
|
+
}
|
|
1187
|
+
const plan = directivesToPlan(
|
|
1188
|
+
config,
|
|
1189
|
+
set,
|
|
1190
|
+
directives,
|
|
1191
|
+
taskDeal ? { objectType: "deal", objectId: taskDeal } : { objectType: "account", objectId: taskAccount as string },
|
|
1192
|
+
);
|
|
1193
|
+
const stored = await createFilePlanStore().save(plan);
|
|
1194
|
+
console.log(`Saved plan ${stored.plan.id} (${directives.length} directive task(s); approve via \`plans approve\`)`);
|
|
1195
|
+
}
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1092
1199
|
throw new Error(
|
|
1093
|
-
`Unknown market subcommand: ${subcommand} (try: init, capture, classify, worksheet, observe, fronts, axes, report, refresh)`,
|
|
1200
|
+
`Unknown market subcommand: ${subcommand} (try: init, capture, classify, worksheet, observe, fronts, axes, overlay, scale, report, refresh)`,
|
|
1094
1201
|
);
|
|
1095
1202
|
}
|
|
1096
1203
|
|
package/src/index.ts
CHANGED
|
@@ -161,6 +161,7 @@ export {
|
|
|
161
161
|
type ObservationConfidence,
|
|
162
162
|
type ObservationSet,
|
|
163
163
|
type ObservationStore,
|
|
164
|
+
type ScaleSignal,
|
|
164
165
|
type SpanVerificationFailure,
|
|
165
166
|
} from "./market.ts";
|
|
166
167
|
export {
|
|
@@ -182,6 +183,29 @@ export {
|
|
|
182
183
|
type ClassifyMarketResult,
|
|
183
184
|
type MarketWorksheet,
|
|
184
185
|
} from "./marketClassify.ts";
|
|
186
|
+
export {
|
|
187
|
+
computeDirectives,
|
|
188
|
+
computeOverlayStats,
|
|
189
|
+
directivesToPlan,
|
|
190
|
+
overlayToMarkdown,
|
|
191
|
+
type CallDocument,
|
|
192
|
+
type ClaimMentionStats,
|
|
193
|
+
type DirectiveStat,
|
|
194
|
+
type DirectiveType,
|
|
195
|
+
type MarketDirective,
|
|
196
|
+
type OverlayOptions,
|
|
197
|
+
type OverlayStats,
|
|
198
|
+
type VendorMentionStats,
|
|
199
|
+
} from "./marketOverlay.ts";
|
|
200
|
+
export {
|
|
201
|
+
computeScaleIndex,
|
|
202
|
+
dimensionForMetric,
|
|
203
|
+
scaleReportToText,
|
|
204
|
+
type ScaleDimension,
|
|
205
|
+
type ScaleReport,
|
|
206
|
+
type SignalEstimate,
|
|
207
|
+
type VendorScale,
|
|
208
|
+
} from "./marketScale.ts";
|
|
185
209
|
export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
|
|
186
210
|
export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
|
|
187
211
|
export type {
|
package/src/market.ts
CHANGED
|
@@ -38,6 +38,37 @@ export type MarketClaim = {
|
|
|
38
38
|
pricingStructure: string;
|
|
39
39
|
/** Operational definition: how a reader judges LOUD vs QUIET vs ABSENT. */
|
|
40
40
|
definition: string;
|
|
41
|
+
/**
|
|
42
|
+
* Exact terms buyers use for this claim, for deterministic mention
|
|
43
|
+
* matching against call transcripts (the overlay). No terms = no mention
|
|
44
|
+
* stats for this claim; matching is word-boundary, case-insensitive.
|
|
45
|
+
*/
|
|
46
|
+
terms?: string[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One public, citable scale signal for a vendor (G2 review count, LinkedIn
|
|
51
|
+
* headcount, disclosed revenue, self-reported customer count). The composite
|
|
52
|
+
* of several biased-in-different-directions signals sizes the report's
|
|
53
|
+
* bubbles — a RELATIVE scale index within the mapped set, never "market
|
|
54
|
+
* share" unqualified.
|
|
55
|
+
*/
|
|
56
|
+
export type ScaleSignal = {
|
|
57
|
+
/** e.g. "g2_reviews", "linkedin_employees", "revenue_usd", "self_reported_customers". */
|
|
58
|
+
metric: string;
|
|
59
|
+
value: number;
|
|
60
|
+
unit: string;
|
|
61
|
+
sourceUrl: string;
|
|
62
|
+
/** Verbatim snippet containing the number — same evidence posture as observations. */
|
|
63
|
+
quote: string;
|
|
64
|
+
asOf: string;
|
|
65
|
+
caveat?: string;
|
|
66
|
+
/**
|
|
67
|
+
* What the signal proxies: revenue (used directly), headcount, or
|
|
68
|
+
* customers (count-of-customers proxies like reviews). Inferred from the
|
|
69
|
+
* metric name when omitted; set explicitly for unusual metrics.
|
|
70
|
+
*/
|
|
71
|
+
dimension?: "revenue" | "headcount" | "customers";
|
|
41
72
|
};
|
|
42
73
|
|
|
43
74
|
export type MarketVendor = {
|
|
@@ -49,6 +80,18 @@ export type MarketVendor = {
|
|
|
49
80
|
pricing: string | null;
|
|
50
81
|
product: string[];
|
|
51
82
|
};
|
|
83
|
+
/** Alternate names/spellings for deterministic mention matching. */
|
|
84
|
+
aliases?: string[];
|
|
85
|
+
/** Public scale signals; see ScaleSignal. */
|
|
86
|
+
scaleSignals?: ScaleSignal[];
|
|
87
|
+
/**
|
|
88
|
+
* ACV stratum ("smb" | "mid" | "enterprise" by convention) used to
|
|
89
|
+
* calibrate customer-count → revenue conversion in the scale index.
|
|
90
|
+
* Revenue-per-customer differs ~75× between SMB tools and enterprise
|
|
91
|
+
* suites; stratifying kills the many-small-customers bias. Usually
|
|
92
|
+
* obvious from the vendor's own pricing page (which the map captures).
|
|
93
|
+
*/
|
|
94
|
+
acvBand?: string;
|
|
52
95
|
notes?: string;
|
|
53
96
|
};
|
|
54
97
|
|