fullstackgtm 0.19.0 → 0.20.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,18 @@ 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
+ const sizeOf = (vendorId) => useScale ? scaleIndex.get(vendorId) : (loudCounts.get(vendorId) ?? 0) / maxLoud;
135
+ const sizeCaption = useScale
136
+ ? `Dot area &#8733; relative scale index (within this vendor set, from: ${e(scale.metricsUsed.join(", "))} — citable signals, not true market share)`
137
+ : "Dot area &#8733; LOUD count";
125
138
  const breadthAxis = {
126
139
  id: "breadth",
127
140
  label: "Message breadth",
@@ -158,7 +171,7 @@ function axisSectionsHtml(config, set) {
158
171
  name: vendorNames.get(vendorId) ?? vendorId,
159
172
  x: xs.get(vendorId),
160
173
  y: ys.get(vendorId),
161
- loud: loudCounts.get(vendorId) ?? 0,
174
+ size: sizeOf(vendorId),
162
175
  }));
163
176
  };
164
177
  const [px, py] = config.primaryAxes ?? [axes[0].id, axes[1]?.id ?? "breadth"];
@@ -170,7 +183,7 @@ function axisSectionsHtml(config, set) {
170
183
  <figure>${svgScatter(pointsFor(px, py), axInfo, ayInfo, config.anchorVendor, false)}
171
184
  <figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
172
185
  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>
186
+ voices. ${sizeCaption}. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
174
187
  </figure>
175
188
  </section>`;
176
189
  // Deliberately no axis-pairing gallery here: the report is the client-facing
@@ -0,0 +1,42 @@
1
+ import type { MarketConfig, ScaleSignal } from "./market.ts";
2
+ /**
3
+ * Relative scale index over the mapped vendor set — the honest version of
4
+ * "bubble size = market share". True segment market share is unknowable from
5
+ * public data for mostly-private vendor sets, so this computes a composite
6
+ * index from whatever citable signals exist per vendor (review counts,
7
+ * headcount, disclosed revenue, self-reported customers), each of which is
8
+ * biased in a different direction; the composite triangulates.
9
+ *
10
+ * Method, deterministic and auditable:
11
+ * 1. Per metric, log10(value + 1) — these signals span orders of magnitude.
12
+ * 2. Normalize each metric to [0, 1] across the vendors that HAVE it
13
+ * (min–max within the set; a metric only one vendor has is skipped —
14
+ * it cannot rank anyone).
15
+ * 3. A vendor's index = arithmetic mean of its normalized metric scores
16
+ * (mean-of-normalized rather than geometric-of-raw so missing signals
17
+ * neither punish nor reward), reported with coverage (which metrics).
18
+ *
19
+ * Vendors with zero signals get index null — the report falls back to its
20
+ * LOUD-count sizing for the whole map rather than mixing semantics.
21
+ */
22
+ export type VendorScale = {
23
+ vendorId: string;
24
+ /** [0, 1] within the mapped set; null when the vendor has no usable signals. */
25
+ index: number | null;
26
+ /** Metrics that contributed, with their normalized scores. */
27
+ coverage: Array<{
28
+ metric: string;
29
+ value: number;
30
+ normalized: number;
31
+ }>;
32
+ signals: ScaleSignal[];
33
+ };
34
+ export type ScaleReport = {
35
+ vendors: VendorScale[];
36
+ /** Metrics used (present for ≥2 vendors) and metrics skipped (singletons). */
37
+ metricsUsed: string[];
38
+ metricsSkipped: string[];
39
+ complete: boolean;
40
+ };
41
+ export declare function computeScaleIndex(config: MarketConfig): ScaleReport;
42
+ export declare function scaleReportToText(config: MarketConfig, report: ScaleReport): string;
@@ -0,0 +1,68 @@
1
+ export function computeScaleIndex(config) {
2
+ const byMetric = new Map();
3
+ for (const vendor of config.vendors) {
4
+ for (const signal of vendor.scaleSignals ?? []) {
5
+ if (!Number.isFinite(signal.value) || signal.value < 0)
6
+ continue;
7
+ const rows = byMetric.get(signal.metric) ?? [];
8
+ rows.push({ vendorId: vendor.id, value: signal.value });
9
+ byMetric.set(signal.metric, rows);
10
+ }
11
+ }
12
+ const metricsUsed = [];
13
+ const metricsSkipped = [];
14
+ const normalized = new Map();
15
+ for (const [metric, rows] of byMetric) {
16
+ // Last write wins if a vendor lists the same metric twice.
17
+ const perVendor = new Map(rows.map((row) => [row.vendorId, row.value]));
18
+ if (perVendor.size < 2) {
19
+ metricsSkipped.push(metric);
20
+ continue;
21
+ }
22
+ metricsUsed.push(metric);
23
+ const logs = new Map([...perVendor].map(([vendorId, value]) => [vendorId, Math.log10(value + 1)]));
24
+ const values = [...logs.values()];
25
+ const lo = Math.min(...values);
26
+ const hi = Math.max(...values);
27
+ const span = hi - lo || 1;
28
+ const scores = new Map();
29
+ for (const [vendorId, log] of logs) {
30
+ scores.set(vendorId, { value: perVendor.get(vendorId), normalized: (log - lo) / span });
31
+ }
32
+ normalized.set(metric, scores);
33
+ }
34
+ metricsUsed.sort();
35
+ metricsSkipped.sort();
36
+ const vendors = config.vendors.map((vendor) => {
37
+ const coverage = [];
38
+ for (const metric of metricsUsed) {
39
+ const score = normalized.get(metric)?.get(vendor.id);
40
+ if (score)
41
+ coverage.push({ metric, value: score.value, normalized: Number(score.normalized.toFixed(4)) });
42
+ }
43
+ const index = coverage.length > 0
44
+ ? Number((coverage.reduce((sum, entry) => sum + entry.normalized, 0) / coverage.length).toFixed(4))
45
+ : null;
46
+ return { vendorId: vendor.id, index, coverage, signals: vendor.scaleSignals ?? [] };
47
+ });
48
+ return {
49
+ vendors,
50
+ metricsUsed,
51
+ metricsSkipped,
52
+ complete: vendors.every((vendor) => vendor.index !== null),
53
+ };
54
+ }
55
+ export function scaleReportToText(config, report) {
56
+ const names = new Map(config.vendors.map((vendor) => [vendor.id, vendor.name]));
57
+ const lines = [];
58
+ lines.push(`Scale index (relative, within this ${config.vendors.length}-vendor set — not market share):`);
59
+ lines.push(`metrics used: ${report.metricsUsed.join(", ") || "none"}${report.metricsSkipped.length ? ` · skipped (single-vendor): ${report.metricsSkipped.join(", ")}` : ""}`);
60
+ lines.push("");
61
+ const ranked = [...report.vendors].sort((a, b) => (b.index ?? -1) - (a.index ?? -1));
62
+ for (const vendor of ranked) {
63
+ const idx = vendor.index === null ? " n/a" : vendor.index.toFixed(2);
64
+ const cov = vendor.coverage.map((entry) => `${entry.metric}=${entry.value}`).join(", ") || "no signals";
65
+ lines.push(` ${idx} ${(names.get(vendor.vendorId) ?? vendor.vendorId).padEnd(22)} ${cov}`);
66
+ }
67
+ return `${lines.join("\n")}\n`;
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.19.0",
3
+ "version": "0.20.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,21 @@ 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 { computeScaleIndex, scaleReportToText, type ScaleReport, type VendorScale } from "./marketScale.ts";
185
201
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
186
202
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
187
203
  export type {
package/src/market.ts CHANGED
@@ -38,6 +38,31 @@ 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;
41
66
  };
42
67
 
43
68
  export type MarketVendor = {
@@ -49,6 +74,10 @@ export type MarketVendor = {
49
74
  pricing: string | null;
50
75
  product: string[];
51
76
  };
77
+ /** Alternate names/spellings for deterministic mention matching. */
78
+ aliases?: string[];
79
+ /** Public scale signals; see ScaleSignal. */
80
+ scaleSignals?: ScaleSignal[];
52
81
  notes?: string;
53
82
  };
54
83