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/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
  The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
7
7
 
8
+ ## [0.22.0] — 2026-06-12
9
+
10
+ The report becomes a narrative: map → claims → where to attack.
11
+
12
+ ### Changed
13
+
14
+ - **Reading order rebuilt around the insight.** The strategic map is now the
15
+ hero, directly under the header: contenders and their positions first.
16
+ The claim detail follows, then the report CLOSES on the reasoned takeaway.
17
+ The front-summary stat cards are gone (numbers without referents).
18
+ - **"Where to attack"** — a generated closing section that walks the open
19
+ fronts as an argument: each open claim names its closest quiet contenders,
20
+ whether the anchor already ships it quietly (promote candidate) or it's
21
+ unclaimed (first-mover), plus held ground (anchor-owned fronts to defend)
22
+ and crowded ground (saturated fronts where message budget buys least).
23
+ Ends by pointing at `market overlay` for evidence-backed directives.
24
+ - **Claims grouped and collapsed**: the matrix splits into Open / Contested /
25
+ Owned / Saturated `<details>` groups, default collapsed, each summary
26
+ carrying the skimmer's stats (count, definition of the state, anchor's
27
+ loud count within the group).
28
+ - **Evidence appendix grouped by vendor**, collapsed — receipts on demand.
29
+ All groups auto-expand on print (beforeprint).
30
+
8
31
  ## [0.21.2] — 2026-06-12
9
32
 
10
33
  Scatter interactivity + honest sizing fallbacks.
