fullstackgtm 0.15.0 → 0.17.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,233 @@
1
+ import { computeFrontStates } from "./market.js";
2
+ /**
3
+ * Render a market map as a client-ready deliverable: markdown for terminals
4
+ * and PRs, and a self-contained printable HTML "field report" — front
5
+ * summary, claim × vendor intensity matrix, and a verbatim-evidence
6
+ * appendix. Deterministic: same observation set, same bytes. No webfonts,
7
+ * no CDNs — the artifact must stand alone wherever it's sent.
8
+ */
9
+ const FRONT_ORDER = {
10
+ open: 0,
11
+ contested: 1,
12
+ owned: 2,
13
+ saturated: 3,
14
+ vacant: 4,
15
+ };
16
+ const GLYPH = {
17
+ loud: "■",
18
+ quiet: "□",
19
+ absent: "·",
20
+ unobservable: "▨",
21
+ };
22
+ function escapeHtml(value) {
23
+ return value
24
+ .replace(/&/g, "&")
25
+ .replace(/</g, "&lt;")
26
+ .replace(/>/g, "&gt;")
27
+ .replace(/"/g, "&quot;");
28
+ }
29
+ function buildModel(config, set) {
30
+ const fronts = computeFrontStates(config, set);
31
+ const stateByClaim = new Map(fronts.map((front) => [front.claimId, front.state]));
32
+ const orderedClaimIds = config.claims
33
+ .map((claim) => claim.id)
34
+ .sort((a, b) => {
35
+ const byFront = FRONT_ORDER[stateByClaim.get(a) ?? "vacant"] - FRONT_ORDER[stateByClaim.get(b) ?? "vacant"];
36
+ return byFront !== 0 ? byFront : a.localeCompare(b);
37
+ });
38
+ const byCell = new Map(set.observations.map((obs) => [`${obs.vendorId}|${obs.claimId}`, obs]));
39
+ return {
40
+ config,
41
+ set,
42
+ fronts,
43
+ orderedClaimIds,
44
+ cell: (vendorId, claimId) => byCell.get(`${vendorId}|${claimId}`),
45
+ };
46
+ }
47
+ export function marketMapToMarkdown(config, set) {
48
+ const model = buildModel(config, set);
49
+ const stateByClaim = new Map(model.fronts.map((front) => [front.claimId, front.state]));
50
+ const counts = { open: 0, contested: 0, owned: 0, saturated: 0, vacant: 0 };
51
+ for (const front of model.fronts)
52
+ counts[front.state] += 1;
53
+ const lines = [];
54
+ lines.push(`# Market map — ${config.category} (${set.runLabel})`);
55
+ lines.push("");
56
+ lines.push(`Observed ${set.runAt} · ${config.vendors.length} vendors · ${config.claims.length} claims · ` +
57
+ `${set.observations.length} readings · extractor ${set.extractor}`);
58
+ lines.push("");
59
+ lines.push(`Fronts: ${counts.open + counts.vacant} open/vacant · ${counts.contested} contested · ` +
60
+ `${counts.owned} owned · ${counts.saturated} saturated`);
61
+ lines.push("");
62
+ lines.push(`Legend: ■ loud · □ quiet · · absent · ▨ unobservable`);
63
+ lines.push("");
64
+ const header = ["claim", ...config.vendors.map((v) => v.id), "front"];
65
+ lines.push(`| ${header.join(" | ")} |`);
66
+ lines.push(`| ${header.map(() => "---").join(" | ")} |`);
67
+ for (const claimId of model.orderedClaimIds) {
68
+ const cells = config.vendors.map((vendor) => GLYPH[model.cell(vendor.id, claimId)?.intensity ?? "unobservable"]);
69
+ lines.push(`| ${claimId} | ${cells.join(" | ")} | ${stateByClaim.get(claimId)?.toUpperCase()} |`);
70
+ }
71
+ lines.push("");
72
+ for (const front of model.fronts.filter((f) => f.state === "owned")) {
73
+ lines.push(`- OWNED: ${front.claimId} → ${front.loudVendorIds[0]}`);
74
+ }
75
+ for (const front of model.fronts.filter((f) => f.state === "open" || f.state === "vacant")) {
76
+ lines.push(`- ${front.state.toUpperCase()}: ${front.claimId} — no vendor is loud here`);
77
+ }
78
+ return `${lines.join("\n")}\n`;
79
+ }
80
+ export function marketMapToHtml(config, set) {
81
+ const model = buildModel(config, set);
82
+ const stateByClaim = new Map(model.fronts.map((front) => [front.claimId, front.state]));
83
+ const claimsById = new Map(config.claims.map((claim) => [claim.id, claim]));
84
+ const counts = { open: 0, contested: 0, owned: 0, saturated: 0, vacant: 0 };
85
+ for (const front of model.fronts)
86
+ counts[front.state] += 1;
87
+ const unobservable = set.observations.filter((obs) => obs.intensity === "unobservable").length;
88
+ const anchor = config.anchorVendor;
89
+ const e = escapeHtml;
90
+ const matrixRows = model.orderedClaimIds
91
+ .map((claimId) => {
92
+ const claim = claimsById.get(claimId);
93
+ if (!claim)
94
+ return "";
95
+ const state = stateByClaim.get(claimId) ?? "vacant";
96
+ const cells = config.vendors
97
+ .map((vendor) => {
98
+ const intensity = model.cell(vendor.id, claimId)?.intensity ?? "unobservable";
99
+ const anchorClass = vendor.id === anchor ? " anchor-col" : "";
100
+ return `<td class="cell${anchorClass}"><span class="g g-${intensity}" title="${e(vendor.name)}: ${intensity}"></span></td>`;
101
+ })
102
+ .join("");
103
+ return (`<tr class="front-${state}"><th scope="row"><span class="claim-cap">${e(claim.capability.split(":")[0])}</span>` +
104
+ `<span class="claim-meta">${e(claim.icp)} · ${e(claim.pricingStructure)}</span></th>${cells}` +
105
+ `<td class="front"><span class="chip chip-${state}">${state.toUpperCase()}</span></td></tr>`);
106
+ })
107
+ .join("");
108
+ const openList = model.orderedClaimIds
109
+ .filter((claimId) => {
110
+ const state = stateByClaim.get(claimId);
111
+ return state === "open" || state === "vacant";
112
+ })
113
+ .map((claimId) => {
114
+ const claim = claimsById.get(claimId);
115
+ return `<li><b>${e(claim?.capability.split(":")[0] ?? claimId)}</b> <span class="why">— no vendor is loud here; ${e(claim?.icp ?? "")} cell</span></li>`;
116
+ })
117
+ .join("");
118
+ const appendix = model.orderedClaimIds
119
+ .flatMap((claimId) => config.vendors.flatMap((vendor) => {
120
+ const obs = model.cell(vendor.id, claimId);
121
+ if (!obs || obs.evidence.length === 0)
122
+ return [];
123
+ return obs.evidence.map((evidence) => `<div class="ev"><span class="ev-head">${e(vendor.name)} · ${e(claimId)} · ${obs.intensity.toUpperCase()} (${obs.confidence})</span>` +
124
+ `<blockquote>“${e(evidence.text)}”</blockquote>` +
125
+ `<span class="ev-src">${e(String(evidence.metadata?.url ?? ""))} · capture ${e(String(evidence.metadata?.captureHash ?? "").slice(0, 12))}</span></div>`);
126
+ }))
127
+ .join("");
128
+ const vendorHeads = config.vendors
129
+ .map((vendor) => `<th class="vh${vendor.id === anchor ? " anchor-col" : ""}"><span>${e(vendor.name)}</span></th>`)
130
+ .join("");
131
+ return `<!doctype html>
132
+ <html lang="en"><head><meta charset="utf-8">
133
+ <meta name="viewport" content="width=device-width, initial-scale=1">
134
+ <title>Market map — ${e(config.category)} — ${e(set.runLabel)}</title>
135
+ <style>
136
+ :root { --paper:#f4efe4; --ink:#211d16; --ink-soft:#5a5244; --line:#c9bfa9; --accent:#b4441b; --green:#2e5339; --quiet:#8a7d63; }
137
+ * { box-sizing:border-box; margin:0; }
138
+ body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background:var(--paper);
139
+ max-width:1080px; margin:0 auto; padding:0 48px 96px;
140
+ background-image:radial-gradient(rgba(33,29,22,.028) 1px, transparent 1.2px); background-size:5px 5px; }
141
+ .chip,.claim-meta,.ev-src,.lg,.stamp,.meta,th.vh span { font-family:"SF Mono",Menlo,Consolas,monospace; }
142
+ header { padding:56px 0 28px; border-bottom:3px double var(--ink); position:relative; }
143
+ .kicker { font-size:11px; letter-spacing:.32em; color:var(--accent); text-transform:uppercase; }
144
+ h1 { font-size:44px; line-height:1.05; font-weight:600; margin:10px 0 6px; }
145
+ h1 em { font-style:italic; color:var(--green); }
146
+ .meta { font-size:11.5px; color:var(--ink-soft); display:flex; gap:24px; flex-wrap:wrap; margin-top:14px; }
147
+ .stamp { position:absolute; right:0; top:58px; border:2px solid var(--accent); color:var(--accent); padding:7px 13px;
148
+ font-size:11px; letter-spacing:.22em; transform:rotate(3.5deg); text-transform:uppercase; }
149
+ section { margin-top:56px; }
150
+ h2 { font-size:13px; letter-spacing:.26em; text-transform:uppercase; color:var(--ink-soft);
151
+ border-bottom:1px solid var(--line); padding-bottom:9px; display:flex; gap:14px; align-items:baseline; }
152
+ h2 .no { color:var(--accent); font-style:italic; font-size:15px; letter-spacing:0; }
153
+ .fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:22px; }
154
+ .fcard { background:var(--paper); padding:18px 18px 14px; }
155
+ .fcard b { display:block; font-size:42px; font-weight:600; line-height:1; }
156
+ .fcard span { font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:var(--ink-soft); }
157
+ .fcard.open b { color:var(--accent); }
158
+ .openlist { margin-top:18px; font-size:15.5px; line-height:1.55; }
159
+ .openlist li { margin:4px 0 4px 20px; }
160
+ .openlist .why { color:var(--ink-soft); font-size:13px; font-style:italic; }
161
+ .legend { display:flex; gap:22px; flex-wrap:wrap; margin:18px 0 10px; font-size:10.5px; color:var(--ink-soft); }
162
+ .lg { display:inline-flex; align-items:center; gap:7px; }
163
+ table { border-collapse:collapse; width:100%; margin-top:6px; }
164
+ thead th { border-bottom:2px solid var(--ink); padding:6px 2px 10px; }
165
+ th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px; letter-spacing:.12em;
166
+ text-transform:uppercase; color:var(--ink-soft); display:inline-block; }
167
+ th.vh.anchor-col span { color:var(--green); font-weight:700; }
168
+ tbody th { text-align:left; font-weight:400; padding:7px 14px 7px 0; border-bottom:1px solid var(--line); max-width:330px; }
169
+ .claim-cap { display:block; font-size:14.5px; }
170
+ .claim-meta { display:block; font-size:9.5px; color:var(--quiet); letter-spacing:.08em; margin-top:2px; }
171
+ td.cell { text-align:center; border-bottom:1px solid var(--line); padding:4px 2px; }
172
+ td.cell.anchor-col { background:rgba(46,83,57,.06); }
173
+ td.front { border-bottom:1px solid var(--line); text-align:right; white-space:nowrap; }
174
+ .g { display:inline-block; width:15px; height:15px; vertical-align:middle; }
175
+ .g-loud { background:var(--ink); }
176
+ .g-quiet { box-shadow:inset 0 0 0 2px var(--quiet); }
177
+ .g-absent { background:radial-gradient(circle at center, var(--line) 0 2.5px, transparent 3px); }
178
+ .g-unobservable { background:repeating-linear-gradient(45deg, var(--line) 0 2px, transparent 2px 5px); }
179
+ tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
180
+ .chip { font-size:9px; letter-spacing:.16em; padding:3px 8px; border:1px solid currentColor; }
181
+ .chip-open { color:var(--accent); } .chip-contested { color:#7a5a12; }
182
+ .chip-owned { color:var(--green); } .chip-saturated { color:var(--ink-soft); } .chip-vacant { color:var(--quiet); }
183
+ .ev { border-bottom:1px solid var(--line); padding:12px 0; }
184
+ .ev-head { font-size:10.5px; letter-spacing:.1em; color:var(--accent); }
185
+ .ev blockquote { font-style:italic; margin:6px 0; font-size:13.5px; line-height:1.5; }
186
+ .ev-src { font-size:10px; color:var(--ink-soft); word-break:break-all; }
187
+ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; font-size:11px; color:var(--ink-soft);
188
+ display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
189
+ @media print { body { max-width:none; padding:0 8mm; background:white; } section { break-inside:avoid-page; } tr { break-inside:avoid; } }
190
+ </style></head><body>
191
+ <header>
192
+ <div class="kicker">Full Stack GTM · Market Map</div>
193
+ <h1>The <em>${e(config.category.replace(/-/g, " "))}</em> front map</h1>
194
+ <div class="meta">
195
+ <span>RUN ${e(set.runLabel.toUpperCase())}</span><span>OBSERVED ${e(set.runAt)}</span>
196
+ <span>${config.vendors.length} VENDORS · ${config.claims.length} CLAIMS · ${set.observations.length} READINGS</span>
197
+ <span>${unobservable} UNOBSERVABLE · EXTRACTOR ${e(set.extractor)}</span>
198
+ </div>
199
+ <div class="stamp">Field Report</div>
200
+ </header>
201
+ <section>
202
+ <h2><span class="no">01</span> Front summary</h2>
203
+ <div class="fronts">
204
+ <div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
205
+ <div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
206
+ <div class="fcard"><b>${counts.owned}</b><span>Owned</span></div>
207
+ <div class="fcard"><b>${counts.saturated}</b><span>Saturated</span></div>
208
+ </div>
209
+ <ul class="openlist">${openList}</ul>
210
+ </section>
211
+ <section>
212
+ <h2><span class="no">02</span> Claim × vendor intensity matrix</h2>
213
+ <div class="legend">
214
+ <span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
215
+ <span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
216
+ <span class="lg"><i class="g g-absent"></i>ABSENT</span>
217
+ <span class="lg"><i class="g g-unobservable"></i>UNOBSERVABLE — capture failed</span>
218
+ </div>
219
+ <table>
220
+ <thead><tr><th></th>${vendorHeads}<th></th></tr></thead>
221
+ <tbody>${matrixRows}</tbody>
222
+ </table>
223
+ </section>
224
+ <section>
225
+ <h2><span class="no">03</span> Evidence appendix</h2>
226
+ ${appendix}
227
+ </section>
228
+ <footer>
229
+ <span>Generated by fullstackgtm market · deterministic render of ${e(set.runLabel)}</span>
230
+ <span>Front rule v1: 0 loud=open · 1=owned · 2–3=contested · ≥4=saturated</span>
231
+ </footer>
232
+ </body></html>`;
233
+ }
package/dist/mcp.js CHANGED
@@ -47,6 +47,8 @@ import { builtinAuditRules } from "./rules.js";
47
47
  import { sampleSnapshot } from "./sampleData.js";
48
48
  import { normalizeTranscript, parseCall } from "./calls.js";
49
49
  import { extractInsightsLlm, resolveLlmCredential } from "./llm.js";
50
+ import { computeFrontStates, createFileObservationStore, loadCaptureTexts, loadMarketConfig, validateObservationSet, verifyEvidenceSpans, } from "./market.js";
51
+ import { buildWorksheet } from "./marketClassify.js";
50
52
  import { resolveRecord } from "./resolve.js";
51
53
  import { suggestValues } from "./suggest.js";
52
54
  function content(value) {
@@ -244,6 +246,49 @@ export async function startMcpServer() {
244
246
  });
245
247
  return content(output === "markdown" ? formatPatchPlanRun(run) : run);
246
248
  });
