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.
@@ -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
- const r = mini ? 3 + p.loud * 0.8 : 6 + p.loud * 1.6;
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 &#8733; 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 &#8733; 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
- loud: loudCounts.get(vendorId) ?? 0,
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=&#189;) of the claims it
173
- voices. Dot size = LOUD count. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
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.19.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