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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,44 @@ 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.20.0] — 2026-06-12
9
+
10
+ The directive layer: the market map joined to your own CRM ground truth.
11
+ Two companies mapping the same category see the same fronts; their own
12
+ conversion fingerprints produce different directives.
13
+
14
+ ### Added
15
+
16
+ - **`fullstackgtm market overlay`** — joins the observation store to a CRM
17
+ snapshot and a call corpus, and emits OCCUPY / PROMOTE / URGENT / RETREAT
18
+ directives. Deterministic throughout: claim mentions are word-boundary
19
+ matches of each claim's configured `terms` (and vendor `aliases`) against
20
+ call documents (`call parse` output, optionally deal-linked via a manifest);
21
+ every directive carries ≥1 observation id and ≥1 CRM statistic **with its
22
+ sample size**; explicit minimum-evidence thresholds (`--min-mentions`,
23
+ `--promote-lift`) refuse to mint strategy from small samples — and an
24
+ empty directive list is reported as an answer, not a failure.
25
+ - OCCUPY: open front the anchor doesn't own, buyers demonstrably discuss it.
26
+ - PROMOTE: anchor-quiet claim whose mentioned-deal win rate beats baseline.
27
+ - URGENT: a front the anchor is loud on drifted toward saturation
28
+ (`--prior-run`).
29
+ - RETREAT: saturated front, loud anchor, zero presence in won-deal calls.
30
+ - `--save --task-account <id>|--task-deal <id>` turns directives into
31
+ approval-gated `create_task` operations through the normal
32
+ plans → approve → apply gate. Nothing writes without approval.
33
+ - **`fullstackgtm market scale` + scale-sized report bubbles** — vendors may
34
+ carry `scaleSignals` (citable G2 review counts, LinkedIn headcount,
35
+ disclosed revenue, self-reported customers — each with sourceUrl, verbatim
36
+ quote, asOf, and caveat). A deterministic composite (log-normalized
37
+ per metric within the set, mean over available metrics, singleton metrics
38
+ skipped) yields a **relative scale index — never "market share"
39
+ unqualified** — and the strategic map's dot area becomes proportional to
40
+ it when every placeable vendor has signals (LOUD-count sizing otherwise;
41
+ the caption always states which, and bubbles are now area-proportional
42
+ either way).
43
+ - `MarketClaim.terms` and `MarketVendor.aliases` for deterministic mention
44
+ matching; `MarketVendor.scaleSignals`.
45
+
8
46
  ## [0.19.0] — 2026-06-11
9
47
 
10
48
  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
- throw new Error(`Unknown market subcommand: ${subcommand} (try: init, capture, classify, worksheet, observe, fronts, axes, report, refresh)`);
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, scaleReportToText, type ScaleReport, 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, 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,30 @@ 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;
32
56
  };
33
57
  export type MarketVendor = {
34
58
  id: string;
@@ -39,6 +63,10 @@ export type MarketVendor = {
39
63
  pricing: string | null;
40
64
  product: string[];
41
65
  };
66
+ /** Alternate names/spellings for deterministic mention matching. */
67
+ aliases?: string[];
68
+ /** Public scale signals; see ScaleSignal. */
69
+ scaleSignals?: ScaleSignal[];
42
70
  notes?: string;
43
71
  };
44
72
  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
+ }