fullstackgtm 0.15.0 → 0.16.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,272 @@
1
+ import type {
2
+ ClaimFront,
3
+ FrontState,
4
+ MarketConfig,
5
+ MarketObservation,
6
+ ObservationSet,
7
+ } from "./market.ts";
8
+ import { computeFrontStates } from "./market.ts";
9
+
10
+ /**
11
+ * Render a market map as a client-ready deliverable: markdown for terminals
12
+ * and PRs, and a self-contained printable HTML "field report" — front
13
+ * summary, claim × vendor intensity matrix, and a verbatim-evidence
14
+ * appendix. Deterministic: same observation set, same bytes. No webfonts,
15
+ * no CDNs — the artifact must stand alone wherever it's sent.
16
+ */
17
+
18
+ const FRONT_ORDER: Record<FrontState, number> = {
19
+ open: 0,
20
+ contested: 1,
21
+ owned: 2,
22
+ saturated: 3,
23
+ vacant: 4,
24
+ };
25
+
26
+ const GLYPH: Record<string, string> = {
27
+ loud: "■",
28
+ quiet: "□",
29
+ absent: "·",
30
+ unobservable: "▨",
31
+ };
32
+
33
+ function escapeHtml(value: string): string {
34
+ return value
35
+ .replace(/&/g, "&amp;")
36
+ .replace(/</g, "&lt;")
37
+ .replace(/>/g, "&gt;")
38
+ .replace(/"/g, "&quot;");
39
+ }
40
+
41
+ type MapModel = {
42
+ config: MarketConfig;
43
+ set: ObservationSet;
44
+ fronts: ClaimFront[];
45
+ /** claims sorted open-first, then by id — the report's reading order. */
46
+ orderedClaimIds: string[];
47
+ cell: (vendorId: string, claimId: string) => MarketObservation | undefined;
48
+ };
49
+
50
+ function buildModel(config: MarketConfig, set: ObservationSet): MapModel {
51
+ const fronts = computeFrontStates(config, set);
52
+ const stateByClaim = new Map(fronts.map((front) => [front.claimId, front.state]));
53
+ const orderedClaimIds = config.claims
54
+ .map((claim) => claim.id)
55
+ .sort((a, b) => {
56
+ const byFront = FRONT_ORDER[stateByClaim.get(a) ?? "vacant"] - FRONT_ORDER[stateByClaim.get(b) ?? "vacant"];
57
+ return byFront !== 0 ? byFront : a.localeCompare(b);
58
+ });
59
+ const byCell = new Map(set.observations.map((obs) => [`${obs.vendorId}|${obs.claimId}`, obs]));
60
+ return {
61
+ config,
62
+ set,
63
+ fronts,
64
+ orderedClaimIds,
65
+ cell: (vendorId, claimId) => byCell.get(`${vendorId}|${claimId}`),
66
+ };
67
+ }
68
+
69
+ export function marketMapToMarkdown(config: MarketConfig, set: ObservationSet): string {
70
+ const model = buildModel(config, set);
71
+ const stateByClaim = new Map(model.fronts.map((front) => [front.claimId, front.state]));
72
+ const counts: Record<FrontState, number> = { open: 0, contested: 0, owned: 0, saturated: 0, vacant: 0 };
73
+ for (const front of model.fronts) counts[front.state] += 1;
74
+
75
+ const lines: string[] = [];
76
+ lines.push(`# Market map — ${config.category} (${set.runLabel})`);
77
+ lines.push("");
78
+ lines.push(
79
+ `Observed ${set.runAt} · ${config.vendors.length} vendors · ${config.claims.length} claims · ` +
80
+ `${set.observations.length} readings · extractor ${set.extractor}`,
81
+ );
82
+ lines.push("");
83
+ lines.push(
84
+ `Fronts: ${counts.open + counts.vacant} open/vacant · ${counts.contested} contested · ` +
85
+ `${counts.owned} owned · ${counts.saturated} saturated`,
86
+ );
87
+ lines.push("");
88
+ lines.push(`Legend: ■ loud · □ quiet · · absent · ▨ unobservable`);
89
+ lines.push("");
90
+ const header = ["claim", ...config.vendors.map((v) => v.id), "front"];
91
+ lines.push(`| ${header.join(" | ")} |`);
92
+ lines.push(`| ${header.map(() => "---").join(" | ")} |`);
93
+ for (const claimId of model.orderedClaimIds) {
94
+ const cells = config.vendors.map((vendor) => GLYPH[model.cell(vendor.id, claimId)?.intensity ?? "unobservable"]);
95
+ lines.push(`| ${claimId} | ${cells.join(" | ")} | ${stateByClaim.get(claimId)?.toUpperCase()} |`);
96
+ }
97
+ lines.push("");
98
+ for (const front of model.fronts.filter((f) => f.state === "owned")) {
99
+ lines.push(`- OWNED: ${front.claimId} → ${front.loudVendorIds[0]}`);
100
+ }
101
+ for (const front of model.fronts.filter((f) => f.state === "open" || f.state === "vacant")) {
102
+ lines.push(`- ${front.state.toUpperCase()}: ${front.claimId} — no vendor is loud here`);
103
+ }
104
+ return `${lines.join("\n")}\n`;
105
+ }
106
+
107
+ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): string {
108
+ const model = buildModel(config, set);
109
+ const stateByClaim = new Map(model.fronts.map((front) => [front.claimId, front.state]));
110
+ const claimsById = new Map(config.claims.map((claim) => [claim.id, claim]));
111
+ const counts: Record<FrontState, number> = { open: 0, contested: 0, owned: 0, saturated: 0, vacant: 0 };
112
+ for (const front of model.fronts) counts[front.state] += 1;
113
+ const unobservable = set.observations.filter((obs) => obs.intensity === "unobservable").length;
114
+ const anchor = config.anchorVendor;
115
+ const e = escapeHtml;
116
+
117
+ const matrixRows = model.orderedClaimIds
118
+ .map((claimId) => {
119
+ const claim = claimsById.get(claimId);
120
+ if (!claim) return "";
121
+ const state = stateByClaim.get(claimId) ?? "vacant";
122
+ const cells = config.vendors
123
+ .map((vendor) => {
124
+ const intensity = model.cell(vendor.id, claimId)?.intensity ?? "unobservable";
125
+ const anchorClass = vendor.id === anchor ? " anchor-col" : "";
126
+ return `<td class="cell${anchorClass}"><span class="g g-${intensity}" title="${e(vendor.name)}: ${intensity}"></span></td>`;
127
+ })
128
+ .join("");
129
+ return (
130
+ `<tr class="front-${state}"><th scope="row"><span class="claim-cap">${e(claim.capability.split(":")[0])}</span>` +
131
+ `<span class="claim-meta">${e(claim.icp)} · ${e(claim.pricingStructure)}</span></th>${cells}` +
132
+ `<td class="front"><span class="chip chip-${state}">${state.toUpperCase()}</span></td></tr>`
133
+ );
134
+ })
135
+ .join("");
136
+
137
+ const openList = model.orderedClaimIds
138
+ .filter((claimId) => {
139
+ const state = stateByClaim.get(claimId);
140
+ return state === "open" || state === "vacant";
141
+ })
142
+ .map((claimId) => {
143
+ const claim = claimsById.get(claimId);
144
+ return `<li><b>${e(claim?.capability.split(":")[0] ?? claimId)}</b> <span class="why">— no vendor is loud here; ${e(claim?.icp ?? "")} cell</span></li>`;
145
+ })
146
+ .join("");
147
+
148
+ const appendix = model.orderedClaimIds
149
+ .flatMap((claimId) =>
150
+ config.vendors.flatMap((vendor) => {
151
+ const obs = model.cell(vendor.id, claimId);
152
+ if (!obs || obs.evidence.length === 0) return [];
153
+ return obs.evidence.map(
154
+ (evidence) =>
155
+ `<div class="ev"><span class="ev-head">${e(vendor.name)} · ${e(claimId)} · ${obs.intensity.toUpperCase()} (${obs.confidence})</span>` +
156
+ `<blockquote>“${e(evidence.text)}”</blockquote>` +
157
+ `<span class="ev-src">${e(String(evidence.metadata?.url ?? ""))} · capture ${e(String(evidence.metadata?.captureHash ?? "").slice(0, 12))}</span></div>`,
158
+ );
159
+ }),
160
+ )
161
+ .join("");
162
+
163
+ const vendorHeads = config.vendors
164
+ .map(
165
+ (vendor) =>
166
+ `<th class="vh${vendor.id === anchor ? " anchor-col" : ""}"><span>${e(vendor.name)}</span></th>`,
167
+ )
168
+ .join("");
169
+
170
+ return `<!doctype html>
171
+ <html lang="en"><head><meta charset="utf-8">
172
+ <meta name="viewport" content="width=device-width, initial-scale=1">
173
+ <title>Market map — ${e(config.category)} — ${e(set.runLabel)}</title>
174
+ <style>
175
+ :root { --paper:#f4efe4; --ink:#211d16; --ink-soft:#5a5244; --line:#c9bfa9; --accent:#b4441b; --green:#2e5339; --quiet:#8a7d63; }
176
+ * { box-sizing:border-box; margin:0; }
177
+ body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background:var(--paper);
178
+ max-width:1080px; margin:0 auto; padding:0 48px 96px;
179
+ background-image:radial-gradient(rgba(33,29,22,.028) 1px, transparent 1.2px); background-size:5px 5px; }
180
+ .chip,.claim-meta,.ev-src,.lg,.stamp,.meta,th.vh span { font-family:"SF Mono",Menlo,Consolas,monospace; }
181
+ header { padding:56px 0 28px; border-bottom:3px double var(--ink); position:relative; }
182
+ .kicker { font-size:11px; letter-spacing:.32em; color:var(--accent); text-transform:uppercase; }
183
+ h1 { font-size:44px; line-height:1.05; font-weight:600; margin:10px 0 6px; }
184
+ h1 em { font-style:italic; color:var(--green); }
185
+ .meta { font-size:11.5px; color:var(--ink-soft); display:flex; gap:24px; flex-wrap:wrap; margin-top:14px; }
186
+ .stamp { position:absolute; right:0; top:58px; border:2px solid var(--accent); color:var(--accent); padding:7px 13px;
187
+ font-size:11px; letter-spacing:.22em; transform:rotate(3.5deg); text-transform:uppercase; }
188
+ section { margin-top:56px; }
189
+ h2 { font-size:13px; letter-spacing:.26em; text-transform:uppercase; color:var(--ink-soft);
190
+ border-bottom:1px solid var(--line); padding-bottom:9px; display:flex; gap:14px; align-items:baseline; }
191
+ h2 .no { color:var(--accent); font-style:italic; font-size:15px; letter-spacing:0; }
192
+ .fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:22px; }
193
+ .fcard { background:var(--paper); padding:18px 18px 14px; }
194
+ .fcard b { display:block; font-size:42px; font-weight:600; line-height:1; }
195
+ .fcard span { font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:var(--ink-soft); }
196
+ .fcard.open b { color:var(--accent); }
197
+ .openlist { margin-top:18px; font-size:15.5px; line-height:1.55; }
198
+ .openlist li { margin:4px 0 4px 20px; }
199
+ .openlist .why { color:var(--ink-soft); font-size:13px; font-style:italic; }
200
+ .legend { display:flex; gap:22px; flex-wrap:wrap; margin:18px 0 10px; font-size:10.5px; color:var(--ink-soft); }
201
+ .lg { display:inline-flex; align-items:center; gap:7px; }
202
+ table { border-collapse:collapse; width:100%; margin-top:6px; }
203
+ thead th { border-bottom:2px solid var(--ink); padding:6px 2px 10px; }
204
+ th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px; letter-spacing:.12em;
205
+ text-transform:uppercase; color:var(--ink-soft); display:inline-block; }
206
+ th.vh.anchor-col span { color:var(--green); font-weight:700; }
207
+ tbody th { text-align:left; font-weight:400; padding:7px 14px 7px 0; border-bottom:1px solid var(--line); max-width:330px; }
208
+ .claim-cap { display:block; font-size:14.5px; }
209
+ .claim-meta { display:block; font-size:9.5px; color:var(--quiet); letter-spacing:.08em; margin-top:2px; }
210
+ td.cell { text-align:center; border-bottom:1px solid var(--line); padding:4px 2px; }
211
+ td.cell.anchor-col { background:rgba(46,83,57,.06); }
212
+ td.front { border-bottom:1px solid var(--line); text-align:right; white-space:nowrap; }
213
+ .g { display:inline-block; width:15px; height:15px; vertical-align:middle; }
214
+ .g-loud { background:var(--ink); }
215
+ .g-quiet { box-shadow:inset 0 0 0 2px var(--quiet); }
216
+ .g-absent { background:radial-gradient(circle at center, var(--line) 0 2.5px, transparent 3px); }
217
+ .g-unobservable { background:repeating-linear-gradient(45deg, var(--line) 0 2px, transparent 2px 5px); }
218
+ tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
219
+ .chip { font-size:9px; letter-spacing:.16em; padding:3px 8px; border:1px solid currentColor; }
220
+ .chip-open { color:var(--accent); } .chip-contested { color:#7a5a12; }
221
+ .chip-owned { color:var(--green); } .chip-saturated { color:var(--ink-soft); } .chip-vacant { color:var(--quiet); }
222
+ .ev { border-bottom:1px solid var(--line); padding:12px 0; }
223
+ .ev-head { font-size:10.5px; letter-spacing:.1em; color:var(--accent); }
224
+ .ev blockquote { font-style:italic; margin:6px 0; font-size:13.5px; line-height:1.5; }
225
+ .ev-src { font-size:10px; color:var(--ink-soft); word-break:break-all; }
226
+ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; font-size:11px; color:var(--ink-soft);
227
+ display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
228
+ @media print { body { max-width:none; padding:0 8mm; background:white; } section { break-inside:avoid-page; } tr { break-inside:avoid; } }
229
+ </style></head><body>
230
+ <header>
231
+ <div class="kicker">Full Stack GTM · Market Map</div>
232
+ <h1>The <em>${e(config.category.replace(/-/g, " "))}</em> front map</h1>
233
+ <div class="meta">
234
+ <span>RUN ${e(set.runLabel.toUpperCase())}</span><span>OBSERVED ${e(set.runAt)}</span>
235
+ <span>${config.vendors.length} VENDORS · ${config.claims.length} CLAIMS · ${set.observations.length} READINGS</span>
236
+ <span>${unobservable} UNOBSERVABLE · EXTRACTOR ${e(set.extractor)}</span>
237
+ </div>
238
+ <div class="stamp">Field Report</div>
239
+ </header>
240
+ <section>
241
+ <h2><span class="no">01</span> Front summary</h2>
242
+ <div class="fronts">
243
+ <div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
244
+ <div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
245
+ <div class="fcard"><b>${counts.owned}</b><span>Owned</span></div>
246
+ <div class="fcard"><b>${counts.saturated}</b><span>Saturated</span></div>
247
+ </div>
248
+ <ul class="openlist">${openList}</ul>
249
+ </section>
250
+ <section>
251
+ <h2><span class="no">02</span> Claim × vendor intensity matrix</h2>
252
+ <div class="legend">
253
+ <span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
254
+ <span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
255
+ <span class="lg"><i class="g g-absent"></i>ABSENT</span>
256
+ <span class="lg"><i class="g g-unobservable"></i>UNOBSERVABLE — capture failed</span>
257
+ </div>
258
+ <table>
259
+ <thead><tr><th></th>${vendorHeads}<th></th></tr></thead>
260
+ <tbody>${matrixRows}</tbody>
261
+ </table>
262
+ </section>
263
+ <section>
264
+ <h2><span class="no">03</span> Evidence appendix</h2>
265
+ ${appendix}
266
+ </section>
267
+ <footer>
268
+ <span>Generated by fullstackgtm market · deterministic render of ${e(set.runLabel)}</span>
269
+ <span>Front rule v1: 0 loud=open · 1=owned · 2–3=contested · ≥4=saturated</span>
270
+ </footer>
271
+ </body></html>`;
272
+ }
package/src/resolve.ts CHANGED
@@ -118,30 +118,50 @@ function resolveDeal(snapshot: CanonicalGtmSnapshot, c: ResolveCandidate): Resol
118
118
  if (!c.name) {
119
119
  return { ...base, verdict: "ambiguous", matches: [], reason: "Supply --name (and ideally --account-id) to resolve a deal." };
120
120
  }
