fullstackgtm 0.21.2 → 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.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";
@@ -382,8 +382,9 @@ export function marketMapToHtml(config, set) {
382
382
  const anchor = config.anchorVendor;
383
383
  const e = escapeHtml;
384
384
  const axisHtml = axisSectionsHtml(config, set);
385
- const matrixRows = model.orderedClaimIds
386
- .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) => {
387
388
  const claim = claimsById.get(claimId);
388
389
  if (!claim)
389
390
  return "";
@@ -398,27 +399,87 @@ export function marketMapToHtml(config, set) {
398
399
  return (`<tr class="front-${state}"><th scope="row"><span class="claim-cap">${e(claim.capability.split(":")[0])}</span>` +
399
400
  `<span class="claim-meta">${e(claim.icp)} · ${e(claim.pricingStructure)}</span></th>${cells}` +
400
401
  `<td class="front"><span class="chip chip-${state}">${state.toUpperCase()}</span></td></tr>`);
401
- })
402
- .join("");
403
- const openList = model.orderedClaimIds
404
- .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) => {
405
426
  const state = stateByClaim.get(claimId);
406
427
  return state === "open" || state === "vacant";
407
- })
428
+ });
429
+ const targetItems = openFronts
408
430
  .map((claimId) => {
409
431
  const claim = claimsById.get(claimId);
410
- 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>`;
411
447
  })
412
448
  .join("");
413
- const appendix = model.orderedClaimIds
414
- .flatMap((claimId) => config.vendors.flatMap((vendor) => {
415
- const obs = model.cell(vendor.id, claimId);
416
- if (!obs || obs.evidence.length === 0)
417
- return [];
418
- return obs.evidence.map((evidence) => `<div class="ev"><span class="ev-head">${e(vendor.name)} · ${e(claimId)} · ${obs.intensity.toUpperCase()} (${obs.confidence})</span>` +
419
- `<blockquote>“${e(evidence.text)}”</blockquote>` +
420
- `<span class="ev-src">${e(String(evidence.metadata?.url ?? ""))} · capture ${e(String(evidence.metadata?.captureHash ?? "").slice(0, 12))}</span></div>`);
421
- }))
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
+ })
422
483
  .join("");
423
484
  const vendorHeads = config.vendors
424
485
  .map((vendor) => `<th class="vh${vendor.id === anchor ? " anchor-col" : ""}"><span>${e(vendor.name)}</span></th>`)
@@ -438,14 +499,16 @@ h1 { font-size:27px; font-weight:600; line-height:1.2; }
438
499
  .meta { font-size:11px; color:var(--soft); display:flex; gap:18px; flex-wrap:wrap; margin-top:8px; }
439
500
  section { margin-top:44px; }
440
501
  h2 { font-size:17px; font-weight:600; border-bottom:1px solid var(--line); padding-bottom:7px; margin-bottom:4px; }
441
- .fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:16px; }
442
- .fcard { background:#fff; padding:14px 16px 12px; }
443
- .fcard b { display:block; font-size:30px; font-weight:600; line-height:1.1; }
444
- .fcard span { font-size:11px; color:var(--soft); }
445
- .fcard.open b { color:var(--accent); }
446
- .openlist { margin-top:14px; font-size:14.5px; line-height:1.55; }
447
- .openlist li { margin:3px 0 3px 20px; }
448
- .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; }
449
512
  .key { display:flex; gap:20px; flex-wrap:wrap; margin:14px 0 8px; font-size:10.5px; color:var(--soft); }
450
513
  .lg { display:inline-flex; align-items:center; gap:6px; }
451
514
  table { border-collapse:collapse; width:100%; margin-top:6px; }
@@ -506,34 +569,24 @@ footer { margin-top:60px; border-top:1px solid var(--ink); padding-top:12px; fon
506
569
  <span>extractor: ${e(set.extractor)}</span>
507
570
  </div>
508
571
  </header>
572
+ ${axisHtml.strategicMap}
509
573
  <section>
510
- <h2>Front summary</h2>
511
- <div class="fronts">
512
- <div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
513
- <div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
514
- <div class="fcard"><b>${counts.owned}</b><span>Owned</span></div>
515
- <div class="fcard"><b>${counts.saturated}</b><span>Saturated</span></div>
516
- </div>
517
- <ul class="openlist">${openList}</ul>
518
- </section>
519
- <section>
520
- <h2>Claim × vendor intensity matrix</h2>
574
+ <h2>Claims, front by front</h2>
521
575
  <div class="key">
522
576
  <span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
523
577
  <span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
524
578
  <span class="lg"><i class="g g-absent"></i>ABSENT</span>
525
579
  <span class="lg"><i class="g g-unobservable"></i>UNOBSERVABLE — capture failed</span>
526
580
  </div>
527
- <table>
528
- <thead><tr><th></th>${vendorHeads}<th></th></tr></thead>
529
- <tbody>${matrixRows}</tbody>
530
- </table>
581
+ ${groupedMatrix}
531
582
  </section>
532
- ${axisHtml.strategicMap}
583
+ ${takeaway}
533
584
  <section>
534
585
  <h2>Evidence appendix</h2>
535
- ${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}
536
588
  </section>
589
+ <script>window.addEventListener("beforeprint",function(){document.querySelectorAll("details").forEach(function(d){d.open=true;});});</script>
537
590
  <footer>
538
591
  <span>Generated by fullstackgtm market · deterministic render of ${e(set.runLabel)}</span>
539
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.2",
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",
package/src/bulkUpdate.ts CHANGED
@@ -27,6 +27,7 @@
27
27
  * `account.ownerId`, `account.contactCount`; accounts get `contactCount`
28
28
  * and `openDealCount`.
29
29
  */
30
+ import { normalizeDomain } from "./merge.ts";
30
31
  import { stableHash } from "./rules.ts";
31
32
  import type {
32
33
  CanonicalGtmSnapshot,
@@ -40,10 +41,23 @@ export type BulkUpdateOptions = {
40
41
  objectType: "account" | "contact" | "deal";
41
42
  /** raw --where expressions, AND-ed together; at least one is required */
42
43
  where: string[];
43
- /** canonical field → new value; one action only */
44
+ /**
45
+ * canonical field → new value; one action only. A value of the form
46
+ * `from:<sourceField>` is resolved PER RECORD from the filter view at
47
+ * plan time (relational pseudo-fields like account.ownerId included);
48
+ * records whose source value is empty are skipped, not failed, and
49
+ * counted in the plan summary.
50
+ */
44
51
  set?: Record<string, string>;
45
52
  /** propose archive_record instead of field writes */
46
53
  archive?: boolean;
54
+ /**
55
+ * bypass the archive duplicate guard: by default --archive refuses when a
56
+ * matched account/contact shares its identity key (normalized domain /
57
+ * lowercased email) with another record — those are duplicates, and
58
+ * archiving a duplicate discards its data where merging preserves it
59
+ */
60
+ forceArchiveDuplicates?: boolean;
47
61
  /** propose create_task on each matched record with this subject/body text */
48
62
  createTask?: string;
49
63
  /** explicit preconditions (field=value), re-verified at apply time */
@@ -131,6 +145,14 @@ const VALID_FIELDS: Record<BulkUpdateOptions["objectType"], Set<string>> = {
131
145
  deal: new Set(["id", "crmId", "accountId", "ownerId", "name", "amount", "currency", "stage", "closeDate", "dealType", "forecastCategory", "nextStep", "probability", "isClosed", "isWon", "lastActivityAt", "lastSyncAt", ...RELATIONAL_FIELDS]),
132
146
  };
133
147
 
148
+ /** True when `field` is filterable for this object type (relational pseudo-fields included). */
149
+ export function isFilterableField(
150
+ objectType: BulkUpdateOptions["objectType"],
151
+ field: string,
152
+ ): boolean {
153
+ return VALID_FIELDS[objectType].has(field);
154
+ }
155
+
134
156
  function assertValidFields(objectType: BulkUpdateOptions["objectType"], clauses: WhereClause[], context: string): void {
135
157
  for (const clause of clauses) {
136
158
  if (!VALID_FIELDS[objectType].has(clause.field)) {
@@ -251,10 +273,25 @@ export function buildBulkUpdatePlan(
251
273
  const clauses = options.where.map(parseWhere);
252
274
  assertValidFields(options.objectType, clauses, "--where");
253
275
  const WRITABLE_BLOCKLIST = new Set(["id", "crmId", "contactCount", "openDealCount", "openDealStages"]);
254
- for (const field of Object.keys(options.set ?? {})) {
276
+ // `from:<sourceField>` values resolve per record from the filter view —
277
+ // the source is validated with the same strictness as filters (relational
278
+ // pseudo-fields allowed; the WRITTEN field still must be canonical).
279
+ const assignments: Array<{ field: string; literal?: string; fromField?: string }> = [];
280
+ for (const [field, value] of Object.entries(options.set ?? {})) {
255
281
  if (!VALID_FIELDS[options.objectType].has(field) || WRITABLE_BLOCKLIST.has(field) || field.includes(".")) {
256
282
  throw new Error(`Cannot --set "${field}" on ${options.objectType}s — not a writable canonical field.`);
257
283
  }
284
+ if (value.startsWith("from:")) {
285
+ const fromField = value.slice("from:".length);
286
+ if (!VALID_FIELDS[options.objectType].has(fromField)) {
287
+ throw new Error(
288
+ `Cannot --set ${field}=from:${fromField} on ${options.objectType}s — unknown source field "${fromField}". Valid fields: ${[...VALID_FIELDS[options.objectType]].join(", ")}.`,
289
+ );
290
+ }
291
+ assignments.push({ field, fromField });
292
+ } else {
293
+ assignments.push({ field, literal: value });
294
+ }
258
295
  }
259
296
  const views = buildViews(snapshot, options.objectType);
260
297
  const matched = views.filter(({ view }) => clauses.every((c) => matches(view, c)));
@@ -264,6 +301,45 @@ export function buildBulkUpdatePlan(
264
301
  );
265
302
  }
266
303
 
304
+ // Archive duplicate guard: archiving a record that shares its identity key
305
+ // with another active record discards data a merge would preserve. Refuse
306
+ // and point at `dedupe` unless explicitly overridden. Deals are exempt —
307
+ // they carry no identity key.
308
+ if (options.archive && options.objectType !== "deal" && !options.forceArchiveDuplicates) {
309
+ const keyName = options.objectType === "account" ? "domain" : "email";
310
+ const keyOf = (record: Record<string, unknown>): string | undefined =>
311
+ options.objectType === "account"
312
+ ? normalizeDomain(record.domain as string | undefined)
313
+ : ((record.email as string | undefined)?.trim().toLowerCase() || undefined);
314
+ const allRecords = snapshot[COLLECTIONS[options.objectType]] as Array<Record<string, unknown>>;
315
+ const byKey = new Map<string, Array<Record<string, unknown>>>();
316
+ for (const record of allRecords) {
317
+ const key = keyOf(record);
318
+ if (!key) continue;
319
+ const existing = byKey.get(key) ?? [];
320
+ existing.push(record);
321
+ byKey.set(key, existing);
322
+ }
323
+ const collisions: string[] = [];
324
+ for (const { record } of matched) {
325
+ const key = keyOf(record);
326
+ if (!key) continue;
327
+ const others = (byKey.get(key) ?? []).filter((other) => other.id !== record.id);
328
+ if (others.length === 0) continue;
329
+ const label = (record.name as string | undefined) ?? (record.email as string | undefined) ?? "";
330
+ collisions.push(
331
+ `${options.objectType} ${record.id}${label ? ` "${label}"` : ""} shares ${keyName} "${key}" with ${others
332
+ .map((other) => `${other.id}${other.name ? ` "${other.name}"` : ""}`)
333
+ .join(", ")}`,
334
+ );
335
+ }
336
+ if (collisions.length > 0) {
337
+ throw new Error(
338
+ `Refusing to archive: ${collisions.length} matched record(s) look like duplicates of other records — archiving a duplicate DISCARDS its data, merging preserves it. Use \`fullstackgtm dedupe ${options.objectType} --key ${keyName}\` (merge_records) instead, or pass --force-archive-duplicates to archive anyway.\n - ${collisions.join("\n - ")}`,
339
+ );
340
+ }
341
+ }
342
+
267
343
  // Preconditions: explicit --require, plus every equality filter on a real
