fullstackgtm 0.21.1 → 0.22.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/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { auditSnapshot, defaultPolicy } from "./audit.ts";
2
- export { buildBulkUpdatePlan, parseWhere, type BulkUpdateOptions } from "./bulkUpdate.ts";
2
+ export { buildBulkUpdatePlan, isFilterableField, parseWhere, type BulkUpdateOptions } from "./bulkUpdate.ts";
3
+ export { buildDedupePlan, dedupeKey, type DedupeOptions } from "./dedupe.ts";
4
+ export { buildReassignPlans, type ReassignObjectType, type ReassignOptions } from "./reassign.ts";
3
5
  export { CONFIG_FILE_NAME, loadConfig, mergePolicy, resolveConfiguredRules, type FullstackgtmConfig, type LoadedConfig, } from "./config.ts";
4
6
  export { applyPatchPlan, type ApplyPatchPlanOptions } from "./connector.ts";
5
7
  export { createHubspotConnector, type HubspotConnectorOptions } from "./connectors/hubspot.ts";
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  export { auditSnapshot, defaultPolicy } from "./audit.js";
2
- export { buildBulkUpdatePlan, parseWhere } from "./bulkUpdate.js";
2
+ export { buildBulkUpdatePlan, isFilterableField, parseWhere } from "./bulkUpdate.js";
3
+ export { buildDedupePlan, dedupeKey } from "./dedupe.js";
4
+ export { buildReassignPlans } from "./reassign.js";
3
5
  export { CONFIG_FILE_NAME, loadConfig, mergePolicy, resolveConfiguredRules, } from "./config.js";
4
6
  export { applyPatchPlan } from "./connector.js";
5
7
  export { createHubspotConnector } from "./connectors/hubspot.js";