121
- const key = `${c.accountId ?? "unlinked"}:${normalizeName(c.name)}`;
121
+ const nameKey = normalizeName(c.name);
122
122
  const open = snapshot.deals.filter((d) => d.isClosed !== true && d.isWon !== true);
123
- const matches = open
124
- .filter((d) => `${d.accountId ?? "unlinked"}:${normalizeName(d.name)}` === key)
125
- .map((d) => match(d.id, d.name, "deal_key", `open deal with the same name on ${c.accountId ? `account ${c.accountId}` : "no account"}`));
126
- if (matches.length > 0) {
123
+ if (c.accountId) {
124
+ const key = `${c.accountId}:${nameKey}`;
125
+ const matches = open
126
+ .filter((d) => `${d.accountId ?? "unlinked"}:${normalizeName(d.name)}` === key)
127
+ .map((d) => match(d.id, d.name, "deal_key", `open deal with the same name on account ${c.accountId}`));
128
+ if (matches.length > 0) {
129
+ return {
130
+ ...base,
131
+ verdict: "exists",
132
+ matches,
133
+ reason: `${matches.length} open deal(s) already match "${c.name}" on account ${c.accountId} — creating another would double-count pipeline. Update the existing deal.`,
134
+ };
135
+ }
136
+ const closedSameName = snapshot.deals.filter(
137
+ (d) =>
138
+ (d.isClosed === true || d.isWon === true) &&
139
+ d.accountId === c.accountId &&
140
+ normalizeName(d.name) === nameKey,
141
+ );
142
+ return {
143
+ ...base,
144
+ verdict: "safe_to_create",
145
+ matches: [],
146
+ reason: closedSameName.length > 0
147
+ ? `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.`
148
+ : `No open deal matches "${c.name}" on account ${c.accountId}. Safe to create.`,
149
+ };
150
+ }
151
+ // No account scope: name-only matches across ALL open deals are ambiguous,
152
+ // never safe — a gate that ignores name collisions protects nobody.
153
+ const sameName = open
154
+ .filter((d) => normalizeName(d.name) === nameKey)
155
+ .map((d) => match(d.id, d.name, "name", `open deal with the same name on ${d.accountId ? `account ${d.accountId}` : "no account"}`));
156
+ if (sameName.length > 0) {
127
157
  return {
128
158
  ...base,
129
- verdict: "exists",
130
- matches,
131
- 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.`,
159
+ verdict: "ambiguous",
160
+ matches: sameName,
161
+ 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.`,
132
162
  };
133
163
  }
134
- const closedSameName = snapshot.deals.filter(
135
- (d) => (d.isClosed === true || d.isWon === true) && normalizeName(d.name) === normalizeName(c.name!),
136
- );
137
- return {
138
- ...base,
139
- verdict: "safe_to_create",
140
- matches: [],
141
- reason: closedSameName.length > 0
142
- ? `No open deal matches; ${closedSameName.length} closed deal(s) share the name (a re-open/renewal may be intended). Safe to create.`
143
- : `No open deal matches "${c.name}". Safe to create.`,
144
- };
164
+ return { ...base, verdict: "safe_to_create", matches: [], reason: `No open deal named "${c.name}" anywhere. Safe to create.` };
145
165
  }
146
166
 
147
167
  function contactName(row: { firstName?: string; lastName?: string }) {
package/src/types.ts CHANGED
@@ -34,6 +34,7 @@ export type GtmEvidenceSourceSystem =
34
34
  | "manual"
35
35
  | "csv"
36
36
  | "mock"
37
+ | "web"
37
38
  | "unknown";
38
39
 
39
40
  export type PatchOperationType =