249
+ server.registerTool("fullstackgtm_market_worksheet", {
250
+ title: "Market Map Classification Worksheet",
251
+ description: "Get everything needed to classify ONE vendor's messaging intensity for a market map: " +
252
+ "the claim taxonomy with judging definitions, the surface rule, and the captured page " +
253
+ "texts. Read each claim's definition, judge loud/quiet/absent from the page texts only, " +
254
+ "and quote verbatim spans (≤300 chars) for every loud/quiet reading. Submit the full " +
255
+ "ObservationSet via fullstackgtm_market_observe — quotes are verified character-for-" +
256
+ "character against the captures, so never paraphrase.",
257
+ inputSchema: {
258
+ vendorId: z.string(),
259
+ configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
260
+ captureRun: z.string().optional(),
261
+ },
262
+ }, async ({ vendorId, configPath, captureRun }) => {
263
+ const config = loadMarketConfig(resolve(process.cwd(), configPath ?? "market.config.json"));
264
+ return content(buildWorksheet(config, vendorId, { captureRun }));
265
+ });
266
+ server.registerTool("fullstackgtm_market_observe", {
267
+ title: "Submit Market Map Observations",
268
+ description: "Submit a complete ObservationSet (every vendor × claim cell) for a market map run. " +
269
+ "Validates coverage, the verbatim-evidence rule, and mechanically verifies every quoted " +
270
+ "span against the stored capture it cites. Returns problems if rejected; nothing is " +
271
+ "stored unless the whole set passes. Observations are append-only — use a new runLabel.",
272
+ inputSchema: {
273
+ observationsPath: z.string().describe("Path to the ObservationSet JSON file"),
274
+ configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
275
+ },
276
+ }, async ({ observationsPath, configPath }) => {
277
+ const config = loadMarketConfig(resolve(process.cwd(), configPath ?? "market.config.json"));
278
+ const set = JSON.parse(readFileSync(resolve(process.cwd(), observationsPath), "utf8"));
279
+ const problems = validateObservationSet(config, set);
280
+ const failures = verifyEvidenceSpans(set.observations, loadCaptureTexts(config.category).textByHash);
281
+ if (problems.length > 0 || failures.length > 0) {
282
+ return content({
283
+ accepted: false,
284
+ problems,
285
+ spanFailures: failures.map((failure) => `${failure.vendorId} × ${failure.claimId}: ${failure.problem}`),
286
+ });
287
+ }
288
+ await createFileObservationStore(config.category).append(set);
289
+ const fronts = computeFrontStates(config, set);
290
+ return content({ accepted: true, runLabel: set.runLabel, observations: set.observations.length, fronts });
291
+ });
247
292
  const transport = new StdioServerTransport();