@@ -149,19 +149,26 @@ function svgScatter(points, ax, ay, anchor, colorByVendor, numberByVendor) {
149
149
  const sx = (x) => PAD_X + ((x - xLo) / (xHi - xLo)) * (W - 2 * PAD_X);
150
150
  const sy = (y) => H - PAD_BOTTOM - ((y - yLo) / (yHi - yLo)) * (H - PAD_TOP - PAD_BOTTOM);
151
151
  const e = escapeHtml;
152
- // Big bubbles first so small ones stay clickable/visible on top.
152
+ // Big bubbles first so small ones stay clickable/visible on top; hover
153
+ // raises a bubble to the front (JS re-appends its <g>), so even a bubble
154
+ // born fully underneath a bigger one is one mouse-over from visible.
153
155
  const ordered = [...points].sort((a, b) => b.size - a.size);
154
156
  const dots = ordered
155
157
  .map((p) => {
156
- const r = 7 + 24 * Math.sqrt(p.size);
158
+ const r = p.noScale ? 7 : 7 + 24 * Math.sqrt(p.size);
157
159
  const color = colorByVendor.get(p.vendorId) ?? "#717171";
158
- const ring = p.vendorId === anchor ? ` stroke="#1c1c1c" stroke-width="2.5"` : ` stroke="#ffffff" stroke-width="1.5"`;
159
160
  const number = numberByVendor.get(p.vendorId) ?? 0;
161
+ const cx = sx(p.x).toFixed(1);
162
+ const cy = sy(p.y);
163
+ const ring = p.vendorId === anchor ? ` stroke="#1c1c1c" stroke-width="2.5"` : ` stroke="#ffffff" stroke-width="1.5"`;
164
+ // No measurable scale: minimal dashed outline — visibly "no data", never a guess.
165
+ const fill = p.noScale ? ` fill="${color}" fill-opacity="0.2" stroke="${color}" stroke-width="1.5" stroke-dasharray="3 2"` : ` fill="${color}" fill-opacity="0.78"${ring}`;
166
+ // Numbers go inside when they fit, above the bubble when they don't.
160
167
  const fs = Math.max(10, Math.min(14, r * 0.9));
161
- return (`<circle cx="${sx(p.x).toFixed(1)}" cy="${sy(p.y).toFixed(1)}" r="${r.toFixed(1)}" fill="${color}" fill-opacity="0.88"${ring}>` +
162
- `<title>${e(p.name)}</title></circle>` +
163
- `<text x="${sx(p.x).toFixed(1)}" y="${(sy(p.y) + fs * 0.36).toFixed(1)}" text-anchor="middle" ` +
164
- `font-size="${fs.toFixed(1)}" font-weight="700" fill="${numeralColor(color)}" style="pointer-events:none">${number}</text>`);
168
+ const numberSvg = r >= 10 && !p.noScale
169
+ ? `<text x="${cx}" y="${(cy + fs * 0.36).toFixed(1)}" text-anchor="middle" font-size="${fs.toFixed(1)}" font-weight="700" fill="${numeralColor(color)}" style="pointer-events:none">${number}</text>`
170
+ : `<text x="${cx}" y="${(cy - r - 3).toFixed(1)}" text-anchor="middle" font-size="10" font-weight="700" fill="${color}" style="pointer-events:none">${number}</text>`;
171
+ return `<g class="bubble${p.noScale ? " no-scale" : ""}" data-v="${e(p.vendorId)}"><circle cx="${cx}" cy="${cy.toFixed(1)}" r="${r.toFixed(1)}"${fill}/>${numberSvg}</g>`;
165
172
  })
166
173
  .join("");
167
174
  const midX = ax.signed ? `<line class="grid" x1="${sx(0).toFixed(0)}" y1="${PAD_TOP}" x2="${sx(0).toFixed(0)}" y2="${H - PAD_BOTTOM}"/>` : "";
@@ -199,17 +206,27 @@ function axisSectionsHtml(config, set) {
199
206
  // when every placeable vendor has one; LOUD count otherwise — never mix
200
207
  // the two semantics on one chart.
201
208
  const scale = computeScaleIndex(config);
209
+ const scaleByVendor = new Map(scale.vendors.map((vendor) => [vendor.vendorId, vendor]));
202
210
  const scaleIndex = new Map(scale.vendors.map((vendor) => [vendor.vendorId, vendor.index]));
203
- const useScale = report.vendors.length > 0 && report.vendors.every((vendorId) => scaleIndex.get(vendorId) !== null && scaleIndex.get(vendorId) !== undefined);
211
+ const estimated = report.vendors.filter((vendorId) => typeof scaleIndex.get(vendorId) === "number");
212
+ // Scale mode needs a real majority of vendors estimated; the stragglers
213
+ // (idea-stage anchors, signal-less vendors) render as minimal dashed
214
+ // bubbles — visibly "no measurable scale", never silently re-sized.
215
+ const useScale = estimated.length >= 2 && estimated.length * 2 >= report.vendors.length;
204
216
  const loudCounts = new Map(report.vendors.map((vendorId) => [vendorId, messageBreadth(vendorId, set.observations).loudCount]));
205
- const maxLoud = Math.max(1, ...loudCounts.values());
206
217
  // Bubble areas stay proportional to the metric; dividing by the max just
207
218
  // spends the full visual range without distorting any ratio.
208
219
  const maxShare = Math.max(1e-9, ...report.vendors.map((vendorId) => scaleIndex.get(vendorId) ?? 0));
209
- const sizeOf = (vendorId) => useScale ? scaleIndex.get(vendorId) / maxShare : (loudCounts.get(vendorId) ?? 0) / maxLoud;
220
+ const sizeOf = (vendorId) => {
221
+ if (!useScale)
222
+ return 0.14; // uniform: size carries NO meaning without scale signals
223
+ const share = scaleIndex.get(vendorId);
224
+ return typeof share === "number" ? share / maxShare : 0;
225
+ };
226
+ const noScaleFor = (vendorId) => useScale && typeof scaleIndex.get(vendorId) !== "number";
210
227
  const sizeCaption = useScale
211
- ? `Dot area &#8733; estimated revenue share of this vendor set (signals: ${e(scale.metricsUsed.join(", "))}; calibrated within-set, ACV-band stratified, citable but NOT audited — see \`market scale\` for estimates and spreads)`
212
- : "Dot area &#8733; LOUD count";
228
+ ? `Dot area &#8733; estimated revenue share of this vendor set (signals: ${e(scale.metricsUsed.join(", "))}; calibrated within-set, ACV-band stratified, citable but NOT audited — see \`market scale\` for estimates and spreads). Dashed outline = no measurable scale signals.`
229
+ : "Dot size carries no meaning on this map (no scaleSignals in the config); the legend lists LOUD counts";
213
230
  const breadthAxis = {
214
231
  id: "breadth",
215
232
  label: "Message breadth",
@@ -247,6 +264,7 @@ function axisSectionsHtml(config, set) {
247
264
  x: xs.get(vendorId),
248
265
  y: ys.get(vendorId),
249
266
  size: sizeOf(vendorId),
267
+ noScale: noScaleFor(vendorId),
250
268
  }));
251
269
  };
252
270
  const [px, py] = config.primaryAxes ?? [axes[0].id, axes[1]?.id ?? "breadth"];
@@ -265,13 +283,35 @@ function axisSectionsHtml(config, set) {
265
283
  const number = numberByVendor.get(point.vendorId);
266
284
  const color = colorByVendor.get(point.vendorId);
267
285
  const isAnchor = point.vendorId === config.anchorVendor;
286
+ const share = scaleIndex.get(point.vendorId);
268
287
  const measure = useScale
269
- ? `${((scaleIndex.get(point.vendorId) ?? 0) * 100).toFixed(1)}%`
288
+ ? typeof share === "number"
289
+ ? `${(share * 100).toFixed(1)}%`
290
+ : "—"
270
291
  : `${loudCounts.get(point.vendorId) ?? 0} loud`;
271
- return `<tr${isAnchor ? ' class="anchor-row"' : ""}><td><span class="swatch" style="background:${color};color:${numeralColor(color)}">${number}</span></td><td>${e(point.name)}${isAnchor ? " ·&nbsp;anchor" : ""}</td><td class="num">${measure}</td></tr>`;
292
+ return `<tr data-v="${e(point.vendorId)}"${isAnchor ? ' class="anchor-row"' : ""}><td><span class="swatch" style="background:${color};color:${numeralColor(color)}">${number}</span></td><td>${e(point.name)}${isAnchor ? " ·&nbsp;anchor" : ""}</td><td class="num">${measure}</td></tr>`;
272
293
  })
