fullstackgtm 0.18.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.
@@ -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
+ }
@@ -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/dist/mcp.js CHANGED
@@ -260,7 +260,7 @@ export async function startMcpServer() {
260
260
  captureRun: z.string().optional(),
261
261
  },
262
262
  }, async ({ vendorId, configPath, captureRun }) => {
263
- const config = loadMarketConfig(resolve(process.cwd(), configPath ?? "market.config.json"));
263
+ const config = loadMarketConfigOrHint(resolve(process.cwd(), configPath ?? "market.config.json"));
264
264
  return content(buildWorksheet(config, vendorId, { captureRun }));
265
265
  });
266
266
  server.registerTool("fullstackgtm_market_observe", {
@@ -274,7 +274,7 @@ export async function startMcpServer() {
274
274
  configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
275
275
  },
276
276
  }, async ({ observationsPath, configPath }) => {
277
- const config = loadMarketConfig(resolve(process.cwd(), configPath ?? "market.config.json"));
277
+ const config = loadMarketConfigOrHint(resolve(process.cwd(), configPath ?? "market.config.json"));
278
278
  const set = JSON.parse(readFileSync(resolve(process.cwd(), observationsPath), "utf8"));
279
279
  const problems = validateObservationSet(config, set);
280
280
  const failures = verifyEvidenceSpans(set.observations, loadCaptureTexts(config.category).textByHash);
@@ -292,3 +292,14 @@ export async function startMcpServer() {
292
292
  const transport = new StdioServerTransport();
293
293
  await server.connect(transport);
294
294
  }
295
+ function loadMarketConfigOrHint(path) {
296
+ try {
297
+ return loadMarketConfig(path);
298
+ }
299
+ catch (error) {
300
+ if (error.code === "ENOENT") {
301
+ throw new Error(`No market config at ${path} — run \`fullstackgtm market init --category <name>\` in that directory first, or pass configPath.`);
302
+ }
303
+ throw error;
304
+ }
305
+ }
package/dist/types.d.ts CHANGED
@@ -222,6 +222,23 @@ export type PatchOperation = {
222
222
  evidenceIds?: string[];
223
223
  findingIds?: string[];
224
224
  verification?: PatchVerification;
225
+ /**
226
+ * Compare-and-set guards beyond the written field: each precondition is
227
+ * re-read at apply time and a mismatch turns the operation into a
228
+ * conflict instead of a write. Guards against a record drifting on a
229
+ * DIFFERENT field than the one being written (e.g. stage changed while
230
+ * an owner write was pending).
231
+ */
232
+ preconditions?: Array<{
233
+ field: string;
234
+ expectedValue: unknown;
235
+ }>;
236
+ /**
237
+ * Operations sharing a groupId are all-or-nothing at apply time: a
238
+ * conflict (beforeValue or precondition) on any member skips every
239
+ * member of the group.
240
+ */
241
+ groupId?: string;
225
242
  };
226
243
  /**
227
244
  * A patch plan is always a dry-run proposal. Applying a plan never mutates
@@ -238,6 +255,33 @@ export type PatchPlan = {
238
255
  pipelineFindings?: PipelineFinding[];
239
256
  evidence?: GtmEvidence[];
240
257
  operations: PatchOperation[];
258
+ /**
259
+ * The filter this plan's operations were selected by. Re-evaluated per
260
+ * record against a FRESH snapshot at apply time: any operation whose
261
+ * record no longer matches is reported as a conflict instead of applied.
262
+ * Unlike per-operation preconditions, this enforces the FULL filter —
263
+ * negations and relational pseudo-fields included.
264
+ */
265
+ filter?: {
266
+ objectType: "account" | "contact" | "deal";
267
+ where: string[];
268
+ };
269
+ /**
270
+ * Plan-level guards re-evaluated against a FRESH snapshot at apply time.
271
+ * If any guard fails, NO operation in the plan is applied. This is how a
272
+ * plan expresses cross-record eligibility ("apply only while the account
273
+ * still has no open deal in contractsent") that per-operation
274
+ * preconditions cannot reach.
275
+ */
276
+ guards?: PlanGuard[];
277
+ };
278
+ export type PlanGuard = {
279
+ objectType: "account" | "contact" | "deal";
280
+ /** filter expressions in bulk-update --where grammar, AND-ed */
281
+ where: string[];
282
+ /** none: guard passes when ZERO records match; some: when at least one matches */
283
+ expect: "none" | "some";
284
+ description?: string;
241
285
  };
242
286
  /** Pre-computed lookups shared by all rules so each rule stays O(n). */
243
287
  export type GtmSnapshotIndex = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.18.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",