248
293
  await server.connect(transport);
249
294
  }
package/dist/resolve.js CHANGED
@@ -73,28 +73,47 @@ function resolveDeal(snapshot, c) {
73
73
  if (!c.name) {
74
74
  return { ...base, verdict: "ambiguous", matches: [], reason: "Supply --name (and ideally --account-id) to resolve a deal." };
75
75
  }
76
- const key = `${c.accountId ?? "unlinked"}:${normalizeName(c.name)}`;
76
+ const nameKey = normalizeName(c.name);
77
77
  const open = snapshot.deals.filter((d) => d.isClosed !== true && d.isWon !== true);
78
- const matches = open
79
- .filter((d) => `${d.accountId ?? "unlinked"}:${normalizeName(d.name)}` === key)
80
- .map((d) => match(d.id, d.name, "deal_key", `open deal with the same name on ${c.accountId ? `account ${c.accountId}` : "no account"}`));
81
- if (matches.length > 0) {
78
+ if (c.accountId) {
79
+ const key = `${c.accountId}:${nameKey}`;
80
+ const matches = open
81
+ .filter((d) => `${d.accountId ?? "unlinked"}:${normalizeName(d.name)}` === key)
82
+ .map((d) => match(d.id, d.name, "deal_key", `open deal with the same name on account ${c.accountId}`));
83
+ if (matches.length > 0) {
84
+ return {
85
+ ...base,
86
+ verdict: "exists",
87
+ matches,
88
+ reason: `${matches.length} open deal(s) already match "${c.name}" on account ${c.accountId} — creating another would double-count pipeline. Update the existing deal.`,
89
+ };
90
+ }
91
+ const closedSameName = snapshot.deals.filter((d) => (d.isClosed === true || d.isWon === true) &&
92
+ d.accountId === c.accountId &&
93
+ normalizeName(d.name) === nameKey);
94
+ return {
95
+ ...base,
96
+ verdict: "safe_to_create",
97
+ matches: [],
98
+ reason: closedSameName.length > 0
99
+ ? `No open deal matches on account ${c.accountId}; ${closedSameName.length} closed deal(s) on it share the name (a re-open/renewal may be intended). Safe to create.`
100
+ : `No open deal matches "${c.name}" on account ${c.accountId}. Safe to create.`,
101
+ };
102
+ }
103
+ // No account scope: name-only matches across ALL open deals are ambiguous,
104
+ // never safe — a gate that ignores name collisions protects nobody.
105
+ const sameName = open
106
+ .filter((d) => normalizeName(d.name) === nameKey)
107
+ .map((d) => match(d.id, d.name, "name", `open deal with the same name on ${d.accountId ? `account ${d.accountId}` : "no account"}`));
108
+ if (sameName.length > 0) {
82
109
  return {
83
110
  ...base,
84
- verdict: "exists",
85
- matches,
86
- reason: `${matches.length} open deal(s) already match "${c.name}" on ${c.accountId ? `account ${c.accountId}` : "unlinked"} creating another would double-count pipeline. Update the existing deal.`,
111
+ verdict: "ambiguous",
112
+ matches: sameName,
113
+ reason: `${sameName.length} open deal(s) named "${c.name}" exist (no --account-id supplied to scope the check). Confirm before creating supply --account-id to resolve definitively.`,
87
114
  };
88
115
  }
89
- const closedSameName = snapshot.deals.filter((d) => (d.isClosed === true || d.isWon === true) && normalizeName(d.name) === normalizeName(c.name));
90
- return {
91
- ...base,
92
- verdict: "safe_to_create",
93
- matches: [],
94
- reason: closedSameName.length > 0
95
- ? `No open deal matches; ${closedSameName.length} closed deal(s) share the name (a re-open/renewal may be intended). Safe to create.`
96
- : `No open deal matches "${c.name}". Safe to create.`,
97
- };
116
+ return { ...base, verdict: "safe_to_create", matches: [], reason: `No open deal named "${c.name}" anywhere. Safe to create.` };
98
117
  }
99
118
  function contactName(row) {
100
119
  return [row.firstName, row.lastName].filter(Boolean).join(" ") || "(unnamed)";
package/dist/types.d.ts CHANGED
@@ -9,7 +9,7 @@ export type CrmProvider = "salesforce" | "hubspot" | "mock" | "unknown" | (strin
9
9
  export type RiskLevel = "low" | "medium" | "high";
10
10
  export type ApprovalStatus = "draft" | "needs_approval" | "approved" | "rejected" | "applied";
11
11
  export type GtmObjectType = "account" | "contact" | "deal" | "user" | "activity";
12
- export type GtmEvidenceSourceSystem = "salesforce" | "hubspot" | "gong" | "chorus" | "fathom" | "manual" | "csv" | "mock" | "unknown";
12
+ export type GtmEvidenceSourceSystem = "salesforce" | "hubspot" | "gong" | "chorus" | "fathom" | "manual" | "csv" | "mock" | "web" | "unknown";
13
13
  export type PatchOperationType = "set_field" | "clear_field" | "link_record" | "archive_record" | "create_task" | "merge_records";
14
14
  export type AuditFindingSeverity = "info" | "warning" | "critical";
15
15
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.15.0",
3
+ "version": "0.17.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",