273
294
  .join("");
274
295
  const legendMeasureHead = useScale ? "est. share" : "loud";
296
+ const money = (value) => value >= 1e9 ? `$${(value / 1e9).toFixed(1)}B` : value >= 1e6 ? `$${(value / 1e6).toFixed(1)}M` : `$${Math.round(value / 1e3)}K`;
297
+ const tipData = {};
298
+ for (const point of points) {
299
+ const vendorScale = scaleByVendor.get(point.vendorId);
300
+ const lines = [
301
+ `${axInfo.label}: ${point.x.toFixed(2)}`,
302
+ `${ayInfo.label}: ${point.y.toFixed(2)}`,
303
+ `LOUD claims: ${loudCounts.get(point.vendorId) ?? 0}`,
304
+ ];
305
+ if (useScale) {
306
+ if (vendorScale?.estimatedRevenue != null) {
307
+ lines.push(`est. revenue: ~${money(vendorScale.estimatedRevenue)} (${((scaleIndex.get(point.vendorId) ?? 0) * 100).toFixed(1)}% of set${vendorScale.uncertainty && vendorScale.uncertainty > 1 ? `, ×${vendorScale.uncertainty.toFixed(1)} signal spread` : ""})`);
308
+ }
309
+ else {
310
+ lines.push("est. revenue: no measurable scale signals");
311
+ }
312
+ }
313
+ tipData[point.vendorId] = { name: point.name, n: numberByVendor.get(point.vendorId) ?? 0, lines };
314
+ }
275
315
  const strategicMap = `<section>
276
316
  <h2>Strategic map: ${e(axInfo.label)} &#215; ${e(ayInfo.label)}</h2>
277
317
  <figure class="map">
@@ -279,6 +319,47 @@ function axisSectionsHtml(config, set) {
279
319
  ${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor)}
280
320
  <table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
281
321
  </div>
322
+ <div class="map-tip" id="map-tip" hidden></div>
323
+ <script type="application/json" id="map-data">${JSON.stringify(tipData)}</script>
324
+ <script>
325
+ (function () {
326
+ var data = JSON.parse(document.getElementById("map-data").textContent);
327
+ var tip = document.getElementById("map-tip");
328
+ var fig = tip.closest("figure");
329
+ var bubbles = fig.querySelectorAll("g.bubble");
330
+ var rows = fig.querySelectorAll("table.legend tbody tr");
331
+ function show(v, evt) {
332
+ var d = data[v];
333
+ if (!d) return;
334
+ tip.innerHTML = "<b>" + d.n + " · " + d.name + "</b>" + d.lines.map(function (l) { return "<div>" + l + "</div>"; }).join("");
335
+ tip.hidden = false;
336
+ var box = fig.getBoundingClientRect();
337
+ tip.style.left = Math.min(evt.clientX - box.left + 14, box.width - tip.offsetWidth - 8) + "px";
338
+ tip.style.top = (evt.clientY - box.top + 14) + "px";
339
+ }
340
+ function focusOn(v) {
341
+ bubbles.forEach(function (b) {
342
+ b.classList.toggle("dim", b.getAttribute("data-v") !== v);
343
+ if (b.getAttribute("data-v") === v) b.parentNode.appendChild(b); // raise hidden bubbles
344
+ });
345
+ rows.forEach(function (r) { r.classList.toggle("hl", r.getAttribute("data-v") === v); });
346
+ }
347
+ function clear() {
348
+ tip.hidden = true;
349
+ bubbles.forEach(function (b) { b.classList.remove("dim"); });
350
+ rows.forEach(function (r) { r.classList.remove("hl"); });
351
+ }
352
+ bubbles.forEach(function (b) {
353
+ b.addEventListener("mousemove", function (evt) { show(b.getAttribute("data-v"), evt); });
354
+ b.addEventListener("mouseenter", function () { focusOn(b.getAttribute("data-v")); });
355
+ b.addEventListener("mouseleave", clear);
356
+ });
357
+ rows.forEach(function (r) {
358
+ r.addEventListener("mouseenter", function () { focusOn(r.getAttribute("data-v")); });
359
+ r.addEventListener("mouseleave", clear);
360
+ });
361
+ })();
362
+ </script>
282
363
  <figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
283
364
  in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=&#189;) of the claims it
284
365
  voices. ${sizeCaption}. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
@@ -301,8 +382,9 @@ export function marketMapToHtml(config, set) {
301
382
  const anchor = config.anchorVendor;
302
383
  const e = escapeHtml;
303
384
  const axisHtml = axisSectionsHtml(config, set);
304
- const matrixRows = model.orderedClaimIds
305
- .map((claimId) => {
385
+ const vendorNamesById = new Map(config.vendors.map((vendor) => [vendor.id, vendor.name]));
386
+ const frontByClaim = new Map(model.fronts.map((front) => [front.claimId, front]));
387
+ const matrixRow = (claimId) => {
306
388
  const claim = claimsById.get(claimId);
307
389
  if (!claim)
308
390
  return "";
@@ -317,27 +399,87 @@ export function marketMapToHtml(config, set) {
317
399
  return (`<tr class="front-${state}"><th scope="row"><span class="claim-cap">${e(claim.capability.split(":")[0])}</span>` +
318
400
  `<span class="claim-meta">${e(claim.icp)} · ${e(claim.pricingStructure)}</span></th>${cells}` +
319
401
  `<td class="front"><span class="chip chip-${state}">${state.toUpperCase()}</span></td></tr>`);
320
- })
321
- .join("");
322
- const openList = model.orderedClaimIds
323
- .filter((claimId) => {
402
+ };
403
+ // Claims grouped by front state, each group a collapsed <details> whose
404
+ // summary carries the stats a skimmer needs; the full matrix is one click
405
+ // away, not a wall the reader must climb to reach the takeaway.
406
+ const GROUPS = [
407
+ { states: ["open", "vacant"], title: "Open ground", blurb: "no vendor is loud here" },
408
+ { states: ["contested"], title: "Contested fronts", blurb: "2–3 vendors loud" },
409
+ { states: ["owned"], title: "Owned fronts", blurb: "exactly one vendor loud" },
410
+ { states: ["saturated"], title: "Saturated fronts", blurb: "4+ vendors loud" },
411
+ ];
412
+ const groupedMatrix = GROUPS.map((group) => {
413
+ const claimIds = model.orderedClaimIds.filter((claimId) => group.states.includes(stateByClaim.get(claimId) ?? "vacant"));
414
+ if (claimIds.length === 0)
415
+ return "";
416
+ const anchorLoud = anchor
417
+ ? claimIds.filter((claimId) => model.cell(anchor, claimId)?.intensity === "loud").length
418
+ : 0;
419
+ const anchorNote = anchor ? ` · ${vendorNamesById.get(anchor) ?? anchor} loud on ${anchorLoud}` : "";
420
+ return `<details class="claim-group"><summary><b>${e(group.title)}</b> — ${claimIds.length} claim${claimIds.length === 1 ? "" : "s"} <span class="sum-soft">(${e(group.blurb)}${anchorNote})</span></summary>
421
+ <table><thead><tr><th></th>${"${vendorHeads}"}<th></th></tr></thead><tbody>${claimIds.map(matrixRow).join("")}</tbody></table>
422
+ </details>`;
423
+ }).join("");
424
+ // The closing argument: walk from open ground to a reasoned target list.
425
+ const openFronts = model.orderedClaimIds.filter((claimId) => {
324
426
  const state = stateByClaim.get(claimId);
325
427
  return state === "open" || state === "vacant";
326
- })
428
+ });
429
+ const targetItems = openFronts
327
430
  .map((claimId) => {
328
431
  const claim = claimsById.get(claimId);
329
- return `<li><b>${e(claim?.capability.split(":")[0] ?? claimId)}</b> <span class="why">— no vendor is loud here; ${e(claim?.icp ?? "")} cell</span></li>`;
432
+ const front = frontByClaim.get(claimId);
433
+ if (!claim || !front)
434
+ return "";
435
+ const quietNames = front.quietVendorIds.map((id) => vendorNamesById.get(id) ?? id);
436
+ const anchorIntensity = anchor ? model.cell(anchor, claimId)?.intensity ?? "unobservable" : null;
437
+ const nearest = quietNames.length > 0
438
+ ? `Closest contenders (quiet): ${quietNames.join(", ")}.`
439
+ : "Nobody even ships it quietly — vacant ground.";
440
+ const move = anchorIntensity === "quiet"
441
+ ? `${e(vendorNamesById.get(anchor) ?? "The anchor")} already ships this quietly — a promote-to-loud candidate.`
442
+ : anchorIntensity === "absent"
443
+ ? "Unclaimed by the anchor: a first-mover messaging opportunity if the capability is real or buildable."
444
+ : "";
445
+ return `<li><b>${e(claim.capability.split(":")[0])}</b> <span class="sum-soft">(${e(claim.icp)} · ${e(claim.pricingStructure)})</span><br>
446
+ No vendor is loud on this claim. ${e(nearest)} ${move}</li>`;
330
447
  })
331
448
  .join("");
332
- const appendix = model.orderedClaimIds
333
- .flatMap((claimId) => config.vendors.flatMap((vendor) => {
334
- const obs = model.cell(vendor.id, claimId);
335
- if (!obs || obs.evidence.length === 0)
336
- return [];
337
- return obs.evidence.map((evidence) => `<div class="ev"><span class="ev-head">${e(vendor.name)} · ${e(claimId)} · ${obs.intensity.toUpperCase()} (${obs.confidence})</span>` +
338
- `<blockquote>“${e(evidence.text)}”</blockquote>` +
339
- `<span class="ev-src">${e(String(evidence.metadata?.url ?? ""))} · capture ${e(String(evidence.metadata?.captureHash ?? "").slice(0, 12))}</span></div>`);
340
- }))
449
+ const heldFronts = anchor
450
+ ? model.fronts.filter((front) => front.state === "owned" && front.loudVendorIds[0] === anchor)
451
+ : [];
452
+ const heldLine = heldFronts.length > 0
453
+ ? `<p><b>Held ground:</b> ${e(vendorNamesById.get(anchor) ?? "the anchor")} is the sole loud vendor on ${heldFronts
454
+ .map((front) => `<i>${e(claimsById.get(front.claimId)?.capability.split(":")[0] ?? front.claimId)}</i>`)
455
+ .join(", ")} — positions to defend, not abandon.</p>`
456
+ : "";
457
+ const crowdLine = counts.saturated > 0
458
+ ? `<p><b>Crowded ground:</b> ${counts.saturated} claim${counts.saturated === 1 ? " is" : "s are"} saturated (4+ vendors loud) — message budget spent there buys the least differentiation.</p>`
459
+ : "";
460
+ const takeaway = `<section>
461
+ <h2>Where to attack</h2>
462
+ <p class="lede">${openFronts.length === 0 ? "No open fronts this run — every claim has at least one loud vendor. Watch the drift between runs for windows opening." : `${openFronts.length} claim${openFronts.length === 1 ? "" : "s"} in this category ${openFronts.length === 1 ? "is" : "are"} open: buyers can be reached there without out-shouting anyone.`}</p>
463
+ <ul class="targets">${targetItems}</ul>
464
+ ${heldLine}
465
+ ${crowdLine}
466
+ <p class="sum-soft">These are messaging fronts, not verdicts — join the map to CRM ground truth (\`market overlay\`) for evidence-backed OCCUPY / PROMOTE / URGENT / RETREAT directives with win-rate stats.</p>
467
+ </section>`;
468
+ // Evidence grouped by vendor, collapsed: receipts on demand, not a scroll wall.
469
+ const appendixGroups = config.vendors
470
+ .map((vendor) => {
471
+ const items = model.orderedClaimIds.flatMap((claimId) => {
472
+ const obs = model.cell(vendor.id, claimId);
473
+ if (!obs || obs.evidence.length === 0)
474
+ return [];
475
+ return obs.evidence.map((evidence) => `<div class="ev"><span class="ev-head">${e(claimId)} · ${obs.intensity.toUpperCase()} (${obs.confidence})</span>` +
476
+ `<blockquote>“${e(evidence.text)}”</blockquote>` +
477
+ `<span class="ev-src">${e(String(evidence.metadata?.url ?? ""))} · capture ${e(String(evidence.metadata?.captureHash ?? "").slice(0, 12))}</span></div>`);
478
+ });
479
+ if (items.length === 0)
480
+ return "";
481
+ return `<details class="ev-group"><summary><b>${e(vendor.name)}</b> <span class="sum-soft">— ${items.length} quoted span${items.length === 1 ? "" : "s"}</span></summary>${items.join("")}</details>`;
482
+ })
341
483
  .join("");
342
484
  const vendorHeads = config.vendors
343
485
  .map((vendor) => `<th class="vh${vendor.id === anchor ? " anchor-col" : ""}"><span>${e(vendor.name)}</span></th>`)
@@ -357,14 +499,16 @@ h1 { font-size:27px; font-weight:600; line-height:1.2; }
357
499
  .meta { font-size:11px; color:var(--soft); display:flex; gap:18px; flex-wrap:wrap; margin-top:8px; }
358
500
  section { margin-top:44px; }
359
501
  h2 { font-size:17px; font-weight:600; border-bottom:1px solid var(--line); padding-bottom:7px; margin-bottom:4px; }
360
- .fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:16px; }
361
- .fcard { background:#fff; padding:14px 16px 12px; }
362
- .fcard b { display:block; font-size:30px; font-weight:600; line-height:1.1; }
363
- .fcard span { font-size:11px; color:var(--soft); }
364
- .fcard.open b { color:var(--accent); }
365
- .openlist { margin-top:14px; font-size:14.5px; line-height:1.55; }
366
- .openlist li { margin:3px 0 3px 20px; }
367
- .openlist .why { color:var(--soft); font-size:13px; }
502
+ details.claim-group, details.ev-group { border:1px solid var(--line); border-radius:3px; margin-top:10px; }
503
+ details.claim-group summary, details.ev-group summary { cursor:pointer; padding:10px 14px; font-size:14px; list-style-position:inside; }
504
+ details.claim-group summary:hover, details.ev-group summary:hover { background:var(--faint); }
505
+ details.claim-group[open] summary, details.ev-group[open] summary { border-bottom:1px solid var(--line); }
506
+ details.claim-group table { margin:4px 12px 12px; width:calc(100% - 24px); }
507
+ details.ev-group .ev { margin:0 14px; }
508
+ .sum-soft { color:var(--soft); font-size:12px; }
509
+ .lede { font-size:16px; margin-top:12px; }
510
+ .targets { margin-top:12px; font-size:14.5px; line-height:1.6; }
511
+ .targets li { margin:10px 0 10px 20px; }
368
512
  .key { display:flex; gap:20px; flex-wrap:wrap; margin:14px 0 8px; font-size:10.5px; color:var(--soft); }
369
513
  .lg { display:inline-flex; align-items:center; gap:6px; }
370
514
  table { border-collapse:collapse; width:100%; margin-top:6px; }
@@ -390,7 +534,15 @@ tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
390
534
  .ev-head { font-size:10.5px; color:var(--soft); font-weight:600; }
391
535
  .ev blockquote { margin:5px 0; font-size:13.5px; line-height:1.5; }
392
536
  .ev-src { font-size:10px; color:var(--soft); word-break:break-all; }
393
- figure.map { margin-top:16px; border:1px solid var(--line); }
537
+ figure.map { margin-top:16px; border:1px solid var(--line); position:relative; }
538
+ g.bubble { cursor:pointer; }
539
+ g.bubble.dim { opacity:0.25; transition:opacity .12s; }
540
+ table.legend tbody tr { cursor:default; }
541
+ table.legend tbody tr.hl td { background:var(--faint); }
542
+ .map-tip { position:absolute; z-index:5; background:#1c1c1c; color:#fff; font-size:11.5px; line-height:1.45;
543
+ padding:8px 10px; border-radius:3px; pointer-events:none; max-width:260px;
544
+ font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
545
+ .map-tip b { display:block; margin-bottom:3px; }
394
546
  .map-row { display:flex; gap:8px; align-items:flex-start; padding:10px 12px 0; }
395
547
  .map-row svg { flex:1 1 62%; min-width:0; }
396
548
  .plot { fill:var(--faint); stroke:var(--line); }
@@ -406,7 +558,7 @@ table.legend tr.anchor-row td { font-weight:700; }
406
558
  figcaption { font-size:11.5px; color:var(--soft); padding:10px 14px 12px; border-top:1px solid var(--line); line-height:1.5; }
407
559
  footer { margin-top:60px; border-top:1px solid var(--ink); padding-top:12px; font-size:11px; color:var(--soft);
408
560
  display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
409
- @media print { body { max-width:none; padding:0 8mm; } section { break-inside:avoid-page; } tr { break-inside:avoid; } .map-row { display:block; } }
561
+ @media print { body { max-width:none; padding:0 8mm; } section { break-inside:avoid-page; } tr { break-inside:avoid; } .map-row { display:block; } .map-tip { display:none; } }
410
562
  @media (max-width:760px) { .map-row { display:block; } }
411
563
  </style></head><body>
412
564
  <header>
@@ -417,34 +569,24 @@ footer { margin-top:60px; border-top:1px solid var(--ink); padding-top:12px; fon
417
569
  <span>extractor: ${e(set.extractor)}</span>
418
570
  </div>
419
571
  </header>
572
+ ${axisHtml.strategicMap}
420
573
  <section>
421
- <h2>Front summary</h2>
422
- <div class="fronts">
423
- <div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
424
- <div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
425
- <div class="fcard"><b>${counts.owned}</b><span>Owned</span></div>
426
- <div class="fcard"><b>${counts.saturated}</b><span>Saturated</span></div>
427
- </div>
428
- <ul class="openlist">${openList}</ul>
429
- </section>
430
- <section>
431
- <h2>Claim × vendor intensity matrix</h2>
574
+ <h2>Claims, front by front</h2>
432
575
  <div class="key">
433
576
  <span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
434
577
  <span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
435
578
  <span class="lg"><i class="g g-absent"></i>ABSENT</span>
436
579
  <span class="lg"><i class="g g-unobservable"></i>UNOBSERVABLE — capture failed</span>
437
580
  </div>
438
- <table>
439
- <thead><tr><th></th>${vendorHeads}<th></th></tr></thead>
440
- <tbody>${matrixRows}</tbody>
441
- </table>
581
+ ${groupedMatrix}
442
582
  </section>
443
- ${axisHtml.strategicMap}
583
+ ${takeaway}
444
584
  <section>
445
585
  <h2>Evidence appendix</h2>
446
- ${appendix}
586
+ <p class="sum-soft">Every loud/quiet reading is grounded in a verbatim span from a stored page capture; expand a vendor for its receipts.</p>
587
+ ${appendixGroups}
447
588
  </section>
589
+ <script>window.addEventListener("beforeprint",function(){document.querySelectorAll("details").forEach(function(d){d.open=true;});});</script>
448
590
  <footer>
449
591
  <span>Generated by fullstackgtm market · deterministic render of ${e(set.runLabel)}</span>
450
592
  <span>Front rule v1: 0 loud=open · 1=owned · 2–3=contested · ≥4=saturated</span>
@@ -0,0 +1,19 @@
1
+ import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
2
+ export type ReassignObjectType = "account" | "contact" | "deal";
3
+ export type ReassignOptions = {
4
+ /** the owner whose records are being handed off */
5
+ fromOwnerId: string;
6
+ /** the receiving owner — must be a known user in the snapshot */
7
+ toOwnerId: string;
8
+ /** which object types to compile plans for (default all three) */
9
+ objects?: ReassignObjectType[];
10
+ /** extra --where scoping, AND-ed into every plan (account fields lifted) */
11
+ where?: string[];
12
+ /** exclude records tied to an open deal in this stage (see module doc) */
13
+ exceptDealStage?: string;
14
+ /** also reassign closed deals (default false: history keeps its owner) */
15
+ includeClosedDeals?: boolean;
16
+ reason?: string;
17
+ maxOperations?: number;
18
+ };
19
+ export declare function buildReassignPlans(snapshot: CanonicalGtmSnapshot, options: ReassignOptions): PatchPlan[];
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The ownership-handoff playbook: `reassign` compiles one bulk-update-style
3
+ * dry-run plan PER object type (accounts, contacts, deals by default) that
4
+ * moves every record owned by --from to --to. It NEVER writes — each plan
5
+ * flows through the same plans-approve → apply gate, and because every plan
6
+ * carries its full filter, eligibility (extra --where scoping AND the
7
+ * --except-deal-stage exclusion) is re-verified per record against a FRESH
8
+ * snapshot at apply time, with mid-apply rechecks. A record that drifts into
9
+ * the exception set mid-run surfaces as a conflict, not a bad write.
10
+ *
11
+ * --except-deal-stage <stage> excludes in-flight business end to end:
12
+ * - deal plans drop deals in that stage (stage!=<stage>), and
13
+ * - ALL plans drop records whose account has an OPEN deal in that stage
14
+ * (accounts: openDealStages!~<stage>; deals/contacts:
15
+ * account.openDealStages!~<stage>).
16
+ *
17
+ * Deal plans cover OPEN deals only unless includeClosedDeals is set: closed
18
+ * deals keep their historical owner in a handoff.
19
+ *
20
+ * Extra --where clauses are account-scoped where needed: a clause on a field
21
+ * that only exists on accounts (e.g. domain~.de) is lifted to the relational
22
+ * pseudo-field (account.domain~.de) for contact and deal plans, so one
23
+ * invocation scopes all three object types consistently.
24
+ */
25
+ import { buildBulkUpdatePlan, isFilterableField, parseWhere } from "./bulkUpdate.js";
26
+ const ALL_OBJECTS = ["account", "contact", "deal"];
27
+ /**
28
+ * Scope an extra --where clause to one object type: pass through when the
29
+ * field is valid there, lift account-only fields to account.<field> for
30
+ * contacts and deals. Unknown fields fall through to buildBulkUpdatePlan's
31
+ * strict validation, which throws with the valid-field list.
32
+ */
33
+ function scopeWhere(objectType, raw) {
34
+ const clause = parseWhere(raw);
35
+ if (isFilterableField(objectType, clause.field))
36
+ return raw;
37
+ if (objectType !== "account" && isFilterableField(objectType, `account.${clause.field}`)) {
38
+ return `account.${raw}`;
39
+ }
40
+ return raw;
41
+ }
42
+ export function buildReassignPlans(snapshot, options) {
43
+ if (!options.fromOwnerId || !options.toOwnerId) {
44
+ throw new Error("reassign requires both --from <ownerId> and --to <ownerId>.");
45
+ }
46
+ if (options.fromOwnerId === options.toOwnerId) {
47
+ throw new Error("reassign --from and --to are the same owner — nothing to hand off.");
48
+ }
49
+ // The receiving owner must exist: a typo'd --to would otherwise write an
50
+ // invalid owner onto every matched record.
51
+ if (!snapshot.users.some((user) => user.id === options.toOwnerId)) {
52
+ throw new Error(`reassign --to ${options.toOwnerId} is not a known user in the snapshot. Known users: ${snapshot.users
53
+ .map((user) => `${user.id} (${user.name})`)
54
+ .join(", ") || "none"}.`);
55
+ }
56
+ const objects = options.objects ?? ALL_OBJECTS;
57
+ for (const objectType of objects) {
58
+ if (!ALL_OBJECTS.includes(objectType)) {
59
+ throw new Error(`reassign --objects supports account, contact, deal — got "${objectType}".`);
60
+ }
61
+ }
62
+ return objects.map((objectType) => {
63
+ const where = [`ownerId=${options.fromOwnerId}`];
64
+ if (objectType === "deal" && !options.includeClosedDeals) {
65
+ where.push("isClosed=false"); // closed deals keep their historical owner
66
+ }
67
+ for (const extra of options.where ?? []) {
68
+ where.push(scopeWhere(objectType, extra));
69
+ }
70
+ if (options.exceptDealStage) {
71
+ const stage = options.exceptDealStage;
72
+ if (objectType === "deal")
73
+ where.push(`stage!=${stage}`);
74
+ where.push(objectType === "account"
75
+ ? `openDealStages!~${stage}`
76
+ : `account.openDealStages!~${stage}`);
77
+ }
78
+ return buildBulkUpdatePlan(snapshot, {
79
+ objectType,
80
+ where,
81
+ set: { ownerId: options.toOwnerId },
82
+ reason: options.reason ??
83
+ `reassign: hand off ${objectType}s from owner ${options.fromOwnerId} to ${options.toOwnerId}`,
84
+ maxOperations: options.maxOperations,
85
+ });
86
+ });
87
+ }
package/dist/suggest.js CHANGED
@@ -64,11 +64,39 @@ function suggestDealAccount(operation, dealsById, accountsByNorm, accountsById,
64
64
  if (!deal) {
65
65
  return { ...base, suggestedValue: null, confidence: "none", reason: "Deal not found in the snapshot." };
66
66
  }
67
- // Convention: "Contact Name - Company Name". Both signals below are
68
- // independent; agreement upgrades confidence, conflict downgrades it.
69
- const separatorIndex = deal.name.indexOf(" - ");
70
- const left = separatorIndex >= 0 ? deal.name.slice(0, separatorIndex) : deal.name;
71
- const right = separatorIndex >= 0 ? deal.name.slice(separatorIndex + 3).trim() : "";
67
+ // Convention 1: "Contact Name - Company Name" (hyphen, en or em dash).
68
+ // Both signals below are independent; agreement upgrades confidence,
69
+ // conflict downgrades it.
70
+ const separator = [" - ", " – ", " "]
71
+ .map((s) => ({ s, index: deal.name.indexOf(s) }))
72
+ .filter(({ index }) => index >= 0)
73
+ .sort((a, b) => a.index - b.index)[0];
74
+ const left = separator ? deal.name.slice(0, separator.index).trim() : deal.name;
75
+ const right = separator ? deal.name.slice(separator.index + separator.s.length).trim() : "";
76
+ // Convention 2: "Company - Deal descriptor" (e.g. "Globex – Expansion",
77
+ // "Hooli – New Business"). When the right side is purely deal-descriptor
78
+ // words, the company is on the LEFT. Only an exact account-name match
79
+ // counts as high; an unknown left side proposes a create — the engine
80
+ // never guesses at an existing record.
81
+ if (left && right && isDealDescriptor(right)) {
82
+ const leftMatch = accountsByNorm.get(normalize(left));
83
+ if (leftMatch) {
84
+ return {
85
+ ...base,
86
+ suggestedValue: leftMatch.id,
87
+ confidence: "high",
88
+ reason: `Deal name leads with the exact account name "${leftMatch.name}" ("${right}" is a deal descriptor, not a company).`,
89
+ };
90
+ }
91
+ if (!contactsByName.get(normalize(left))) {
92
+ return {
93
+ ...base,
94
+ suggestedValue: `create:${left}`,
95
+ confidence: "create",
96
+ reason: `No account named "${left}" exists ("${right}" is a deal descriptor) — approving creates the company, then links.`,
97
+ };
98
+ }
99
+ }
72
100
  // Signal 1: company-name match against account names.
73
101
  let nameMatch = null;
74
102
  let nameMatchKind = "";
@@ -135,6 +163,23 @@ function suggestDealAccount(operation, dealsById, accountsByNorm, accountsById,
135
163
  reason: `No account named "${right}" exists and contact "${left}" has none — approving creates the company, then links.`,
136
164
  };
137
165
  }
166
+ // Convention 3: "<Company> <descriptor…>" without a separator (e.g.
167
+ // "INITECH renewal"): strip trailing descriptor words and accept only an
168
+ // exact account-name match on what remains.
169
+ if (!separator) {
170
+ const words = normalize(deal.name).split(" ").filter(Boolean);
171
+ while (words.length > 1 && DEAL_DESCRIPTOR_WORDS.has(words[words.length - 1]))
172
+ words.pop();
173
+ const strippedMatch = words.length > 0 ? accountsByNorm.get(words.join(" ")) : undefined;
174
+ if (strippedMatch) {
175
+ return {
176
+ ...base,
177
+ suggestedValue: strippedMatch.id,
178
+ confidence: "high",
179
+ reason: `Deal name leads with the exact account name "${strippedMatch.name}" (trailing words are deal descriptors).`,
180
+ };
181
+ }
182
+ }
138
183
  return {
139
184
  ...base,
140
185
  suggestedValue: null,
@@ -144,6 +189,23 @@ function suggestDealAccount(operation, dealsById, accountsByNorm, accountsById,
144
189
  : `Deal name "${deal.name}" has no "Contact - Company" pattern to derive a company from. Supply --value ${operation.id}=<accountId> or create:<Company Name>.`,
145
190
  };
146
191
  }
192
+ /**
193
+ * Words that describe the deal rather than name a company. Used to recognize
194
+ * the "Company - Deal descriptor" naming convention: a segment counts as a
195
+ * descriptor only when EVERY word is in this list, so any real company name
196
+ * ("Brand New Startup") falls through to the contact/company conventions.
197
+ */
198
+ const DEAL_DESCRIPTOR_WORDS = new Set([
199
+ "renewal", "expansion", "pilot", "annual", "monthly", "platform", "new", "business",
200
+ "add", "on", "addon", "upsell", "upgrade", "trial", "poc", "proof", "concept",
201
+ "subscription", "license", "licence", "contract", "opportunity", "deal", "engagement",
202
+ "implementation", "onboarding", "services", "inbound", "outbound",
203
+ "q1", "q2", "q3", "q4",
204
+ ]);
205
+ function isDealDescriptor(segment) {
206
+ const words = normalize(segment).split(" ").filter(Boolean);
207
+ return words.length > 0 && words.every((word) => DEAL_DESCRIPTOR_WORDS.has(word));
208
+ }
147
209
  /**
148
210
  * Survivor selection for merge_records. Ranking is deterministic and
149
211
  * evidence-based: most complete record first (count of populated canonical
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.21.1",
3
+ "version": "0.22.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",