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/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,75 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
5
5
|
and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
|
|
7
7
|
|
|
8
|
+
## [0.21.0] — 2026-06-12
|
|
9
|
+
|
|
10
|
+
Scale estimation v2 — dimensional, calibrated, SMB-bias-robust.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **`market scale` estimates revenue, not a normalized blend.** v1 averaged
|
|
15
|
+
[0,1]-normalized signals, which quietly mixed dimensions: review/customer
|
|
16
|
+
counts proxy CUSTOMER COUNT while employees/revenue proxy REVENUE, so
|
|
17
|
+
many-small-customer vendors outranked fewer-bigger-customer ones (observed
|
|
18
|
+
live: a ~$20M SMB dialer outranked a ~$33M mid-market platform). v2
|
|
19
|
+
converts every signal into revenue space first: revenue signals are used
|
|
20
|
+
directly; headcount × a revenue-per-employee ratio; customer counts × a
|
|
21
|
+
revenue-per-customer ratio **calibrated within the set (median over
|
|
22
|
+
vendors that have both) and stratified by the vendor's new `acvBand`** —
|
|
23
|
+
revenue-per-review spans ~75× between SMB tools and enterprise suites,
|
|
24
|
+
which is the bias, killed at the source. Per-vendor output is an
|
|
25
|
+
estimated revenue (weighted geometric mean: revenue 3 / headcount 2 /
|
|
26
|
+
customers 1) with a disclosed max/min **uncertainty spread**, an index =
|
|
27
|
+
share of the set's summed estimates, and the full calibration table.
|
|
28
|
+
Uncalibratable metrics (no revenue pair anywhere) are skipped and named.
|
|
29
|
+
- Report bubbles: dot area ∝ estimated revenue share (normalized to the
|
|
30
|
+
set's max for visual range — ratios preserved); caption now says
|
|
31
|
+
"estimated revenue share … citable but NOT audited" and points at
|
|
32
|
+
`market scale` for the per-vendor estimates and spreads.
|
|
33
|
+
- New config fields: `MarketVendor.acvBand` ("smb" | "mid" | "enterprise"
|
|
34
|
+
by convention — usually obvious from the pricing page the map already
|
|
35
|
+
captures) and `ScaleSignal.dimension` override.
|
|
36
|
+
- SMB-bias regression test: many cheap-product reviews must not outrank
|
|
37
|
+
fewer expensive-product reviews when band calibration says otherwise.
|
|
38
|
+
|
|
39
|
+
## [0.20.0] — 2026-06-12
|
|
40
|
+
|
|
41
|
+
The directive layer: the market map joined to your own CRM ground truth.
|
|
42
|
+
Two companies mapping the same category see the same fronts; their own
|
|
43
|
+
conversion fingerprints produce different directives.
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- **`fullstackgtm market overlay`** — joins the observation store to a CRM
|
|
48
|
+
snapshot and a call corpus, and emits OCCUPY / PROMOTE / URGENT / RETREAT
|
|
49
|
+
directives. Deterministic throughout: claim mentions are word-boundary
|
|
50
|
+
matches of each claim's configured `terms` (and vendor `aliases`) against
|
|
51
|
+
call documents (`call parse` output, optionally deal-linked via a manifest);
|
|
52
|
+
every directive carries ≥1 observation id and ≥1 CRM statistic **with its
|
|
53
|
+
sample size**; explicit minimum-evidence thresholds (`--min-mentions`,
|
|
54
|
+
`--promote-lift`) refuse to mint strategy from small samples — and an
|
|
55
|
+
empty directive list is reported as an answer, not a failure.
|
|
56
|
+
- OCCUPY: open front the anchor doesn't own, buyers demonstrably discuss it.
|
|
57
|
+
- PROMOTE: anchor-quiet claim whose mentioned-deal win rate beats baseline.
|
|
58
|
+
- URGENT: a front the anchor is loud on drifted toward saturation
|
|
59
|
+
(`--prior-run`).
|
|
60
|
+
- RETREAT: saturated front, loud anchor, zero presence in won-deal calls.
|
|
61
|
+
- `--save --task-account <id>|--task-deal <id>` turns directives into
|
|
62
|
+
approval-gated `create_task` operations through the normal
|
|
63
|
+
plans → approve → apply gate. Nothing writes without approval.
|
|
64
|
+
- **`fullstackgtm market scale` + scale-sized report bubbles** — vendors may
|
|
65
|
+
carry `scaleSignals` (citable G2 review counts, LinkedIn headcount,
|
|
66
|
+
disclosed revenue, self-reported customers — each with sourceUrl, verbatim
|
|
67
|
+
quote, asOf, and caveat). A deterministic composite (log-normalized
|
|
68
|
+
per metric within the set, mean over available metrics, singleton metrics
|
|
69
|
+
skipped) yields a **relative scale index — never "market share"
|
|
70
|
+
unqualified** — and the strategic map's dot area becomes proportional to
|
|
71
|
+
it when every placeable vendor has signals (LOUD-count sizing otherwise;
|
|
72
|
+
the caption always states which, and bubbles are now area-proportional
|
|
73
|
+
either way).
|
|
74
|
+
- `MarketClaim.terms` and `MarketVendor.aliases` for deterministic mention
|
|
75
|
+
matching; `MarketVendor.scaleSignals`.
|
|
76
|
+
|
|
8
77
|
## [0.19.0] — 2026-06-11
|
|
9
78
|
|
|
10
79
|
Governed bulk writes, plus fixes from the 0.18 published-artifact verification.
|
package/dist/cli.js
CHANGED
|
@@ -20,6 +20,8 @@ import { sampleSnapshot } from "./sampleData.js";
|
|
|
20
20
|
import { normalizeTranscript, parseCall, suggestCallDeal } from "./calls.js";
|
|
21
21
|
import { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, loadCaptureTexts, loadMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, } from "./market.js";
|
|
22
22
|
import { assessAxes, axesReportToText } from "./marketAxes.js";
|
|
23
|
+
import { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, } from "./marketOverlay.js";
|
|
24
|
+
import { computeScaleIndex, scaleReportToText } from "./marketScale.js";
|
|
23
25
|
import { buildWorksheet, classifyMarket } from "./marketClassify.js";
|
|
24
26
|
import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
|
|
25
27
|
import { DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
|
|
@@ -68,6 +70,8 @@ Usage:
|
|
|
68
70
|
fullstackgtm market fronts [--run <label>] [--diff <prior-run>] [--json]
|
|
69
71
|
fullstackgtm market axes [--run <label>] [--json]
|
|
70
72
|
fullstackgtm market report [--run <label>] [--format md|html] [--out <path>]
|
|
73
|
+
fullstackgtm market overlay --snapshot <crm.json> [--calls <files>] [--save]
|
|
74
|
+
fullstackgtm market scale [--json]
|
|
71
75
|
fullstackgtm market refresh [--run <label>] [--model m]
|
|
72
76
|
the live competitive map: capture vendor pages (content-addressed),
|
|
73
77
|
classify intensity per claim (LLM bring-your-own-key, or fill the
|
|
@@ -775,9 +779,24 @@ market worksheet --vendor <id> [--capture-run <label>] [--out <path>]
|
|
|
775
779
|
market observe --from <observations.json> [--unverified]
|
|
776
780
|
market fronts [--config <path>] [--run <label>] [--diff <prior-run>] [--json]
|
|
777
781
|
market axes [--config <path>] [--run <label>] [--json]
|
|
782
|
+
market overlay --snapshot <crm.json> [--calls <parsed.json|manifest.json>]... [--prior-run <label>]
|
|
783
|
+
[--min-mentions N] [--promote-lift X] [--json] [--save --task-account <id>|--task-deal <id>]
|
|
784
|
+
market scale [--config <path>] [--json]
|
|
778
785
|
market report [--config <path>] [--run <label>] [--format md|html] [--out <path>]
|
|
779
786
|
market refresh [--run <label>] [--model m] capture → classify → fronts drift → HTML report
|
|
780
787
|
|
|
788
|
+
overlay is the directive layer: joins the map to YOUR CRM ground truth and
|
|
789
|
+
emits OCCUPY / PROMOTE / URGENT / RETREAT directives, each carrying ≥1
|
|
790
|
+
observation and ≥1 CRM statistic with its sample size. Claim mentions are
|
|
791
|
+
deterministic word-boundary matches of each claim's "terms" against call
|
|
792
|
+
documents (call parse output); small samples refuse to become strategy
|
|
793
|
+
(--min-mentions, default 3). --save turns directives into approval-gated
|
|
794
|
+
create_task operations through the normal plans → approve → apply gate.
|
|
795
|
+
|
|
796
|
+
scale prints the relative scale index that sizes the report's bubbles when
|
|
797
|
+
vendors carry scaleSignals (citable review counts / headcount / revenue —
|
|
798
|
+
a within-set index, never "market share" unqualified).
|
|
799
|
+
|
|
781
800
|
axes runs the axis-discovery math: PCA over the vendor × claim intensity
|
|
782
801
|
matrix (PC1 = the category's primary axis, PC2 = the max-differentiation
|
|
783
802
|
direction orthogonal to it), triangulation of configured axes against the
|
|
@@ -982,7 +1001,76 @@ recomputed deterministically on every invocation — never stored.`);
|
|
|
982
1001
|
console.log(axesReportToText(report));
|
|
983
1002
|
return;
|
|
984
1003
|
}
|
|
985
|
-
|
|
1004
|
+
if (subcommand === "scale") {
|
|
1005
|
+
const report = computeScaleIndex(config);
|
|
1006
|
+
if (rest.includes("--json")) {
|
|
1007
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
console.log(scaleReportToText(config, report));
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
if (subcommand === "overlay") {
|
|
1014
|
+
const set = await loadSet();
|
|
1015
|
+
const snapshotPath = option(rest, "--snapshot");
|
|
1016
|
+
if (!snapshotPath) {
|
|
1017
|
+
throw new Error("market overlay requires --snapshot <canonical-snapshot.json> (fullstackgtm snapshot --out it first) — directives need CRM ground truth");
|
|
1018
|
+
}
|
|
1019
|
+
const snapshot = JSON.parse(readFileSync(resolve(process.cwd(), snapshotPath), "utf8"));
|
|
1020
|
+
// --calls accepts ParsedCall JSON files (from `call parse --out`) and/or
|
|
1021
|
+
// manifest arrays [{path, dealId?}] linking calls to deals. Repeatable.
|
|
1022
|
+
const documents = [];
|
|
1023
|
+
const addParsedCall = (parsedPath, dealId) => {
|
|
1024
|
+
const parsed = JSON.parse(readFileSync(resolve(process.cwd(), parsedPath), "utf8"));
|
|
1025
|
+
const text = [
|
|
1026
|
+
...(parsed.segments ?? []).map((segment) => segment.text ?? ""),
|
|
1027
|
+
...(parsed.insights ?? []).map((insight) => `${insight.text ?? ""} ${insight.evidence ?? ""}`),
|
|
1028
|
+
].join("\n");
|
|
1029
|
+
documents.push({ id: parsed.id ?? parsedPath, text, dealId, occurredAt: parsed.evidence?.[0]?.capturedAt });
|
|
1030
|
+
};
|
|
1031
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
1032
|
+
if (rest[i] !== "--calls")
|
|
1033
|
+
continue;
|
|
1034
|
+
const callsPath = rest[i + 1];
|
|
1035
|
+
if (!callsPath)
|
|
1036
|
+
throw new Error("--calls needs a path");
|
|
1037
|
+
const raw = JSON.parse(readFileSync(resolve(process.cwd(), callsPath), "utf8"));
|
|
1038
|
+
if (Array.isArray(raw)) {
|
|
1039
|
+
for (const entry of raw)
|
|
1040
|
+
addParsedCall(entry.path, entry.dealId);
|
|
1041
|
+
}
|
|
1042
|
+
else {
|
|
1043
|
+
addParsedCall(callsPath);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
const priorLabel = option(rest, "--prior-run");
|
|
1047
|
+
const priorSet = priorLabel ? await store.get(priorLabel) : null;
|
|
1048
|
+
if (priorLabel && !priorSet)
|
|
1049
|
+
throw new Error(`No observation run "${priorLabel}" for URGENT drift`);
|
|
1050
|
+
const stats = computeOverlayStats(config, snapshot, documents);
|
|
1051
|
+
const directives = computeDirectives(config, set, stats, {
|
|
1052
|
+
minMentions: numericOption(rest, "--min-mentions") ?? undefined,
|
|
1053
|
+
promoteLift: numericOption(rest, "--promote-lift") ?? undefined,
|
|
1054
|
+
priorSet: priorSet ?? undefined,
|
|
1055
|
+
});
|
|
1056
|
+
if (rest.includes("--json")) {
|
|
1057
|
+
console.log(JSON.stringify({ stats, directives }, null, 2));
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
console.log(overlayToMarkdown(stats, directives));
|
|
1061
|
+
if (rest.includes("--save")) {
|
|
1062
|
+
const taskAccount = option(rest, "--task-account");
|
|
1063
|
+
const taskDeal = option(rest, "--task-deal");
|
|
1064
|
+
if (!taskAccount && !taskDeal) {
|
|
1065
|
+
throw new Error("--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)");
|
|
1066
|
+
}
|
|
1067
|
+
const plan = directivesToPlan(config, set, directives, taskDeal ? { objectType: "deal", objectId: taskDeal } : { objectType: "account", objectId: taskAccount });
|
|
1068
|
+
const stored = await createFilePlanStore().save(plan);
|
|
1069
|
+
console.log(`Saved plan ${stored.plan.id} (${directives.length} directive task(s); approve via \`plans approve\`)`);
|
|
1070
|
+
}
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
throw new Error(`Unknown market subcommand: ${subcommand} (try: init, capture, classify, worksheet, observe, fronts, axes, overlay, scale, report, refresh)`);
|
|
986
1074
|
}
|
|
987
1075
|
/**
|
|
988
1076
|
* The resolve gate: exit 0 = safe to create, exit 2 = match found (exists or
|
package/dist/index.d.ts
CHANGED
|
@@ -20,9 +20,11 @@ export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, s
|
|
|
20
20
|
export { sampleSnapshot } from "./sampleData.ts";
|
|
21
21
|
export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, forcedToolCall, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, type CallScorecard, type LlmCredential, type LlmExtractedInsight, type LlmProvider, type Rubric, type ScoredDimension, } from "./llm.ts";
|
|
22
22
|
export { resolveRecord, type ResolveCandidate, type ResolveMatch, type ResolveResult } from "./resolve.ts";
|
|
23
|
-
export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadCaptureTexts, loadMarketConfig, marketHome, normalizeForMatch, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, type CaptureEntry, type CaptureOptions, type ClaimFront, type ClaimIntensity, type FrontDrift, type FrontState, type MarketAxis, type MarketClaim, type MarketConfig, type MarketObservation, type MarketVendor, type ObservationConfidence, type ObservationSet, type ObservationStore, type SpanVerificationFailure, } from "./market.ts";
|
|
23
|
+
export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadCaptureTexts, loadMarketConfig, marketHome, normalizeForMatch, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, type CaptureEntry, type CaptureOptions, type ClaimFront, type ClaimIntensity, type FrontDrift, type FrontState, type MarketAxis, type MarketClaim, type MarketConfig, type MarketObservation, type MarketVendor, type ObservationConfidence, type ObservationSet, type ObservationStore, type ScaleSignal, type SpanVerificationFailure, } from "./market.ts";
|
|
24
24
|
export { assessAxes, axesReportToText, axisPosition, messageBreadth, pcaTop2, pearson, type AxesReport, type AxisAssessment, type AxisPairing, type PrincipalComponent, } from "./marketAxes.ts";
|
|
25
25
|
export { buildWorksheet, classifyMarket, type ClassifyMarketOptions, type ClassifyMarketResult, type MarketWorksheet, } from "./marketClassify.ts";
|
|
26
|
+
export { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, type CallDocument, type ClaimMentionStats, type DirectiveStat, type DirectiveType, type MarketDirective, type OverlayOptions, type OverlayStats, type VendorMentionStats, } from "./marketOverlay.ts";
|
|
27
|
+
export { computeScaleIndex, dimensionForMetric, scaleReportToText, type ScaleDimension, type ScaleReport, type SignalEstimate, type VendorScale, } from "./marketScale.ts";
|
|
26
28
|
export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
|
|
27
29
|
export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
|
|
28
30
|
export type { ApprovalStatus, AuditFinding, AuditFindingSeverity, CanonicalAccount, CanonicalActivity, CanonicalContact, CanonicalDeal, CanonicalGtmSnapshot, CanonicalUser, CrmProvider, GtmAuditRule, GtmConnector, GtmEvidence, GtmEvidenceSourceSystem, GtmObjectType, GtmPolicy, GtmRuleContext, GtmRuleResult, GtmSnapshotIndex, PatchOperation, PatchOperationResult, PatchOperationType, PatchPlan, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
|
package/dist/index.js
CHANGED
|
@@ -23,5 +23,7 @@ export { resolveRecord } from "./resolve.js";
|
|
|
23
23
|
export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadCaptureTexts, loadMarketConfig, marketHome, normalizeForMatch, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, } from "./market.js";
|
|
24
24
|
export { assessAxes, axesReportToText, axisPosition, messageBreadth, pcaTop2, pearson, } from "./marketAxes.js";
|
|
25
25
|
export { buildWorksheet, classifyMarket, } from "./marketClassify.js";
|
|
26
|
+
export { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, } from "./marketOverlay.js";
|
|
27
|
+
export { computeScaleIndex, dimensionForMetric, scaleReportToText, } from "./marketScale.js";
|
|
26
28
|
export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
|
|
27
29
|
export { suggestValues } from "./suggest.js";
|
package/dist/market.d.ts
CHANGED
|
@@ -29,6 +29,36 @@ export type MarketClaim = {
|
|
|
29
29
|
pricingStructure: string;
|
|
30
30
|
/** Operational definition: how a reader judges LOUD vs QUIET vs ABSENT. */
|
|
31
31
|
definition: string;
|
|
32
|
+
/**
|
|
33
|
+
* Exact terms buyers use for this claim, for deterministic mention
|
|
34
|
+
* matching against call transcripts (the overlay). No terms = no mention
|
|
35
|
+
* stats for this claim; matching is word-boundary, case-insensitive.
|
|
36
|
+
*/
|
|
37
|
+
terms?: string[];
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* One public, citable scale signal for a vendor (G2 review count, LinkedIn
|
|
41
|
+
* headcount, disclosed revenue, self-reported customer count). The composite
|
|
42
|
+
* of several biased-in-different-directions signals sizes the report's
|
|
43
|
+
* bubbles — a RELATIVE scale index within the mapped set, never "market
|
|
44
|
+
* share" unqualified.
|
|
45
|
+
*/
|
|
46
|
+
export type ScaleSignal = {
|
|
47
|
+
/** e.g. "g2_reviews", "linkedin_employees", "revenue_usd", "self_reported_customers". */
|
|
48
|
+
metric: string;
|
|
49
|
+
value: number;
|
|
50
|
+
unit: string;
|
|
51
|
+
sourceUrl: string;
|
|
52
|
+
/** Verbatim snippet containing the number — same evidence posture as observations. */
|
|
53
|
+
quote: string;
|
|
54
|
+
asOf: string;
|
|
55
|
+
caveat?: string;
|
|
56
|
+
/**
|
|
57
|
+
* What the signal proxies: revenue (used directly), headcount, or
|
|
58
|
+
* customers (count-of-customers proxies like reviews). Inferred from the
|
|
59
|
+
* metric name when omitted; set explicitly for unusual metrics.
|
|
60
|
+
*/
|
|
61
|
+
dimension?: "revenue" | "headcount" | "customers";
|
|
32
62
|
};
|
|
33
63
|
export type MarketVendor = {
|
|
34
64
|
id: string;
|
|
@@ -39,6 +69,18 @@ export type MarketVendor = {
|
|
|
39
69
|
pricing: string | null;
|
|
40
70
|
product: string[];
|
|
41
71
|
};
|
|
72
|
+
/** Alternate names/spellings for deterministic mention matching. */
|
|
73
|
+
aliases?: string[];
|
|
74
|
+
/** Public scale signals; see ScaleSignal. */
|
|
75
|
+
scaleSignals?: ScaleSignal[];
|
|
76
|
+
/**
|
|
77
|
+
* ACV stratum ("smb" | "mid" | "enterprise" by convention) used to
|
|
78
|
+
* calibrate customer-count → revenue conversion in the scale index.
|
|
79
|
+
* Revenue-per-customer differs ~75× between SMB tools and enterprise
|
|
80
|
+
* suites; stratifying kills the many-small-customers bias. Usually
|
|
81
|
+
* obvious from the vendor's own pricing page (which the map captures).
|
|
82
|
+
*/
|
|
83
|
+
acvBand?: string;
|
|
42
84
|
notes?: string;
|
|
43
85
|
};
|
|
44
86
|
export type MarketAxis = {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
|
|
2
|
+
import type { MarketConfig, ObservationSet } from "./market.ts";
|
|
3
|
+
/**
|
|
4
|
+
* The directive layer: the market map joined to the company's own ground
|
|
5
|
+
* truth. The map alone says what the category looks like; the overlay says
|
|
6
|
+
* what THIS company should do about it — and two companies running the same
|
|
7
|
+
* map see different directives, because the overlay is their own conversion
|
|
8
|
+
* fingerprint.
|
|
9
|
+
*
|
|
10
|
+
* Everything here is deterministic. Inputs are the observation store (front
|
|
11
|
+
* states), a CRM snapshot (won/lost deals), and call documents (transcript
|
|
12
|
+
* text, optionally linked to deals). Claim mentions are found by exact
|
|
13
|
+
* word-boundary term matching against each claim's configured `terms` —
|
|
14
|
+
* the same posture as the rest of the map: no model in the loop, every
|
|
15
|
+
* directive carries at least one observation and at least one CRM statistic
|
|
16
|
+
* with its sample size, and small samples refuse to become claims (the
|
|
17
|
+
* minimum-evidence thresholds are explicit options, not vibes).
|
|
18
|
+
*/
|
|
19
|
+
export type CallDocument = {
|
|
20
|
+
id: string;
|
|
21
|
+
text: string;
|
|
22
|
+
/** Links the document to a deal for win/loss statistics; optional. */
|
|
23
|
+
dealId?: string;
|
|
24
|
+
occurredAt?: string;
|
|
25
|
+
};
|
|
26
|
+
export type ClaimMentionStats = {
|
|
27
|
+
claimId: string;
|
|
28
|
+
/** Documents whose text matches any of the claim's terms. */
|
|
29
|
+
mentionDocIds: string[];
|
|
30
|
+
/** Distinct deals among those documents (only docs with dealId count). */
|
|
31
|
+
mentionDealIds: string[];
|
|
32
|
+
wonDeals: number;
|
|
33
|
+
lostDeals: number;
|
|
34
|
+
/** won / (won + lost) among closed mentioned deals; null below any closure. */
|
|
35
|
+
winRateWhenMentioned: number | null;
|
|
36
|
+
};
|
|
37
|
+
export type VendorMentionStats = {
|
|
38
|
+
vendorId: string;
|
|
39
|
+
mentionDocIds: string[];
|
|
40
|
+
mentionDealIds: string[];
|
|
41
|
+
wonWhenMentioned: number;
|
|
42
|
+
lostWhenMentioned: number;
|
|
43
|
+
};
|
|
44
|
+
export type OverlayStats = {
|
|
45
|
+
documents: number;
|
|
46
|
+
documentsWithDeal: number;
|
|
47
|
+
deals: {
|
|
48
|
+
total: number;
|
|
49
|
+
closed: number;
|
|
50
|
+
won: number;
|
|
51
|
+
baselineWinRate: number | null;
|
|
52
|
+
};
|
|
53
|
+
claims: ClaimMentionStats[];
|
|
54
|
+
vendors: VendorMentionStats[];
|
|
55
|
+
};
|
|
56
|
+
export type DirectiveType = "occupy" | "promote" | "urgent" | "retreat";
|
|
57
|
+
export type DirectiveStat = {
|
|
58
|
+
name: string;
|
|
59
|
+
value: number | string;
|
|
60
|
+
n: number;
|
|
61
|
+
};
|
|
62
|
+
export type MarketDirective = {
|
|
63
|
+
id: string;
|
|
64
|
+
type: DirectiveType;
|
|
65
|
+
claimId: string;
|
|
66
|
+
title: string;
|
|
67
|
+
summary: string;
|
|
68
|
+
recommendation: string;
|
|
69
|
+
/** ≥1 observation id and ≥1 CRM stat — the spec's evidence-chain rule. */
|
|
70
|
+
observationIds: string[];
|
|
71
|
+
stats: DirectiveStat[];
|
|
72
|
+
};
|
|
73
|
+
export type OverlayOptions = {
|
|
74
|
+
/** Minimum mention documents before OCCUPY/PROMOTE may fire (default 3). */
|
|
75
|
+
minMentions?: number;
|
|
76
|
+
/** Minimum win-rate lift over baseline for PROMOTE (default 0.10). */
|
|
77
|
+
promoteLift?: number;
|
|
78
|
+
/** Minimum won deals in the corpus before RETREAT may fire (default 3). */
|
|
79
|
+
minWonDealsForRetreat?: number;
|
|
80
|
+
/** Prior run's observations: enables URGENT (front drift) directives. */
|
|
81
|
+
priorSet?: ObservationSet;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Deterministic claim/vendor mention statistics over a call corpus.
|
|
85
|
+
* Claims match on their configured `terms` (claims without terms simply have
|
|
86
|
+
* no mention stats — the directives that need mentions will not fire for
|
|
87
|
+
* them); vendors match on name + configured `aliases`.
|
|
88
|
+
*/
|
|
89
|
+
export declare function computeOverlayStats(config: MarketConfig, snapshot: CanonicalGtmSnapshot, documents: CallDocument[]): OverlayStats;
|
|
90
|
+
/**
|
|
91
|
+
* Directive rules v1 — deterministic over (front states × overlay stats),
|
|
92
|
+
* with explicit minimum-evidence thresholds so small samples cannot mint
|
|
93
|
+
* strategy. Requires config.anchorVendor: directives are advice to someone.
|
|
94
|
+
*
|
|
95
|
+
* OCCUPY — open/vacant front the anchor doesn't own loudly, and buyers
|
|
96
|
+
* demonstrably talk about it (≥ minMentions documents).
|
|
97
|
+
* PROMOTE — anchor is quiet on a claim whose mentioned-deal win rate beats
|
|
98
|
+
* baseline by ≥ promoteLift (with ≥ minMentions mentioned deals).
|
|
99
|
+
* URGENT — a front the anchor is loud on drifted toward saturation since
|
|
100
|
+
* the prior run (requires priorSet).
|
|
101
|
+
* RETREAT — saturated front the anchor is loud on, with zero presence in
|
|
102
|
+
* won-deal conversations despite a corpus that contains wins.
|
|
103
|
+
*/
|
|
104
|
+
export declare function computeDirectives(config: MarketConfig, set: ObservationSet, stats: OverlayStats, options?: OverlayOptions): MarketDirective[];
|
|
105
|
+
/**
|
|
106
|
+
* Emit directives as a standard dry-run patch plan: one approval-gated
|
|
107
|
+
* create_task per directive against a designated CRM record (the company's
|
|
108
|
+
* own account/deal record — directives are strategy tasks, and the CRM
|
|
109
|
+
* needs somewhere to hang them). Approving and applying goes through the
|
|
110
|
+
* normal plans → approve → apply gate; nothing here writes.
|
|
111
|
+
*/
|
|
112
|
+
export declare function directivesToPlan(config: MarketConfig, set: ObservationSet, directives: MarketDirective[], target: {
|
|
113
|
+
objectType: "account" | "deal";
|
|
114
|
+
objectId: string;
|
|
115
|
+
}, now?: () => Date): PatchPlan;
|
|
116
|
+
export declare function overlayToMarkdown(stats: OverlayStats, directives: MarketDirective[]): string;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { computeFrontStates, diffFrontStates } from "./market.js";
|
|
2
|
+
function fnv1a(value) {
|
|
3
|
+
let hash = 0x811c9dc5;
|
|
4
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
5
|
+
hash ^= value.charCodeAt(i);
|
|
6
|
+
hash = Math.imul(hash, 0x01000193);
|
|
7
|
+
}
|
|
8
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
9
|
+
}
|
|
10
|
+
function termPattern(terms) {
|
|
11
|
+
const cleaned = terms.map((term) => term.trim()).filter(Boolean);
|
|
12
|
+
if (cleaned.length === 0)
|
|
13
|
+
return null;
|
|
14
|
+
const escaped = cleaned.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s+"));
|
|
15
|
+
return new RegExp(`\\b(?:${escaped.join("|")})\\b`, "i");
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Deterministic claim/vendor mention statistics over a call corpus.
|
|
19
|
+
* Claims match on their configured `terms` (claims without terms simply have
|
|
20
|
+
* no mention stats — the directives that need mentions will not fire for
|
|
21
|
+
* them); vendors match on name + configured `aliases`.
|
|
22
|
+
*/
|
|
23
|
+
export function computeOverlayStats(config, snapshot, documents) {
|
|
24
|
+
const dealById = new Map(snapshot.deals.map((deal) => [deal.id, deal]));
|
|
25
|
+
const closed = snapshot.deals.filter((deal) => deal.isClosed === true);
|
|
26
|
+
const won = closed.filter((deal) => deal.isWon === true);
|
|
27
|
+
const baselineWinRate = closed.length > 0 ? won.length / closed.length : null;
|
|
28
|
+
const dealOutcome = (dealIds) => {
|
|
29
|
+
let wonCount = 0;
|
|
30
|
+
let lostCount = 0;
|
|
31
|
+
for (const dealId of dealIds) {
|
|
32
|
+
const deal = dealById.get(dealId);
|
|
33
|
+
if (!deal || deal.isClosed !== true)
|
|
34
|
+
continue;
|
|
35
|
+
if (deal.isWon === true)
|
|
36
|
+
wonCount += 1;
|
|
37
|
+
else
|
|
38
|
+
lostCount += 1;
|
|
39
|
+
}
|
|
40
|
+
return { wonCount, lostCount };
|
|
41
|
+
};
|
|
42
|
+
const claims = config.claims.map((claim) => {
|
|
43
|
+
const pattern = termPattern(claim.terms ?? []);
|
|
44
|
+
const mentionDocs = pattern ? documents.filter((doc) => pattern.test(doc.text)) : [];
|
|
45
|
+
const mentionDealIds = [...new Set(mentionDocs.map((doc) => doc.dealId).filter((id) => !!id))];
|
|
46
|
+
const { wonCount, lostCount } = dealOutcome(mentionDealIds);
|
|
47
|
+
return {
|
|
48
|
+
claimId: claim.id,
|
|
49
|
+
mentionDocIds: mentionDocs.map((doc) => doc.id),
|
|
50
|
+
mentionDealIds,
|
|
51
|
+
wonDeals: wonCount,
|
|
52
|
+
lostDeals: lostCount,
|
|
53
|
+
winRateWhenMentioned: wonCount + lostCount > 0 ? wonCount / (wonCount + lostCount) : null,
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
const vendors = config.vendors.map((vendor) => {
|
|
57
|
+
const pattern = termPattern([vendor.name, ...(vendor.aliases ?? [])]);
|
|
58
|
+
const mentionDocs = pattern ? documents.filter((doc) => pattern.test(doc.text)) : [];
|
|
59
|
+
const mentionDealIds = [...new Set(mentionDocs.map((doc) => doc.dealId).filter((id) => !!id))];
|
|
60
|
+
const { wonCount, lostCount } = dealOutcome(mentionDealIds);
|
|
61
|
+
return {
|
|
62
|
+
vendorId: vendor.id,
|
|
63
|
+
mentionDocIds: mentionDocs.map((doc) => doc.id),
|
|
64
|
+
mentionDealIds,
|
|
65
|
+
wonWhenMentioned: wonCount,
|
|
66
|
+
lostWhenMentioned: lostCount,
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
documents: documents.length,
|
|
71
|
+
documentsWithDeal: documents.filter((doc) => doc.dealId).length,
|
|
72
|
+
deals: { total: snapshot.deals.length, closed: closed.length, won: won.length, baselineWinRate },
|
|
73
|
+
claims,
|
|
74
|
+
vendors,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Directive rules v1 — deterministic over (front states × overlay stats),
|
|
79
|
+
* with explicit minimum-evidence thresholds so small samples cannot mint
|
|
80
|
+
* strategy. Requires config.anchorVendor: directives are advice to someone.
|
|
81
|
+
*
|
|
82
|
+
* OCCUPY — open/vacant front the anchor doesn't own loudly, and buyers
|
|
83
|
+
* demonstrably talk about it (≥ minMentions documents).
|
|
84
|
+
* PROMOTE — anchor is quiet on a claim whose mentioned-deal win rate beats
|
|
85
|
+
* baseline by ≥ promoteLift (with ≥ minMentions mentioned deals).
|
|
86
|
+
* URGENT — a front the anchor is loud on drifted toward saturation since
|
|
87
|
+
* the prior run (requires priorSet).
|
|
88
|
+
* RETREAT — saturated front the anchor is loud on, with zero presence in
|
|
89
|
+
* won-deal conversations despite a corpus that contains wins.
|
|
90
|
+
*/
|
|
91
|
+
export function computeDirectives(config, set, stats, options = {}) {
|
|
92
|
+
const anchor = config.anchorVendor;
|
|
93
|
+
if (!anchor)
|
|
94
|
+
throw new Error("market overlay requires anchorVendor in the config — directives are advice to someone");
|
|
95
|
+
const minMentions = options.minMentions ?? 3;
|
|
96
|
+
const promoteLift = options.promoteLift ?? 0.1;
|
|
97
|
+
const minWonDealsForRetreat = options.minWonDealsForRetreat ?? 3;
|
|
98
|
+
const fronts = computeFrontStates(config, set);
|
|
99
|
+
const frontByClaim = new Map(fronts.map((front) => [front.claimId, front]));
|
|
100
|
+
const statsByClaim = new Map(stats.claims.map((claim) => [claim.claimId, claim]));
|
|
101
|
+
const anchorIntensity = new Map();
|
|
102
|
+
const loudObservationIds = new Map();
|
|
103
|
+
for (const obs of set.observations) {
|
|
104
|
+
if (obs.vendorId === anchor)
|
|
105
|
+
anchorIntensity.set(obs.claimId, { intensity: obs.intensity, observationId: obs.id });
|
|
106
|
+
if (obs.intensity === "loud") {
|
|
107
|
+
const ids = loudObservationIds.get(obs.claimId) ?? [];
|
|
108
|
+
ids.push(obs.id);
|
|
109
|
+
loudObservationIds.set(obs.claimId, ids);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const directives = [];
|
|
113
|
+
const claimName = new Map(config.claims.map((claim) => [claim.id, claim.capability.split(":")[0]]));
|
|
114
|
+
const push = (type, claimId, summary, recommendation, observationIds, statsList) => {
|
|
115
|
+
directives.push({
|
|
116
|
+
id: `dir_${fnv1a(`${config.category}|${set.runLabel}|${type}|${claimId}`)}`,
|
|
117
|
+
type,
|
|
118
|
+
claimId,
|
|
119
|
+
title: `${type.toUpperCase()}: ${claimName.get(claimId) ?? claimId}`,
|
|
120
|
+
summary,
|
|
121
|
+
recommendation,
|
|
122
|
+
observationIds: [...new Set(observationIds)].filter(Boolean),
|
|
123
|
+
stats: statsList,
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
for (const claim of config.claims) {
|
|
127
|
+
const front = frontByClaim.get(claim.id);
|
|
128
|
+
const mention = statsByClaim.get(claim.id);
|
|
129
|
+
const anchorObs = anchorIntensity.get(claim.id);
|
|
130
|
+
if (!front || !anchorObs)
|
|
131
|
+
continue;
|
|
132
|
+
if ((front.state === "open" || front.state === "vacant") &&
|
|
133
|
+
(anchorObs.intensity === "absent" || anchorObs.intensity === "quiet") &&
|
|
134
|
+
mention &&
|
|
135
|
+
mention.mentionDocIds.length >= minMentions) {
|
|
136
|
+
push("occupy", claim.id, `No vendor is loud on this claim, and it came up in ${mention.mentionDocIds.length} of ${stats.documents} call documents.`, `Claim it: buyers already talk about this and nobody owns the message. Anchor is currently ${anchorObs.intensity}.`, [anchorObs.observationId], [
|
|
137
|
+
{ name: "mention_documents", value: mention.mentionDocIds.length, n: stats.documents },
|
|
138
|
+
{ name: "front_state", value: front.state, n: config.vendors.length },
|
|
139
|
+
]);
|
|
140
|
+
}
|
|
141
|
+
if (anchorObs.intensity === "quiet" &&
|
|
142
|
+
mention &&
|
|
143
|
+
mention.winRateWhenMentioned !== null &&
|
|
144
|
+
stats.deals.baselineWinRate !== null &&
|
|
145
|
+
mention.wonDeals + mention.lostDeals >= minMentions &&
|
|
146
|
+
mention.winRateWhenMentioned >= stats.deals.baselineWinRate + promoteLift) {
|
|
147
|
+
push("promote", claim.id, `Anchor ships this quietly; deals where it comes up close at ${(mention.winRateWhenMentioned * 100).toFixed(0)}% vs ${(stats.deals.baselineWinRate * 100).toFixed(0)}% baseline.`, "Turn it loud: the conversion fingerprint says this claim wins deals when discussed.", [anchorObs.observationId], [
|
|
148
|
+
{ name: "win_rate_when_mentioned", value: Number(mention.winRateWhenMentioned.toFixed(3)), n: mention.wonDeals + mention.lostDeals },
|
|
149
|
+
{ name: "baseline_win_rate", value: Number(stats.deals.baselineWinRate.toFixed(3)), n: stats.deals.closed },
|
|
150
|
+
]);
|
|
151
|
+
}
|
|
152
|
+
if (front.state === "saturated" &&
|
|
153
|
+
anchorObs.intensity === "loud" &&
|
|
154
|
+
mention &&
|
|
155
|
+
stats.deals.won >= minWonDealsForRetreat &&
|
|
156
|
+
stats.documentsWithDeal > 0 &&
|
|
157
|
+
mention.wonDeals === 0) {
|
|
158
|
+
push("retreat", claim.id, `${front.loudVendorIds.length} vendors shout this claim; it appears in zero of the anchor's won-deal conversations (${stats.deals.won} won deals in corpus).`, "Stop spending message budget on a saturated front that never shows up in wins.", [anchorObs.observationId, ...(loudObservationIds.get(claim.id) ?? [])], [
|
|
159
|
+
{ name: "won_deals_mentioning", value: 0, n: stats.deals.won },
|
|
160
|
+
{ name: "loud_vendors", value: front.loudVendorIds.length, n: config.vendors.length },
|
|
161
|
+
]);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (options.priorSet) {
|
|
165
|
+
const drift = diffFrontStates(computeFrontStates(config, options.priorSet), fronts);
|
|
166
|
+
const toward = (before, after) => {
|
|
167
|
+
const order = ["vacant", "open", "owned", "contested", "saturated"];
|
|
168
|
+
return order.indexOf(after) > order.indexOf(before);
|
|
169
|
+
};
|
|
170
|
+
for (const change of drift) {
|
|
171
|
+
const anchorObs = anchorIntensity.get(change.claimId);
|
|
172
|
+
if (!anchorObs || anchorObs.intensity !== "loud" || !toward(change.before, change.after))
|
|
173
|
+
continue;
|
|
174
|
+
const mention = statsByClaim.get(change.claimId);
|
|
175
|
+
push("urgent", change.claimId, `Front moved ${change.before} → ${change.after} since ${options.priorSet.runLabel} on a claim the anchor is loud on.`, "The window is closing: decide whether to defend (differentiate the claim) or bank the position before it saturates.", [anchorObs.observationId], [
|
|
176
|
+
{ name: "front_drift", value: `${change.before}→${change.after}`, n: config.vendors.length },
|
|
177
|
+
{ name: "mention_documents", value: mention?.mentionDocIds.length ?? 0, n: stats.documents },
|
|
178
|
+
]);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return directives.sort((a, b) => a.type.localeCompare(b.type) || a.claimId.localeCompare(b.claimId));
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Emit directives as a standard dry-run patch plan: one approval-gated
|
|
185
|
+
* create_task per directive against a designated CRM record (the company's
|
|
186
|
+
* own account/deal record — directives are strategy tasks, and the CRM
|
|
187
|
+
* needs somewhere to hang them). Approving and applying goes through the
|
|
188
|
+
* normal plans → approve → apply gate; nothing here writes.
|
|
189
|
+
*/
|
|
190
|
+
export function directivesToPlan(config, set, directives, target, now = () => new Date()) {
|
|
191
|
+
const createdAt = now().toISOString();
|
|
192
|
+
const findings = directives.map((directive) => ({
|
|
193
|
+
id: `find_${fnv1a(directive.id)}`,
|
|
194
|
+
objectType: target.objectType,
|
|
195
|
+
objectId: target.objectId,
|
|
196
|
+
ruleId: `market_directive_${directive.type}`,
|
|
197
|
+
title: directive.title,
|
|
198
|
+
severity: directive.type === "urgent" ? "critical" : "warning",
|
|
199
|
+
summary: directive.summary,
|
|
200
|
+
recommendation: directive.recommendation,
|
|
201
|
+
evidenceIds: directive.observationIds,
|
|
202
|
+
}));
|
|
203
|
+
const evidence = directives.map((directive) => ({
|
|
204
|
+
id: `ev_${fnv1a(directive.id)}`,
|
|
205
|
+
sourceSystem: "web",
|
|
206
|
+
sourceObjectType: "market_map",
|
|
207
|
+
sourceObjectId: `${config.category}/${set.runLabel}`,
|
|
208
|
+
title: directive.title,
|
|
209
|
+
text: `${directive.summary} Stats: ${directive.stats.map((stat) => `${stat.name}=${stat.value} (n=${stat.n})`).join("; ")}`,
|
|
210
|
+
observedAt: set.runAt,
|
|
211
|
+
metadata: { observationIds: directive.observationIds, stats: directive.stats },
|
|
212
|
+
}));
|
|
213
|
+
const operations = directives.map((directive) => ({
|
|
214
|
+
id: `op_${fnv1a(directive.id)}`,
|
|
215
|
+
objectType: target.objectType,
|
|
216
|
+
objectId: target.objectId,
|
|
217
|
+
operation: "create_task",
|
|
218
|
+
field: "task",
|
|
219
|
+
afterValue: `${directive.title} — ${directive.recommendation} (${directive.stats.map((stat) => `${stat.name}=${stat.value}, n=${stat.n}`).join("; ")})`,
|
|
220
|
+
reason: directive.summary,
|
|
221
|
+
riskLevel: "low",
|
|
222
|
+
approvalRequired: true,
|
|
223
|
+
sourceRuleOrPolicy: `market_directive_${directive.type}`,
|
|
224
|
+
evidenceIds: [`ev_${fnv1a(directive.id)}`],
|
|
225
|
+
}));
|
|
226
|
+
return {
|
|
227
|
+
id: `patch_plan_market_${fnv1a(`${config.category}|${set.runLabel}|${createdAt}`)}`,
|
|
228
|
+
title: `Market directives — ${config.category} (${set.runLabel})`,
|
|
229
|
+
createdAt,
|
|
230
|
+
status: "needs_approval",
|
|
231
|
+
dryRun: true,
|
|
232
|
+
summary: `${directives.length} directive(s) from the ${config.category} market map joined to CRM ground truth.`,
|
|
233
|
+
findings,
|
|
234
|
+
evidence,
|
|
235
|
+
operations,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
export function overlayToMarkdown(stats, directives) {
|
|
239
|
+
const lines = [];
|
|
240
|
+
lines.push(`Corpus: ${stats.documents} documents (${stats.documentsWithDeal} deal-linked) · ` +
|
|
241
|
+
`${stats.deals.closed} closed deals, ${stats.deals.won} won` +
|
|
242
|
+
(stats.deals.baselineWinRate !== null ? ` (baseline ${(stats.deals.baselineWinRate * 100).toFixed(0)}%)` : ""));
|
|
243
|
+
lines.push("");
|
|
244
|
+
if (directives.length === 0) {
|
|
245
|
+
lines.push("No directives met the evidence thresholds. That is an answer, not a failure:");
|
|
246
|
+
lines.push("either the corpus is too thin (add calls), or the current position needs no move.");
|
|
247
|
+
}
|
|
248
|
+
for (const directive of directives) {
|
|
249
|
+
lines.push(`## ${directive.title}`);
|
|
250
|
+
lines.push(directive.summary);
|
|
251
|
+
lines.push(`→ ${directive.recommendation}`);
|
|
252
|
+
lines.push(`evidence: ${directive.observationIds.length} observation(s) · ${directive.stats
|
|
253
|
+
.map((stat) => `${stat.name}=${stat.value} (n=${stat.n})`)
|
|
254
|
+
.join(" · ")}`);
|
|
255
|
+
lines.push("");
|
|
256
|
+
}
|
|
257
|
+
return `${lines.join("\n")}\n`;
|
|
258
|
+
}
|