268
344
  // (re-readable, non-relational) field. The premise the plan was built on
269
345
  // is re-verified per record at apply time.
@@ -295,7 +371,9 @@ export function buildBulkUpdatePlan(
295
371
  const reason = options.reason ?? `bulk-update: ${action} where ${whereText}`;
296
372
 
297
373
  const operations: PatchOperation[] = [];
298
- for (const { record } of matched) {
374
+ // records skipped because a from:<sourceField> value was empty, per source
375
+ const skippedBySource = new Map<string, number>();
376
+ for (const { record, view } of matched) {
299
377
  const objectId = String(record.id);
300
378
  const groupId = `grp_${options.objectType}_${objectId}`;
301
379
  const preconditions = preconditionSpecs.map((p) => ({
@@ -338,7 +416,29 @@ export function buildBulkUpdatePlan(
338
416
  });
339
417
  continue;
340
418
  }
341
- for (const [field, value] of Object.entries(options.set!)) {
419
+ // Resolve every assignment for this record BEFORE emitting any of its
420
+ // operations: a record whose from:<sourceField> resolves empty is
421
+ // skipped whole (its operations share a groupId — half a record's
422
+ // updates is exactly what grouping exists to prevent).
423
+ const resolved: Array<{ field: string; value: string }> = [];
424
+ let emptySource: string | null = null;
425
+ for (const assignment of assignments) {
426
+ if (assignment.fromField !== undefined) {
427
+ const value = fieldValue(view, assignment.fromField);
428
+ if (value === "") {
429
+ emptySource = assignment.fromField;
430
+ break;
431
+ }
432
+ resolved.push({ field: assignment.field, value });
433
+ } else {
434
+ resolved.push({ field: assignment.field, value: assignment.literal! });
435
+ }
436
+ }
437
+ if (emptySource !== null) {
438
+ skippedBySource.set(emptySource, (skippedBySource.get(emptySource) ?? 0) + 1);
439
+ continue;
440
+ }
441
+ for (const { field, value } of resolved) {
342
442
  operations.push({
343
443
  ...shared,
344
444
  id: `op_${stableHash(`bulk-set:${options.objectType}:${objectId}:${field}:${value}`)}`,
@@ -360,13 +460,16 @@ export function buildBulkUpdatePlan(
360
460
  if (failure) throw new Error(`${failure} The guard already fails against the current snapshot — the plan would never apply.`);
361
461
  }
362
462
 
463
+ const skippedText = [...skippedBySource.entries()]
464
+ .map(([sourceField, count]) => ` ${count} skipped: empty ${sourceField}.`)
465
+ .join("");
363
466
  return {
364
- id: `patch_plan_${stableHash(`bulk:${snapshot.provider}:${snapshot.generatedAt}:${whereText}:${action}:${operations.length}`)}`,
467
+ id: `patch_plan_${stableHash(`bulk:${snapshot.provider}:${snapshot.generatedAt}:${options.objectType}:${whereText}:${action}:${operations.length}`)}`,
365
468
  title: `Bulk update: ${options.objectType}s where ${whereText}`,
366
469
  createdAt: snapshot.generatedAt,
367
470
  status: operations.length > 0 ? "needs_approval" : "draft",
368
471
  dryRun: true,
369
- summary: `${matched.length} ${COLLECTIONS[options.objectType]} matched (${whereText}); ${operations.length} proposed dry-run operations (${action}).${guards.length > 0 ? ` ${guards.length} apply-time guard(s).` : ""}`,
472
+ summary: `${matched.length} ${COLLECTIONS[options.objectType]} matched (${whereText}); ${operations.length} proposed dry-run operations (${action}).${skippedText}${guards.length > 0 ? ` ${guards.length} apply-time guard(s).` : ""}`,
370
473
  findings: [],
371
474
  operations,
372
475
  filter: { objectType: options.objectType, where: options.where },