@@ -3,10 +3,23 @@ export type BulkUpdateOptions = {
3
3
  objectType: "account" | "contact" | "deal";
4
4
  /** raw --where expressions, AND-ed together; at least one is required */
5
5
  where: string[];
6
- /** canonical field → new value; one action only */
6
+ /**
7
+ * canonical field → new value; one action only. A value of the form
8
+ * `from:<sourceField>` is resolved PER RECORD from the filter view at
9
+ * plan time (relational pseudo-fields like account.ownerId included);
10
+ * records whose source value is empty are skipped, not failed, and
11
+ * counted in the plan summary.
12
+ */
7
13
  set?: Record<string, string>;
8
14
  /** propose archive_record instead of field writes */
9
15
  archive?: boolean;
16
+ /**
17
+ * bypass the archive duplicate guard: by default --archive refuses when a
18
+ * matched account/contact shares its identity key (normalized domain /
19
+ * lowercased email) with another record — those are duplicates, and
20
+ * archiving a duplicate discards its data where merging preserves it
21
+ */
22
+ forceArchiveDuplicates?: boolean;
10
23
  /** propose create_task on each matched record with this subject/body text */
11
24
  createTask?: string;
12
25
  /** explicit preconditions (field=value), re-verified at apply time */
@@ -28,6 +41,8 @@ type WhereClause = {
28
41
  raw: string;
29
42
  };
30
43
  export declare function parseWhere(expr: string): WhereClause;
44
+ /** True when `field` is filterable for this object type (relational pseudo-fields included). */
45
+ export declare function isFilterableField(objectType: BulkUpdateOptions["objectType"], field: string): boolean;
31
46
  export declare function parseGuard(raw: string): PlanGuard;
32
47
  /** Ids of records matching a filter — used for apply-time filter re-verification. */
33
48
  export declare function eligibleIds(snapshot: CanonicalGtmSnapshot, objectType: BulkUpdateOptions["objectType"], where: string[]): Set<string>;
@@ -27,6 +27,7 @@
27
27
  * `account.ownerId`, `account.contactCount`; accounts get `contactCount`
28
28
  * and `openDealCount`.
29
29
  */
30
+ import { normalizeDomain } from "./merge.js";
30
31
  import { stableHash } from "./rules.js";
31
32
  const FIELD_PATTERN = "[a-zA-Z][a-zA-Z0-9_]*(?:\\.[a-zA-Z][a-zA-Z0-9_]*)?";
32
33
  export function parseWhere(expr) {
@@ -91,6 +92,10 @@ const VALID_FIELDS = {
91
92
  contact: new Set(["id", "crmId", "accountId", "firstName", "lastName", "email", "phone", "title", "ownerId", "lastSyncAt", ...RELATIONAL_FIELDS]),
92
93
  deal: new Set(["id", "crmId", "accountId", "ownerId", "name", "amount", "currency", "stage", "closeDate", "dealType", "forecastCategory", "nextStep", "probability", "isClosed", "isWon", "lastActivityAt", "lastSyncAt", ...RELATIONAL_FIELDS]),
93
94
  };
95
+ /** True when `field` is filterable for this object type (relational pseudo-fields included). */
96
+ export function isFilterableField(objectType, field) {
97
+ return VALID_FIELDS[objectType].has(field);
98
+ }
94
99
  function assertValidFields(objectType, clauses, context) {
95
100
  for (const clause of clauses) {
96
101
  if (!VALID_FIELDS[objectType].has(clause.field)) {
@@ -192,16 +197,66 @@ export function buildBulkUpdatePlan(snapshot, options) {
192
197
  const clauses = options.where.map(parseWhere);
193
198
  assertValidFields(options.objectType, clauses, "--where");
194
199
  const WRITABLE_BLOCKLIST = new Set(["id", "crmId", "contactCount", "openDealCount", "openDealStages"]);
195
- for (const field of Object.keys(options.set ?? {})) {
200
+ // `from:<sourceField>` values resolve per record from the filter view —
201
+ // the source is validated with the same strictness as filters (relational
202
+ // pseudo-fields allowed; the WRITTEN field still must be canonical).
203
+ const assignments = [];
204
+ for (const [field, value] of Object.entries(options.set ?? {})) {
196
205
  if (!VALID_FIELDS[options.objectType].has(field) || WRITABLE_BLOCKLIST.has(field) || field.includes(".")) {
197
206
  throw new Error(`Cannot --set "${field}" on ${options.objectType}s — not a writable canonical field.`);
198
207
  }
208
+ if (value.startsWith("from:")) {
209
+ const fromField = value.slice("from:".length);
210
+ if (!VALID_FIELDS[options.objectType].has(fromField)) {
211
+ throw new Error(`Cannot --set ${field}=from:${fromField} on ${options.objectType}s — unknown source field "${fromField}". Valid fields: ${[...VALID_FIELDS[options.objectType]].join(", ")}.`);
212
+ }
213
+ assignments.push({ field, fromField });
214
+ }
215
+ else {
216
+ assignments.push({ field, literal: value });
217
+ }
199
218
  }
200
219
  const views = buildViews(snapshot, options.objectType);
201
220
  const matched = views.filter(({ view }) => clauses.every((c) => matches(view, c)));
202
221
  if (matched.length > maxOperations) {
203
222
  throw new Error(`Filter matched ${matched.length} ${COLLECTIONS[options.objectType]} — above the ${maxOperations}-record safety cap. Narrow the --where filter or raise --max-operations explicitly.`);
204
223
  }
224
+ // Archive duplicate guard: archiving a record that shares its identity key
225
+ // with another active record discards data a merge would preserve. Refuse
226
+ // and point at `dedupe` unless explicitly overridden. Deals are exempt —
227
+ // they carry no identity key.
228
+ if (options.archive && options.objectType !== "deal" && !options.forceArchiveDuplicates) {
229
+ const keyName = options.objectType === "account" ? "domain" : "email";
230
+ const keyOf = (record) => options.objectType === "account"
231
+ ? normalizeDomain(record.domain)
232
+ : (record.email?.trim().toLowerCase() || undefined);
233
+ const allRecords = snapshot[COLLECTIONS[options.objectType]];
234
+ const byKey = new Map();
235
+ for (const record of allRecords) {
236
+ const key = keyOf(record);
237
+ if (!key)
238
+ continue;
239
+ const existing = byKey.get(key) ?? [];
240
+ existing.push(record);
241
+ byKey.set(key, existing);
242
+ }
243
+ const collisions = [];
244
+ for (const { record } of matched) {
245
+ const key = keyOf(record);
246
+ if (!key)
247
+ continue;
248
+ const others = (byKey.get(key) ?? []).filter((other) => other.id !== record.id);
249
+ if (others.length === 0)
250
+ continue;
251
+ const label = record.name ?? record.email ?? "";
252
+ collisions.push(`${options.objectType} ${record.id}${label ? ` "${label}"` : ""} shares ${keyName} "${key}" with ${others
253
+ .map((other) => `${other.id}${other.name ? ` "${other.name}"` : ""}`)
254
+ .join(", ")}`);
255
+ }
256
+ if (collisions.length > 0) {
257
+ throw new Error(`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 - ")}`);
258
+ }
259
+ }
205
260
  // Preconditions: explicit --require, plus every equality filter on a real
206
261
  // (re-readable, non-relational) field. The premise the plan was built on
207
262
  // is re-verified per record at apply time.
@@ -236,7 +291,9 @@ export function buildBulkUpdatePlan(snapshot, options) {
236
291
  : `set ${Object.entries(options.set).map(([k, v]) => `${k}=${v}`).join(", ")}`;
237
292
  const reason = options.reason ?? `bulk-update: ${action} where ${whereText}`;
238
293
  const operations = [];
239
- for (const { record } of matched) {
294
+ // records skipped because a from:<sourceField> value was empty, per source
295
+ const skippedBySource = new Map();
296
+ for (const { record, view } of matched) {
240
297
  const objectId = String(record.id);
241
298
  const groupId = `grp_${options.objectType}_${objectId}`;
242
299
  const preconditions = preconditionSpecs.map((p) => ({
@@ -279,7 +336,30 @@ export function buildBulkUpdatePlan(snapshot, options) {
279
336
  });
280
337
  continue;
281
338
  }
282
- for (const [field, value] of Object.entries(options.set)) {
339
+ // Resolve every assignment for this record BEFORE emitting any of its
340
+ // operations: a record whose from:<sourceField> resolves empty is
341
+ // skipped whole (its operations share a groupId — half a record's
342
+ // updates is exactly what grouping exists to prevent).
343
+ const resolved = [];
344
+ let emptySource = null;
345
+ for (const assignment of assignments) {
346
+ if (assignment.fromField !== undefined) {
347
+ const value = fieldValue(view, assignment.fromField);
348
+ if (value === "") {
349
+ emptySource = assignment.fromField;
350
+ break;
351
+ }
352
+ resolved.push({ field: assignment.field, value });
353
+ }
354
+ else {
355
+ resolved.push({ field: assignment.field, value: assignment.literal });
356
+ }
357
+ }
358
+ if (emptySource !== null) {
359
+ skippedBySource.set(emptySource, (skippedBySource.get(emptySource) ?? 0) + 1);
360
+ continue;
361
+ }
362
+ for (const { field, value } of resolved) {
283
363
  operations.push({
284
364
  ...shared,
285
365
  id: `op_${stableHash(`bulk-set:${options.objectType}:${objectId}:${field}:${value}`)}`,
@@ -300,13 +380,16 @@ export function buildBulkUpdatePlan(snapshot, options) {
300
380
  if (failure)
301
381
  throw new Error(`${failure} The guard already fails against the current snapshot — the plan would never apply.`);
302
382
  }
383
+ const skippedText = [...skippedBySource.entries()]
384
+ .map(([sourceField, count]) => ` ${count} skipped: empty ${sourceField}.`)
385
+ .join("");
303
386
  return {
304
- id: `patch_plan_${stableHash(`bulk:${snapshot.provider}:${snapshot.generatedAt}:${whereText}:${action}:${operations.length}`)}`,
387
+ id: `patch_plan_${stableHash(`bulk:${snapshot.provider}:${snapshot.generatedAt}:${options.objectType}:${whereText}:${action}:${operations.length}`)}`,
305
388
  title: `Bulk update: ${options.objectType}s where ${whereText}`,
306
389
  createdAt: snapshot.generatedAt,
307
390
  status: operations.length > 0 ? "needs_approval" : "draft",
308
391
  dryRun: true,
309
- summary: `${matched.length} ${COLLECTIONS[options.objectType]} matched (${whereText}); ${operations.length} proposed dry-run operations (${action}).${guards.length > 0 ? ` ${guards.length} apply-time guard(s).` : ""}`,
392
+ 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).` : ""}`,
310
393
  findings: [],
311
394
  operations,
312
395
  filter: { objectType: options.objectType, where: options.where },
package/dist/cli.js CHANGED
@@ -27,6 +27,8 @@ import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
27
27
  import { DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
28
28
  import { resolveRecord } from "./resolve.js";
29
29
  import { buildBulkUpdatePlan } from "./bulkUpdate.js";
30
+ import { buildDedupePlan } from "./dedupe.js";
31
+ import { buildReassignPlans } from "./reassign.js";
30
32
  import { suggestValues } from "./suggest.js";
31
33
  function usage() {
32
34
  return `FullStackGTM — audit GTM data across providers, propose reviewable patch plans,
@@ -79,7 +81,7 @@ Usage:
79
81
  against the stored capture it cites before it's accepted — then
80
82
  compute deterministic front states and drift, render the field
81
83
  report. refresh = capture → classify → drift → report in one step
82
- fullstackgtm bulk-update <account|contact|deal> --where <expr> [--where …] (--set <field>=<value> [--set …] | --archive | --create-task <text>) [--require <field>=<value> …] [--guard <object>:<where>[;<where>]:<none|some> …] [source options] [--save] [--json] [--out <path>]
84
+ fullstackgtm bulk-update <account|contact|deal> --where <expr> [--where …] (--set <field>=<value> [--set …] | --archive [--force-archive-duplicates] | --create-task <text>) [--require <field>=<value> …] [--guard <object>:<where>[;<where>]:<none|some> …] [source options] [--save] [--json] [--out <path>]
83
85
  governed generic writes: filter the snapshot
84
86
  (field=value, field!=value, field~substr, field!~substr,
85
87
  field:empty, field:notempty, '|' = any-of; canonical fields
@@ -91,6 +93,35 @@ Usage:
91
93
  apply time (incl. mid-apply rechecks); equality filters
92
94
  double as preconditions; per-record ops apply
93
95
  all-or-nothing; guards assert cross-record conditions.
96
+ --set <field>=from:<sourceField> derives the value PER
97
+ RECORD from the snapshot (relational sources like
98
+ account.ownerId included); records whose source is empty
99
+ are skipped and counted, never guessed. --archive refuses
100
+ records that share their identity key (account domain /
101
+ contact email) with another record — merge those with
102
+ \`dedupe\` instead, or --force-archive-duplicates.
103
+ fullstackgtm dedupe <account|contact|deal> --key <domain|email|name> [--keep richest|oldest] [source options] [--reason <text>] [--max-operations <n>] [--save] [--json] [--out <path>]
104
+ find duplicate groups by normalized identity key and build
105
+ a dry-run plan of merge_records operations — one per group,
106
+ deterministic survivor (richest = most populated data
107
+ fields, ties to lowest id; oldest = lowest id). Approve and
108
+ apply like any plan; merges are IRREVERSIBLE on apply.
109
+ fullstackgtm reassign --from <ownerId> --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--except-deal-stage <stage>] [--include-closed-deals] [source options] [--save] [--json] [--out <path>]
110
+ ownership handoff playbook: one bulk-update-style plan per
111
+ object type (ownerId=<from> → <to>). Extra --where scoping
112
+ is account-lifted for deals/contacts (domain~.de becomes
113
+ account.domain~.de); --except-deal-stage <stage> excludes
114
+ deals in that stage AND every record whose account has an
115
+ open deal in it, re-verified per record at apply time.
116
+ Deal plans cover open deals only unless
117
+ --include-closed-deals.
118
+ fullstackgtm fix --rule <ruleId> --provider <name> [--min-confidence high|low] [--include-creates] [--today <iso>] [--yes]
119
+ one-shot composite: audit ONE rule → save the plan →
120
+ suggest values → approve only suggestion-backed operations
121
+ meeting the confidence bar (plus operations that need no
122
+ value) → with --yes, apply through the provider and print a
123
+ stage-by-stage summary. Without --yes it stops after
124
+ approval and prints the apply command.
94
125
  fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
95
126
  derive values for requires_human_* placeholders
96
127
  from snapshot evidence, with confidence + reasons
@@ -1128,27 +1159,194 @@ async function bulkUpdateCommand(args) {
1128
1159
  where,
1129
1160
  set: Object.keys(set).length > 0 ? set : undefined,
1130
1161
  archive: rest.includes("--archive"),
1162
+ forceArchiveDuplicates: rest.includes("--force-archive-duplicates"),
1131
1163
  createTask: option(rest, "--create-task") ?? undefined,
1132
1164
  require: repeatedOption(rest, "--require"),
1133
1165
  guard: repeatedOption(rest, "--guard"),
1134
1166
  reason: option(rest, "--reason") ?? undefined,
1135
1167
  maxOperations: numericOption(rest, "--max-operations"),
1136
1168
  });
1137
- const out = option(rest, "--out");
1169
+ await emitPlan(plan, rest);
1170
+ }
1171
+ /** Shared plan output plumbing: --out, --save (with the approve/apply hint), --json or markdown. */
1172
+ async function emitPlan(plan, args) {
1173
+ const out = option(args, "--out");
1138
1174
  if (out) {
1139
1175
  writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(plan, null, 2)}\n`);
1140
1176
  }
1141
- if (rest.includes("--save")) {
1177
+ if (args.includes("--save")) {
1142
1178
  await createFilePlanStore().save(plan);
1143
1179
  console.error(`Saved plan ${plan.id} (${plan.operations.length} operations). Review with \`fullstackgtm plans show ${plan.id}\`, approve with \`fullstackgtm plans approve ${plan.id} --operations <ids|all>\`, then \`fullstackgtm apply --plan-id ${plan.id} --provider <name>\`.`);
1144
1180
  }
1145
- if (rest.includes("--json")) {
1181
+ if (args.includes("--json")) {
1146
1182
  console.log(JSON.stringify(plan, null, 2));
1147
1183
  }
1148
1184
  else {
1149
1185
  console.log(patchPlanToMarkdown(plan));
1150
1186
  }
1151
1187
  }
1188
+ /**
1189
+ * Governed duplicate cleanup: group by a normalized identity key, propose one
1190
+ * merge_records per duplicate group with a deterministic survivor. Never
1191
+ * writes — approve and apply the plan like any audit plan.
1192
+ */
1193
+ async function dedupeCommand(args) {
1194
+ const [objectType, ...rest] = args;
1195
+ if (!objectType || !["account", "contact", "deal"].includes(objectType)) {
1196
+ throw new Error("Usage: fullstackgtm dedupe <account|contact|deal> --key <domain|email|name> [--keep richest|oldest] [source options] [--reason <text>] [--max-operations <n>] [--save] [--out <path>] [--json]");
1197
+ }
1198
+ const key = option(rest, "--key");
1199
+ if (!key || !["domain", "email", "name"].includes(key)) {
1200
+ throw new Error("dedupe requires --key <domain|email|name> (the identity field duplicates share).");
1201
+ }
1202
+ const keep = option(rest, "--keep") ?? undefined;
1203
+ const snapshot = await readSnapshot(rest);
1204
+ const plan = buildDedupePlan(snapshot, {
1205
+ objectType: objectType,
1206
+ key: key,
1207
+ keep: (keep ?? undefined),
1208
+ reason: option(rest, "--reason") ?? undefined,
1209
+ maxOperations: numericOption(rest, "--max-operations"),
1210
+ });
1211
+ await emitPlan(plan, rest);
1212
+ }
1213
+ /**
1214
+ * Ownership handoff playbook: compile one bulk-update-style plan per object
1215
+ * type. Each plan carries its full filter, so eligibility (including the
1216
+ * --except-deal-stage exclusion) is re-verified per record at apply time.
1217
+ */
1218
+ async function reassignCommand(args) {
1219
+ const from = option(args, "--from");
1220
+ const to = option(args, "--to");
1221
+ if (!from || !to) {
1222
+ throw new Error("Usage: fullstackgtm reassign --from <ownerId> --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--except-deal-stage <stage>] [--include-closed-deals] [source options] [--reason <text>] [--max-operations <n>] [--save] [--out <path>] [--json]");
1223
+ }
1224
+ const objects = option(args, "--objects")
1225
+ ?.split(",")
1226
+ .map((value) => value.trim())
1227
+ .filter(Boolean);
1228
+ const snapshot = await readSnapshot(args);
1229
+ const plans = buildReassignPlans(snapshot, {
1230
+ fromOwnerId: from,
1231
+ toOwnerId: to,
1232
+ objects,
1233
+ where: repeatedOption(args, "--where"),
1234
+ exceptDealStage: option(args, "--except-deal-stage") ?? undefined,
1235
+ includeClosedDeals: args.includes("--include-closed-deals"),
1236
+ reason: option(args, "--reason") ?? undefined,
1237
+ maxOperations: numericOption(args, "--max-operations"),
1238
+ });
1239
+ const out = option(args, "--out");
1240
+ if (out) {
1241
+ writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(plans, null, 2)}\n`);
1242
+ }
1243
+ if (args.includes("--json")) {
1244
+ console.log(JSON.stringify(plans, null, 2));
1245
+ return;
1246
+ }
1247
+ const store = args.includes("--save") ? createFilePlanStore() : null;
1248
+ for (const plan of plans) {
1249
+ if (store)
1250
+ await store.save(plan);
1251
+ console.log(`${plan.id} ${String(plan.operations.length).padStart(3)} operation(s) ${plan.title}`);
1252
+ console.log(` ${plan.summary}`);
1253
+ }
1254
+ if (store) {
1255
+ console.log(`\nSaved ${plans.length} plan(s). For each: \`fullstackgtm plans show <id>\`, \`fullstackgtm plans approve <id> --operations <ids|all>\`, then \`fullstackgtm apply --plan-id <id> --provider <name>\`.`);
1256
+ }
1257
+ else {
1258
+ console.log("\nDry run only — re-run with --save to store the plans for approval.");
1259
+ }
1260
+ }
1261
+ /**
1262
+ * One-shot composite for a single audit rule: audit → save → suggest →
1263
+ * approve only suggestion-backed operations meeting the confidence bar (plus
1264
+ * operations that carry concrete values and need no human input) → apply
1265
+ * (only with --yes). Every stage goes through the same gates as the manual
1266
+ * chain; placeholder values below the bar stay unapproved.
1267
+ */
1268
+ async function fixCommand(args) {
1269
+ const ruleId = option(args, "--rule");
1270
+ const provider = option(args, "--provider");
1271
+ if (!ruleId || !provider) {
1272
+ throw new Error("Usage: fullstackgtm fix --rule <ruleId> --provider <name> [--min-confidence high|low] [--include-creates] [--today <iso>] [--yes]");
1273
+ }
1274
+ const minConfidence = option(args, "--min-confidence") ?? "high";
1275
+ if (!["high", "low"].includes(minConfidence)) {
1276
+ throw new Error("--min-confidence must be high or low");
1277
+ }
1278
+ const includeCreates = args.includes("--include-creates");
1279
+ const loaded = loadConfig(option(args, "--config") ?? undefined);
1280
+ const configured = await resolveConfiguredRules(loaded);
1281
+ const rule = configured.find((candidate) => candidate.id === ruleId);
1282
+ if (!rule) {
1283
+ throw new Error(`Unknown rule: ${ruleId}. Available rules: ${configured.map((r) => r.id).join(", ")}`);
1284
+ }
1285
+ const policy = mergePolicy(defaultPolicy(), loaded?.config);
1286
+ const today = option(args, "--today");
1287
+ if (today)
1288
+ policy.today = today;
1289
+ const snapshot = await readSnapshot(args);
1290
+ const plan = auditSnapshot(snapshot, policy, [rule]);
1291
+ if (plan.operations.length === 0) {
1292
+ console.log(`fix ${ruleId}: audit proposed 0 operations — nothing to fix.`);
1293
+ return;
1294
+ }
1295
+ const store = createFilePlanStore();
1296
+ await store.save(plan);
1297
+ const suggestions = suggestValues(plan, snapshot);
1298
+ const accepted = new Set(minConfidence === "low" ? ["high", "low"] : ["high"]);
1299
+ const overrides = {};
1300
+ let belowBar = 0;
1301
+ for (const suggestion of suggestions) {
1302
+ if (suggestion.suggestedValue &&
1303
+ (accepted.has(suggestion.confidence) || (includeCreates && suggestion.confidence === "create"))) {
1304
+ overrides[suggestion.operationId] = suggestion.suggestedValue;
1305
+ }
1306
+ else {
1307
+ belowBar += 1;
1308
+ }
1309
+ }
1310
+ // Approve operations whose placeholder got a qualifying suggested value,
1311
+ // plus operations that already carry a concrete value (no human input
1312
+ // needed — nothing to guess). Everything else stays unapproved.
1313
+ const placeholderIds = new Set(suggestions.map((suggestion) => suggestion.operationId));
1314
+ const approvedIds = plan.operations
1315
+ .map((operation) => operation.id)
1316
+ .filter((id) => overrides[id] !== undefined || !placeholderIds.has(id));
1317
+ const lines = [
1318
+ `fix ${ruleId} via ${provider}:`,
1319
+ ` proposed: ${plan.operations.length} operation(s) — plan ${plan.id} (saved)`,
1320
+ ` suggested: ${Object.keys(overrides).length} value(s) at ${minConfidence}+ confidence${includeCreates ? " (creates included)" : ""}${belowBar > 0 ? `; ${belowBar} below the bar (left unapproved)` : ""}`,
1321
+ ` approved: ${approvedIds.length} of ${plan.operations.length}`,
1322
+ ];
1323
+ if (approvedIds.length === 0) {
1324
+ lines.push(" applied: 0 — no operation met the confidence bar");
1325
+ console.log(lines.join("\n"));
1326
+ console.log(`\nWiden with --min-confidence low / --include-creates, or approve manually: \`fullstackgtm plans approve ${plan.id} --operations <ids> --value <opId>=<value>\`.`);
1327
+ return;
1328
+ }
1329
+ await store.approveOperations(plan.id, approvedIds, overrides);
1330
+ if (!args.includes("--yes")) {
1331
+ lines.push(" applied: 0 (stopped before apply — pass --yes to write)");
1332
+ console.log(lines.join("\n"));
1333
+ console.log(`\nApply with:\n fullstackgtm apply --plan-id ${plan.id} --provider ${provider}`);
1334
+ return;
1335
+ }
1336
+ const connector = await connectorFor(provider, args);
1337
+ const run = await applyPatchPlan(connector, plan, {
1338
+ approvedOperationIds: approvedIds,
1339
+ valueOverrides: overrides,
1340
+ });
1341
+ await store.recordRun(plan.id, run);
1342
+ const counts = { applied: 0, conflict: 0, skipped: 0, failed: 0 };
1343
+ for (const result of run.results)
1344
+ counts[result.status] = (counts[result.status] ?? 0) + 1;
1345
+ lines.push(` applied: ${counts.applied} · conflicts: ${counts.conflict} · skipped: ${counts.skipped} · failed: ${counts.failed}`);
1346
+ console.log(lines.join("\n"));
1347
+ if (run.status === "failed")
1348
+ process.exitCode = 1;
1349
+ }
1152
1350
  async function suggest(args) {
1153
1351
  const planId = option(args, "--plan-id");
1154
1352
  const planPath = option(args, "--plan");
@@ -1909,6 +2107,18 @@ export async function runCli(argv) {
1909
2107
  await bulkUpdateCommand(args);
1910
2108
  return;
1911
2109
  }
2110
+ if (command === "dedupe") {
2111
+ await dedupeCommand(args);
2112
+ return;
2113
+ }
2114
+ if (command === "reassign") {
2115
+ await reassignCommand(args);
2116
+ return;
2117
+ }
2118
+ if (command === "fix") {
2119
+ await fixCommand(args);
2120
+ return;
2121
+ }
1912
2122
  if (command === "market") {
1913
2123
  await marketCommand(args);
1914
2124
  return;
@@ -0,0 +1,14 @@
1
+ import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
2
+ export type DedupeOptions = {
3
+ objectType: "account" | "contact" | "deal";
4
+ /** identity key records are grouped by (normalized before grouping) */
5
+ key: "domain" | "email" | "name";
6
+ /** survivor selection — deterministic either way (default "richest") */
7
+ keep?: "richest" | "oldest";
8
+ reason?: string;
9
+ /** refuse to build plans larger than this (default 500 operations) */
10
+ maxOperations?: number;
11
+ };
12
+ /** Normalize a record's identity key; undefined when the field is empty. */
13
+ export declare function dedupeKey(record: Record<string, unknown>, key: DedupeOptions["key"]): string | undefined;
14
+ export declare function buildDedupePlan(snapshot: CanonicalGtmSnapshot, options: DedupeOptions): PatchPlan;
package/dist/dedupe.js ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Governed duplicate cleanup: `dedupe` groups records by a normalized
3
+ * identity key (account domain, contact email, or name) and builds a
4
+ * dry-run PatchPlan of merge_records operations — one per duplicate group,
5
+ * with a DETERMINISTIC survivor. It NEVER writes — the plan flows through
6
+ * the same plans-approve → apply gate as every other plan.
7
+ *
8
+ * The merge contract matches the connectors (see mergeRecords in
9
+ * connectors/hubspot.ts): afterValue = the survivor id, beforeValue = the
10
+ * ids of EVERY record in the group (survivor included). Merges are
11
+ * IRREVERSIBLE on every provider that supports them, so every operation is
12
+ * riskLevel high and approvalRequired.
13
+ *
14
+ * Survivor selection ("--keep"):
15
+ * richest (default) the record with the most non-empty canonical data
16
+ * fields (bookkeeping fields like id/crmId/identities
17
+ * don't count); ties break to the lowest numeric id
18
+ * oldest the lowest numeric id (CRMs assign ids in creation
19
+ * order)
20
+ */
21
+ import { normalizeDomain } from "./merge.js";
22
+ import { stableHash } from "./rules.js";
23
+ const COLLECTIONS = {
24
+ account: "accounts",
25
+ contact: "contacts",
26
+ deal: "deals",
27
+ };
28
+ /** Which identity keys make sense per object type. */
29
+ const VALID_KEYS = {
30
+ account: ["domain", "name"],
31
+ contact: ["email", "name"],
32
+ deal: ["name"],
33
+ };
34
+ /**
35
+ * Bookkeeping fields excluded from the richness count: they are populated
36
+ * (or not) by the sync machinery, not by the quality of the record's data,
37
+ * so counting them would let plumbing decide which record survives a merge.
38
+ */
39
+ const NON_DATA_FIELDS = new Set(["id", "provider", "crmId", "identities", "raw", "provenance"]);
40
+ function populatedDataFields(record) {
41
+ return Object.entries(record).filter(([field, value]) => !NON_DATA_FIELDS.has(field) && value !== undefined && value !== null && value !== "").length;
42
+ }
43
+ /** True when id `a` sorts before id `b` — numeric when both ids are numeric. */
44
+ function idBefore(a, b) {
45
+ const numericA = Number(a);
46
+ const numericB = Number(b);
47
+ if (Number.isFinite(numericA) && Number.isFinite(numericB) && numericA !== numericB) {
48
+ return numericA < numericB;
49
+ }
50
+ return a < b;
51
+ }
52
+ /** Normalize a record's identity key; undefined when the field is empty. */
53
+ export function dedupeKey(record, key) {
54
+ if (key === "domain")
55
+ return normalizeDomain(record.domain);
56
+ const raw = record[key];
57
+ if (raw === undefined || raw === null)
58
+ return undefined;
59
+ const normalized = String(raw).trim().toLowerCase();
60
+ return normalized || undefined;
61
+ }
62
+ export function buildDedupePlan(snapshot, options) {
63
+ const keep = options.keep ?? "richest";
64
+ const maxOperations = options.maxOperations ?? 500;
65
+ if (!VALID_KEYS[options.objectType].includes(options.key)) {
66
+ throw new Error(`Cannot dedupe ${COLLECTIONS[options.objectType]} by "${options.key}". Valid keys for ${options.objectType}s: ${VALID_KEYS[options.objectType].join(", ")}.`);
67
+ }
68
+ if (keep !== "richest" && keep !== "oldest") {
69
+ throw new Error(`--keep must be richest or oldest, got "${keep}".`);
70
+ }
71
+ const records = snapshot[COLLECTIONS[options.objectType]];
72
+ const groups = new Map();
73
+ for (const record of records) {
74
+ const key = dedupeKey(record, options.key);
75
+ if (!key)
76
+ continue; // records without the identity key cannot be duplicates by it
77
+ const existing = groups.get(key) ?? [];
78
+ existing.push(record);
79
+ groups.set(key, existing);
80
+ }
81
+ for (const [key, members] of Array.from(groups.entries())) {
82
+ if (members.length < 2)
83
+ groups.delete(key);
84
+ }
85
+ if (groups.size > maxOperations) {
86
+ throw new Error(`Found ${groups.size} duplicate groups — above the ${maxOperations}-group safety cap. Raise --max-operations explicitly after reviewing the volume.`);
87
+ }
88
+ const operations = [];
89
+ let duplicateRecordCount = 0;
90
+ for (const [key, members] of groups) {
91
+ duplicateRecordCount += members.length;
92
+ // deterministic survivor: richest data first (ties to lowest id), or
93
+ // simply the lowest id when keeping the oldest
94
+ const survivor = [...members].sort((a, b) => {
95
+ if (keep === "richest") {
96
+ const richness = populatedDataFields(b) - populatedDataFields(a);
97
+ if (richness !== 0)
98
+ return richness;
99
+ }
100
+ return idBefore(String(a.id), String(b.id)) ? -1 : 1;
101
+ })[0];
102
+ const groupIds = members
103
+ .map((member) => String(member.id))
104
+ .sort((a, b) => (idBefore(a, b) ? -1 : 1));
105
+ const survivorName = typeof survivor.name === "string" && survivor.name
106
+ ? survivor.name
107
+ : typeof survivor.email === "string" && survivor.email
108
+ ? survivor.email
109
+ : String(survivor.id);
110
+ const keepDetail = keep === "richest"
111
+ ? `${populatedDataFields(survivor)} populated data fields, the most in the group (ties break to the lowest id)`
112
+ : "the lowest id in the group (oldest record)";
113
+ operations.push({
114
+ id: `op_${stableHash(`dedupe:${options.objectType}:${options.key}:${groupIds.join(",")}`)}`,
115
+ objectType: options.objectType,
116
+ objectId: String(survivor.id),
117
+ operation: "merge_records",
118
+ field: "merge",
119
+ beforeValue: groupIds,
120
+ afterValue: String(survivor.id),
121
+ reason: options.reason ??
122
+ `${members.length} ${COLLECTIONS[options.objectType]} share ${options.key} "${key}". Merge into "${survivorName}" (${survivor.id}) — survivor has ${keepDetail}.`,
123
+ riskLevel: "high",
124
+ approvalRequired: true,
125
+ sourceRuleOrPolicy: "dedupe",
126
+ groupId: `grp_${options.objectType}_${String(survivor.id)}`,
127
+ rollback: "IRREVERSIBLE: provider merges cannot be unmerged. The pre-apply snapshot retains every record's field values; recreate a record manually from it if a merge was wrong.",
128
+ });
129
+ }
130
+ return {
131
+ id: `patch_plan_${stableHash(`dedupe:${snapshot.provider}:${snapshot.generatedAt}:${options.objectType}:${options.key}:${keep}:${operations.length}`)}`,
132
+ title: `Dedupe: ${COLLECTIONS[options.objectType]} sharing the same ${options.key}`,
133
+ createdAt: snapshot.generatedAt,
134
+ status: operations.length > 0 ? "needs_approval" : "draft",
135
+ dryRun: true,
136
+ summary: `${groups.size} duplicate group(s) across ${duplicateRecordCount} ${COLLECTIONS[options.objectType]} (key: ${options.key}, keep: ${keep}); ${operations.length} proposed dry-run merge_records operation(s). Merges are IRREVERSIBLE — review each survivor before approving.`,
137
+ findings: [],
138
+ operations,
139
+ };
140
+ }
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";