fullstackgtm 0.46.0 → 0.48.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +44 -6
  2. package/INSTALL_FOR_AGENTS.md +3 -3
  3. package/README.md +16 -8
  4. package/dist/audit.js +7 -0
  5. package/dist/backfill.js +2 -16
  6. package/dist/cli/fix.d.ts +3 -0
  7. package/dist/cli/fix.js +70 -1
  8. package/dist/cli/help.d.ts +6 -0
  9. package/dist/cli/help.js +76 -2
  10. package/dist/cli/suggest.d.ts +2 -1
  11. package/dist/cli/suggest.js +49 -3
  12. package/dist/cli.js +40 -15
  13. package/dist/connector.js +96 -25
  14. package/dist/connectors/hubspot.d.ts +2 -1
  15. package/dist/connectors/hubspot.js +170 -4
  16. package/dist/connectors/salesforce.d.ts +2 -1
  17. package/dist/connectors/salesforce.js +135 -0
  18. package/dist/connectors/signalSources.js +2 -11
  19. package/dist/format.js +31 -5
  20. package/dist/freeEmailDomains.d.ts +1 -0
  21. package/dist/freeEmailDomains.js +14 -0
  22. package/dist/hierarchy.d.ts +29 -0
  23. package/dist/hierarchy.js +164 -0
  24. package/dist/index.d.ts +6 -2
  25. package/dist/index.js +5 -1
  26. package/dist/mcp.js +19 -15
  27. package/dist/relationships.d.ts +39 -0
  28. package/dist/relationships.js +101 -0
  29. package/dist/route.d.ts +46 -0
  30. package/dist/route.js +228 -0
  31. package/dist/rules.d.ts +1 -0
  32. package/dist/rules.js +172 -12
  33. package/dist/types.d.ts +30 -0
  34. package/docs/api.md +22 -4
  35. package/docs/architecture.md +5 -2
  36. package/package.json +1 -1
  37. package/skills/fullstackgtm/SKILL.md +2 -2
  38. package/src/audit.ts +7 -0
  39. package/src/backfill.ts +2 -17
  40. package/src/cli/fix.ts +68 -1
  41. package/src/cli/help.ts +83 -2
  42. package/src/cli/suggest.ts +58 -4
  43. package/src/cli.ts +38 -13
  44. package/src/connector.ts +94 -20
  45. package/src/connectors/hubspot.ts +164 -5
  46. package/src/connectors/salesforce.ts +136 -1
  47. package/src/connectors/signalSources.ts +2 -12
  48. package/src/format.ts +33 -5
  49. package/src/freeEmailDomains.ts +14 -0
  50. package/src/hierarchy.ts +185 -0
  51. package/src/index.ts +25 -0
  52. package/src/mcp.ts +22 -16
  53. package/src/relationships.ts +115 -0
  54. package/src/route.ts +278 -0
  55. package/src/rules.ts +192 -12
  56. package/src/types.ts +32 -0
@@ -24,6 +24,8 @@ import { SNAPSHOT_PULL_STAGES, type ProgressEmitter } from "../progress.ts";
24
24
 
25
25
  const DEFAULT_API_BASE_URL = "https://api.hubapi.com";
26
26
 
27
+ export type HubspotWritableConnector = GtmConnector & Required<Pick<GtmConnector, "applyOperation" | "readField">>;
28
+
27
29
  export type HubspotConnectorOptions = {
28
30
  /** Returns a HubSpot access token (private app token or OAuth access token). */
29
31
  getAccessToken: () => string | Promise<string>;
@@ -71,7 +73,7 @@ const PULL_STAGE_BY_TYPE: Record<SnapshotProgress["objectType"], (typeof SNAPSHO
71
73
  * amountless deals — so audit rules can surface the gaps instead of hiding
72
74
  * them.
73
75
  */
74
- export function createHubspotConnector(options: HubspotConnectorOptions): Required<GtmConnector> {
76
+ export function createHubspotConnector(options: HubspotConnectorOptions): HubspotWritableConnector {
75
77
  const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
76
78
  const fetchImpl = options.fetchImpl ?? fetch;
77
79
  const mappings = options.fieldMappings;
@@ -1195,6 +1197,164 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
1195
1197
  };
1196
1198
  }
1197
1199
 
1200
+ function fieldMapping(operation: PatchOperation): { objectPath: string; property: string } | null {
1201
+ const objectPath = OBJECT_PATHS[operation.objectType];
1202
+ const mappingType = MAPPING_OBJECT_TYPES[operation.objectType];
1203
+ if (!objectPath || !mappingType || !operation.field) return null;
1204
+ const defaults = HUBSPOT_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
1205
+ return { objectPath, property: mappedField(mappings, mappingType, operation.field, defaults[operation.field] ?? operation.field) };
1206
+ }
1207
+
1208
+ function normalizeHubspotReadValue(field: string | undefined, value: unknown): unknown {
1209
+ if (field === "closeDate" && typeof value === "string") return value.split("T")[0];
1210
+ return value ?? null;
1211
+ }
1212
+
1213
+ function normalizeForComparison(value: unknown): string | null {
1214
+ if (value === undefined || value === null || value === "") return null;
1215
+ return String(value);
1216
+ }
1217
+
1218
+ function batchErrorIds(error: any): string[] {
1219
+ const context = error?.context ?? {};
1220
+ const ids = context.ids ?? context.id ?? error?.id ?? error?.objectId;
1221
+ if (Array.isArray(ids)) return ids.map(String);
1222
+ if (ids !== undefined && ids !== null) return [String(ids)];
1223
+ return [];
1224
+ }
1225
+
1226
+ function batchErrorDetail(error: any): string {
1227
+ const category = error?.category ? `${String(error.category)}: ` : "";
1228
+ return `${category}${String(error?.message ?? "HubSpot batch row failed.")}`;
1229
+ }
1230
+
1231
+ async function applySetFieldBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
1232
+ const out = new Map<string, PatchOperationResult>();
1233
+ const mapped = operations.map((operation) => ({ operation, mapping: fieldMapping(operation) }));
1234
+ for (const item of mapped) {
1235
+ if (!item.mapping) {
1236
+ out.set(item.operation.id, {
1237
+ operationId: item.operation.id,
1238
+ status: "skipped",
1239
+ detail: "Field writes are only supported for accounts, contacts, and deals with an explicit field.",
1240
+ });
1241
+ }
1242
+ }
1243
+ const clean = mapped.filter((item): item is { operation: PatchOperation; mapping: { objectPath: string; property: string } } => Boolean(item.mapping));
1244
+ if (clean.length === 0) return operations.map((operation) => out.get(operation.id)!);
1245
+ const objectPath = clean[0].mapping.objectPath;
1246
+ const properties = [...new Set(clean.map((item) => item.mapping.property))];
1247
+ const liveById = new Map<string, Record<string, unknown>>();
1248
+ const ids = [...new Set(clean.map((item) => item.operation.objectId))];
1249
+ for (let i = 0; i < ids.length; i += 100) {
1250
+ const chunkIds = ids.slice(i, i + 100);
1251
+ const data = await request(`/crm/v3/objects/${objectPath}/batch/read`, {
1252
+ method: "POST",
1253
+ body: JSON.stringify({ properties, inputs: chunkIds.map((id) => ({ id })) }),
1254
+ });
1255
+ for (const row of data?.results ?? []) {
1256
+ if (row?.id) liveById.set(String(row.id), row.properties ?? {});
1257
+ }
1258
+ }
1259
+
1260
+ const toWrite: Array<{ operation: PatchOperation; property: string }> = [];
1261
+ for (const { operation, mapping } of clean) {
1262
+ const props = liveById.get(operation.objectId);
1263
+ const current = normalizeHubspotReadValue(operation.field, props?.[mapping.property] ?? null);
1264
+ const expected = normalizeForComparison(operation.beforeValue);
1265
+ const found = normalizeForComparison(current);
1266
+ if (!props || expected !== found) {
1267
+ out.set(operation.id, {
1268
+ operationId: operation.id,
1269
+ status: "conflict",
1270
+ detail: `Value drifted since the plan was proposed: expected ${expected ?? "∅"}, found ${found ?? "∅"}. Re-run the audit.`,
1271
+ providerData: { currentValue: current ?? null },
1272
+ });
1273
+ continue;
1274
+ }
1275
+ toWrite.push({ operation, property: mapping.property });
1276
+ }
1277
+
1278
+ for (let i = 0; i < toWrite.length; i += 100) {
1279
+ const chunk = toWrite.slice(i, i + 100);
1280
+ let data: any;
1281
+ try {
1282
+ data = await request(`/crm/v3/objects/${objectPath}/batch/update`, {
1283
+ method: "POST",
1284
+ body: JSON.stringify({
1285
+ inputs: chunk.map(({ operation, property }) => ({
1286
+ id: operation.objectId,
1287
+ properties: { [property]: operation.operation === "clear_field" ? "" : String(operation.afterValue ?? "") },
1288
+ })),
1289
+ }),
1290
+ });
1291
+ } catch (error) {
1292
+ for (const { operation } of chunk) out.set(operation.id, { operationId: operation.id, status: "failed", detail: error instanceof Error ? error.message : String(error) });
1293
+ continue;
1294
+ }
1295
+ const opByObjectId = new Map(chunk.map(({ operation }) => [operation.objectId, operation]));
1296
+ for (const error of data?.errors ?? []) {
1297
+ for (const id of batchErrorIds(error)) {
1298
+ const operation = opByObjectId.get(id);
1299
+ if (operation) out.set(operation.id, { operationId: operation.id, status: "failed", detail: batchErrorDetail(error) });
1300
+ }
1301
+ }
1302
+ for (const result of data?.results ?? []) {
1303
+ const operation = opByObjectId.get(String(result?.id));
1304
+ if (operation && !out.has(operation.id)) out.set(operation.id, { operationId: operation.id, status: "applied", detail: `Set ${chunk.find((item) => item.operation.id === operation.id)?.property ?? "field"} on ${objectPath}/${operation.objectId}.`, providerData: { id: result?.id } });
1305
+ }
1306
+ for (const { operation, property } of chunk) {
1307
+ if (!out.has(operation.id)) out.set(operation.id, { operationId: operation.id, status: "applied", detail: `Set ${property} on ${objectPath}/${operation.objectId}.` });
1308
+ }
1309
+ }
1310
+ return operations.map((operation) => out.get(operation.id) ?? { operationId: operation.id, status: "failed", detail: "Batch update did not process this operation." });
1311
+ }
1312
+
1313
+ async function applyArchiveBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
1314
+ const objectPath = OBJECT_PATHS[operations[0]?.objectType];
1315
+ if (!objectPath) return operations.map((operation) => ({ operationId: operation.id, status: "skipped", detail: "archive_record is supported for accounts, contacts, and deals." }));
1316
+ const out = new Map<string, PatchOperationResult>();
1317
+ for (let i = 0; i < operations.length; i += 100) {
1318
+ const chunk = operations.slice(i, i + 100);
1319
+ let data: any;
1320
+ try {
1321
+ data = await request(`/crm/v3/objects/${objectPath}/batch/archive`, {
1322
+ method: "POST",
1323
+ body: JSON.stringify({ inputs: chunk.map((operation) => ({ id: operation.objectId })) }),
1324
+ });
1325
+ } catch (error) {
1326
+ for (const operation of chunk) out.set(operation.id, { operationId: operation.id, status: "failed", detail: error instanceof Error ? error.message : String(error) });
1327
+ continue;
1328
+ }
1329
+ const opByObjectId = new Map(chunk.map((operation) => [operation.objectId, operation]));
1330
+ for (const error of data?.errors ?? []) {
1331
+ for (const id of batchErrorIds(error)) {
1332
+ const operation = opByObjectId.get(id);
1333
+ if (operation) out.set(operation.id, { operationId: operation.id, status: "failed", detail: batchErrorDetail(error) });
1334
+ }
1335
+ }
1336
+ for (const result of data?.results ?? []) {
1337
+ const operation = opByObjectId.get(String(result?.id));
1338
+ if (operation && !out.has(operation.id)) out.set(operation.id, { operationId: operation.id, status: "applied", detail: `Archived ${objectPath}/${operation.objectId}.` });
1339
+ }
1340
+ for (const operation of chunk) {
1341
+ if (!out.has(operation.id)) out.set(operation.id, { operationId: operation.id, status: "applied", detail: `Archived ${objectPath}/${operation.objectId}.` });
1342
+ }
1343
+ }
1344
+ return operations.map((operation) => out.get(operation.id) ?? { operationId: operation.id, status: "failed", detail: "Batch archive did not process this operation." });
1345
+ }
1346
+
1347
+ async function applyBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
1348
+ if (operations.length === 0) return [];
1349
+ if (operations.every((operation) => (operation.operation === "set_field" || operation.operation === "clear_field") && operation.objectType === operations[0].objectType)) {
1350
+ return applySetFieldBatch(operations);
1351
+ }
1352
+ if (operations.every((operation) => operation.operation === "archive_record" && operation.objectType === operations[0].objectType)) {
1353
+ return applyArchiveBatch(operations);
1354
+ }
1355
+ return operations.map((operation) => ({ operationId: operation.id, status: "skipped", detail: "This mixed operation batch is not supported." }));
1356
+ }
1357
+
1198
1358
  /**
1199
1359
  * Merge a duplicate group into the approved survivor via HubSpot's v3
1200
1360
  * merge API (supported for contacts, companies, deals, and tickets).
@@ -1335,10 +1495,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
1335
1495
  // single-object read here returns HubSpot's raw "2026-03-07T00:00:00Z", which
1336
1496
  // made compare-and-set see a spurious drift and refuse every date-field write.
1337
1497
  // Mirror the snapshot's normalization so the comparison is apples-to-apples.
1338
- if (field === "closeDate" && typeof value === "string") {
1339
- return value.split("T")[0];
1340
- }
1341
- return value;
1498
+ return normalizeHubspotReadValue(field, value);
1342
1499
  }
1343
1500
 
1344
1501
  return {
@@ -1347,6 +1504,8 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
1347
1504
  fetchChanges,
1348
1505
  applyOperation,
1349
1506
  applyCreateContactsBatch,
1507
+ applyBatch,
1508
+ applyBatchLimit: 100,
1350
1509
  readField,
1351
1510
  };
1352
1511
  }
@@ -29,6 +29,8 @@ export type SalesforceConnection = {
29
29
  instanceUrl: string;
30
30
  };
31
31
 
32
+ export type SalesforceWritableConnector = GtmConnector & Required<Pick<GtmConnector, "applyOperation" | "readField">>;
33
+
32
34
  export type SalesforceConnectorOptions = {
33
35
  /** Returns an access token plus the instance URL it belongs to. */
34
36
  getConnection: () => SalesforceConnection | Promise<SalesforceConnection>;
@@ -86,7 +88,7 @@ const PULL_STAGE_BY_TYPE: Record<SnapshotProgress["objectType"], (typeof SNAPSHO
86
88
  */
87
89
  export function createSalesforceConnector(
88
90
  options: SalesforceConnectorOptions,
89
- ): Required<GtmConnector> {
91
+ ): SalesforceWritableConnector {
90
92
  const apiVersion = options.apiVersion ?? DEFAULT_API_VERSION;
91
93
  const fetchImpl = options.fetchImpl ?? fetch;
92
94
  const mappings = options.fieldMappings;
@@ -489,6 +491,137 @@ export function createSalesforceConnector(
489
491
  };
490
492
  }
491
493
 
494
+ function normalizeSalesforceValue(value: unknown): string | null {
495
+ if (value === undefined || value === null || value === "") return null;
496
+ return String(value).split("T")[0];
497
+ }
498
+
499
+ function fieldMappingFor(operation: PatchOperation): { sobjectType: string; field: string } | null {
500
+ const sobjectType = SOBJECT_TYPES[operation.objectType];
501
+ const mappingType = MAPPING_OBJECT_TYPES[operation.objectType];
502
+ if (!sobjectType || !mappingType || !operation.field) return null;
503
+ const defaults = SALESFORCE_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
504
+ return {
505
+ sobjectType,
506
+ field: mappedField(mappings, mappingType, operation.field, defaults[operation.field] ?? operation.field),
507
+ };
508
+ }
509
+
510
+ function compositeErrorDetail(result: any): string {
511
+ const errors = Array.isArray(result?.errors) ? result.errors : [];
512
+ const message = errors
513
+ .map((error: any) => [error?.statusCode, error?.message].filter(Boolean).join(": "))
514
+ .filter(Boolean)
515
+ .join("; ");
516
+ return message ? `Salesforce rejected this record: ${message.slice(0, 300)}.` : "Salesforce rejected this record.";
517
+ }
518
+
519
+ async function applySetFieldBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
520
+ const out = new Map<string, PatchOperationResult>();
521
+ const mapped = operations.map((operation) => ({ operation, mapping: fieldMappingFor(operation) }));
522
+ for (const item of mapped) {
523
+ if (!item.mapping) {
524
+ out.set(item.operation.id, {
525
+ operationId: item.operation.id,
526
+ status: "skipped",
527
+ detail: "Field writes are only supported for accounts, contacts, and deals with an explicit field.",
528
+ });
529
+ }
530
+ }
531
+ const clean = mapped.filter((item): item is { operation: PatchOperation; mapping: { sobjectType: string; field: string } } => Boolean(item.mapping));
532
+ if (clean.length === 0) return operations.map((operation) => out.get(operation.id)!);
533
+ const sobjectType = clean[0].mapping.sobjectType;
534
+ const fields = [...new Set(clean.map((item) => item.mapping.field))];
535
+ const liveById = new Map<string, Record<string, unknown>>();
536
+ const ids = [...new Set(clean.map((item) => item.operation.objectId))];
537
+ for (let i = 0; i < ids.length; i += 200) {
538
+ const idList = ids.slice(i, i + 200).map((id) => `'${id.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`).join(",");
539
+ const rows = await query(`SELECT Id, ${fields.join(", ")} FROM ${sobjectType} WHERE Id IN (${idList})`);
540
+ for (const row of rows) liveById.set(String(row.Id), row);
541
+ }
542
+
543
+ const toWrite: Array<{ operation: PatchOperation; field: string }> = [];
544
+ for (const { operation, mapping } of clean) {
545
+ const row = liveById.get(operation.objectId);
546
+ const current = row?.[mapping.field] ?? null;
547
+ const expected = normalizeSalesforceValue(operation.beforeValue);
548
+ const found = normalizeSalesforceValue(current);
549
+ if (!row || expected !== found) {
550
+ out.set(operation.id, {
551
+ operationId: operation.id,
552
+ status: "conflict",
553
+ detail: `Value drifted since the plan was proposed: expected ${expected ?? "∅"}, found ${found ?? "∅"}. Re-run the audit.`,
554
+ providerData: { currentValue: current ?? null },
555
+ });
556
+ continue;
557
+ }
558
+ toWrite.push({ operation, field: mapping.field });
559
+ }
560
+
561
+ for (let i = 0; i < toWrite.length; i += 200) {
562
+ const chunk = toWrite.slice(i, i + 200);
563
+ const records = chunk.map(({ operation, field }) => ({
564
+ attributes: { type: sobjectType },
565
+ Id: operation.objectId,
566
+ [field]: operation.operation === "clear_field" ? null : String(operation.afterValue ?? ""),
567
+ }));
568
+ let response: any[];
569
+ try {
570
+ response = (await request(`/services/data/${apiVersion}/composite/sobjects`, {
571
+ method: "PATCH",
572
+ body: JSON.stringify({ allOrNone: false, records }),
573
+ })) as any[];
574
+ } catch (error) {
575
+ for (const { operation } of chunk) out.set(operation.id, { operationId: operation.id, status: "failed", detail: error instanceof Error ? error.message : String(error) });
576
+ continue;
577
+ }
578
+ for (let j = 0; j < chunk.length; j++) {
579
+ const { operation, field } = chunk[j];
580
+ const result = Array.isArray(response) ? response[j] : undefined;
581
+ out.set(operation.id, result?.success
582
+ ? { operationId: operation.id, status: "applied", detail: `Set ${field} on ${sobjectType}/${operation.objectId}.`, providerData: { sobjectType, field } }
583
+ : { operationId: operation.id, status: "failed", detail: compositeErrorDetail(result) });
584
+ }
585
+ }
586
+ return operations.map((operation) => out.get(operation.id) ?? { operationId: operation.id, status: "failed", detail: "Batch update did not process this operation." });
587
+ }
588
+
589
+ async function applyArchiveBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
590
+ const sobjectType = SOBJECT_TYPES[operations[0]?.objectType];
591
+ if (!sobjectType) return operations.map((operation) => ({ operationId: operation.id, status: "skipped", detail: "archive_record is supported for accounts, contacts, and deals." }));
592
+ const out = new Map<string, PatchOperationResult>();
593
+ for (let i = 0; i < operations.length; i += 200) {
594
+ const chunk = operations.slice(i, i + 200);
595
+ const ids = chunk.map((operation) => encodeURIComponent(operation.objectId)).join(",");
596
+ let response: any[];
597
+ try {
598
+ response = (await request(`/services/data/${apiVersion}/composite/sobjects?ids=${ids}&allOrNone=false`, { method: "DELETE" })) as any[];
599
+ } catch (error) {
600
+ for (const operation of chunk) out.set(operation.id, { operationId: operation.id, status: "failed", detail: error instanceof Error ? error.message : String(error) });
601
+ continue;
602
+ }
603
+ for (let j = 0; j < chunk.length; j++) {
604
+ const operation = chunk[j];
605
+ const result = Array.isArray(response) ? response[j] : undefined;
606
+ out.set(operation.id, result?.success
607
+ ? { operationId: operation.id, status: "applied", detail: `Deleted ${sobjectType}/${operation.objectId}.` }
608
+ : { operationId: operation.id, status: "failed", detail: compositeErrorDetail(result) });
609
+ }
610
+ }
611
+ return operations.map((operation) => out.get(operation.id) ?? { operationId: operation.id, status: "failed", detail: "Batch delete did not process this operation." });
612
+ }
613
+
614
+ async function applyBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
615
+ if (operations.length === 0) return [];
616
+ if (operations.every((operation) => (operation.operation === "set_field" || operation.operation === "clear_field") && operation.objectType === operations[0].objectType)) {
617
+ return applySetFieldBatch(operations);
618
+ }
619
+ if (operations.every((operation) => operation.operation === "archive_record" && operation.objectType === operations[0].objectType)) {
620
+ return applyArchiveBatch(operations);
621
+ }
622
+ return operations.map((operation) => ({ operationId: operation.id, status: "skipped", detail: "This mixed operation batch is not supported." }));
623
+ }
624
+
492
625
  function escapeXml(value: string): string {
493
626
  return value
494
627
  .replace(/&/g, "&amp;")
@@ -869,6 +1002,8 @@ export function createSalesforceConnector(
869
1002
  fetchChanges,
870
1003
  applyOperation,
871
1004
  applyCreateContactsBatch,
1005
+ applyBatch,
1006
+ applyBatchLimit: 200,
872
1007
  readField,
873
1008
  };
874
1009
  }
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { readFileSync, statSync } from "node:fs";
23
23
  import { resolve } from "node:path";
24
+ import { FREE_EMAIL_DOMAINS } from "../freeEmailDomains.ts";
24
25
  import type { SignalBucket, StagedSignalRow } from "../signals.ts";
25
26
  import { SIGNAL_BUCKETS } from "../signals.ts";
26
27
  import { parseSpoolText, spoolFilesIn } from "../spoolFiles.ts";
@@ -294,21 +295,10 @@ function fieldValue(values: Array<{ name?: string; value?: string }> | undefined
294
295
  return "";
295
296
  }
296
297
 
297
- const FREE_MAIL = new Set([
298
- "gmail.com",
299
- "yahoo.com",
300
- "hotmail.com",
301
- "outlook.com",
302
- "icloud.com",
303
- "aol.com",
304
- "proton.me",
305
- "protonmail.com",
306
- ]);
307
-
308
298
  /** Company domain from an email, or "" for free-mail / no email. */
309
299
  function corporateDomain(email: string): string {
310
300
  if (!email.includes("@")) return "";
311
301
  const domain = email.split("@").at(-1)!.trim().toLowerCase();
312
- if (!domain || FREE_MAIL.has(domain)) return "";
302
+ if (!domain || FREE_EMAIL_DOMAINS.has(domain)) return "";
313
303
  return domain;
314
304
  }
package/src/format.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  import type { PatchPlan, PatchPlanRun } from "./types.ts";
2
2
 
3
- // `summary: true` renders only the header + the Findings-by-Rule table + an
4
- // operation count, for read verbs like `audit` where the full per-operation
5
- // dump (1,400+ lines on a real portal) buries the signal. Defaults to the full
6
- // view so write-preview callers (bulk-update, fix, dedupe, plans show, MCP) —
7
- // where you approve specific operations — keep every operation's detail.
3
+ // `summary: true` renders the compact audit view: header, findings summary,
4
+ // assumptions/open questions, and an operation count. Defaults to the full view
5
+ // so write-preview callers (bulk-update, fix, dedupe, plans show, MCP) where
6
+ // you approve specific operations keep every operation's detail.
8
7
  export function patchPlanToMarkdown(plan: PatchPlan, opts: { summary?: boolean } = {}) {
9
8
  const lines = [
10
9
  `# ${plan.title}`,
@@ -29,6 +28,8 @@ export function patchPlanToMarkdown(plan: PatchPlan, opts: { summary?: boolean }
29
28
  lines.push("");
30
29
  }
31
30
 
31
+ appendAssumptionsAndDecisionPoints(lines, plan);
32
+
32
33
  if (opts.summary) {
33
34
  const ops = plan.operations.length;
34
35
  lines.push(
@@ -155,6 +156,33 @@ export function formatPatchPlanRun(run: PatchPlanRun) {
155
156
  return `${lines.join("\n")}\n`;
156
157
  }
157
158
 
159
+
160
+ function appendAssumptionsAndDecisionPoints(lines: string[], plan: PatchPlan) {
161
+ const assumptions = [...(plan.assumptions ?? [])].sort((left, right) => {
162
+ if (left.confidence === "guess" && right.confidence !== "guess") return -1;
163
+ if (left.confidence !== "guess" && right.confidence === "guess") return 1;
164
+ return 0;
165
+ });
166
+ const openQuestions = plan.openQuestions ?? [];
167
+ if (assumptions.length === 0 && openQuestions.length === 0) return;
168
+
169
+ lines.push("## Assumptions & decision points", "");
170
+ for (const assumption of assumptions) {
171
+ const warning = assumption.confidence === "guess" ? "⚠️ " : "";
172
+ lines.push(`- ${warning}${assumption.text}${assumption.source ? ` _(source: ${assumption.source})_` : ""}`);
173
+ }
174
+
175
+ if (openQuestions.length > 0) {
176
+ if (assumptions.length > 0) lines.push("");
177
+ lines.push("### Open questions", "");
178
+ for (const question of openQuestions) {
179
+ lines.push(`- ${question}`);
180
+ }
181
+ }
182
+
183
+ lines.push("");
184
+ }
185
+
158
186
  function formatValue(value: unknown) {
159
187
  if (value === undefined || value === null || value === "") return "unset";
160
188
  if (typeof value === "string") return value;
@@ -0,0 +1,14 @@
1
+ // Shared free-mail domains that should never be treated as company domains.
2
+ export const FREE_EMAIL_DOMAINS = new Set([
3
+ "gmail.com",
4
+ "googlemail.com",
5
+ "yahoo.com",
6
+ "outlook.com",
7
+ "hotmail.com",
8
+ "live.com",
9
+ "icloud.com",
10
+ "me.com",
11
+ "aol.com",
12
+ "proton.me",
13
+ "protonmail.com",
14
+ ]);
@@ -0,0 +1,185 @@
1
+ import { normalizeDomain } from "./merge.ts";
2
+ import type { CanonicalAccount, CanonicalGtmSnapshot } from "./types.ts";
3
+
4
+ export type AccountHierarchyNode = {
5
+ accountId: string;
6
+ name: string;
7
+ domain?: string;
8
+ parentAccountId?: string;
9
+ parentReason?: string;
10
+ children: AccountHierarchyNode[];
11
+ };
12
+
13
+ export type AccountHierarchyConflict = {
14
+ type: "duplicate_domain" | "ambiguous_parent" | "cycle";
15
+ accountIds: string[];
16
+ key: string;
17
+ detail: string;
18
+ };
19
+
20
+ export type AccountHierarchyReport = {
21
+ generatedAt: string;
22
+ roots: AccountHierarchyNode[];
23
+ orphanAccounts: string[];
24
+ conflicts: AccountHierarchyConflict[];
25
+ counts: { accounts: number; roots: number; linkedChildren: number; conflicts: number };
26
+ };
27
+
28
+ function rawString(account: CanonicalAccount, keys: string[]): string | undefined {
29
+ if (!account.raw || typeof account.raw !== "object") return undefined;
30
+ const raw = account.raw as Record<string, unknown>;
31
+ for (const key of keys) {
32
+ const value = raw[key];
33
+ if (typeof value === "string" && value.trim()) return value.trim();
34
+ if (value && typeof value === "object") {
35
+ const nested = value as Record<string, unknown>;
36
+ for (const nestedKey of ["id", "value", "name"]) {
37
+ if (typeof nested[nestedKey] === "string" && String(nested[nestedKey]).trim()) return String(nested[nestedKey]).trim();
38
+ }
39
+ }
40
+ }
41
+ return undefined;
42
+ }
43
+
44
+ function parentHint(account: CanonicalAccount): string | undefined {
45
+ return rawString(account, ["parentAccountId", "parent_account_id", "ParentId", "parentCompanyId", "hs_parent_company_id"]);
46
+ }
47
+
48
+ function cloneNode(account: CanonicalAccount): AccountHierarchyNode {
49
+ return { accountId: String(account.id), name: account.name, ...(account.domain ? { domain: account.domain } : {}), children: [] };
50
+ }
51
+
52
+ function looksLikePublicSuffix(domain: string): boolean {
53
+ const parts = domain.split(".");
54
+ if (parts.length !== 2) return false;
55
+ const [label, tld] = parts;
56
+ return tld.length === 2 && ["ac", "co", "com", "edu", "gov", "net", "org"].includes(label);
57
+ }
58
+
59
+ function domainParent(childDomain: string, domains: Map<string, CanonicalAccount[]>): CanonicalAccount | "ambiguous" | null {
60
+ const parts = childDomain.split(".");
61
+ for (let i = 1; i < parts.length - 1; i++) {
62
+ const candidate = parts.slice(i).join(".");
63
+ if (candidate.split(".").length < 2 || looksLikePublicSuffix(candidate)) continue;
64
+ const unique = new Map((domains.get(candidate) ?? []).map((account) => [String(account.id), account]));
65
+ if (unique.size > 1) return "ambiguous";
66
+ if (unique.size === 1) return [...unique.values()][0];
67
+ }
68
+ return null;
69
+ }
70
+
71
+ function detectCycles(parentByChild: Map<string, { parentId: string; reason: string }>): { accountIds: Set<string>; cycles: string[][] } {
72
+ const cyclic = new Set<string>();
73
+ const cycles: string[][] = [];
74
+ const emitted = new Set<string>();
75
+
76
+ for (const start of parentByChild.keys()) {
77
+ const path: string[] = [];
78
+ const indexById = new Map<string, number>();
79
+ let cursor: string | undefined = start;
80
+ while (cursor && parentByChild.has(cursor)) {
81
+ const seenAt = indexById.get(cursor);
82
+ if (seenAt !== undefined) {
83
+ const cycle = path.slice(seenAt);
84
+ for (const id of cycle) cyclic.add(id);
85
+ const key = [...cycle].sort().join("|");
86
+ if (!emitted.has(key)) {
87
+ emitted.add(key);
88
+ cycles.push(cycle);
89
+ }
90
+ break;
91
+ }
92
+ indexById.set(cursor, path.length);
93
+ path.push(cursor);
94
+ cursor = parentByChild.get(cursor)?.parentId;
95
+ }
96
+ }
97
+
98
+ return { accountIds: cyclic, cycles };
99
+ }
100
+
101
+ export function buildAccountHierarchy(snapshot: CanonicalGtmSnapshot): AccountHierarchyReport {
102
+ const byId = new Map(snapshot.accounts.map((account) => [String(account.id), account]));
103
+ const accounts = [...byId.values()];
104
+ const domains = new Map<string, CanonicalAccount[]>();
105
+ for (const account of accounts) {
106
+ const domain = normalizeDomain(account.domain);
107
+ if (domain) domains.set(domain, [...(domains.get(domain) ?? []), account]);
108
+ }
109
+
110
+ const conflicts: AccountHierarchyConflict[] = [];
111
+ for (const [domain, accounts] of domains) {
112
+ const ids = [...new Set(accounts.map((account) => String(account.id)))];
113
+ if (ids.length > 1) conflicts.push({ type: "duplicate_domain", accountIds: ids.sort(), key: domain, detail: `${ids.length} accounts share domain ${domain}; hierarchy inference will not choose between them.` });
114
+ }
115
+
116
+ const nodes = new Map(accounts.map((account) => [String(account.id), cloneNode(account)]));
117
+ const parentByChild = new Map<string, { parentId: string; reason: string }>();
118
+ for (const account of accounts) {
119
+ const id = String(account.id);
120
+ const hint = parentHint(account);
121
+ if (hint && byId.has(hint) && hint !== id) {
122
+ parentByChild.set(id, { parentId: hint, reason: "provider-parent" });
123
+ continue;
124
+ }
125
+ const domain = normalizeDomain(account.domain);
126
+ if (!domain) continue;
127
+ const inferred = domainParent(domain, domains);
128
+ if (inferred === "ambiguous") conflicts.push({ type: "ambiguous_parent", accountIds: [id], key: domain, detail: `Multiple possible parent accounts found for ${domain}; leaving account as a root.` });
129
+ else if (inferred && String(inferred.id) !== id) parentByChild.set(id, { parentId: String(inferred.id), reason: "domain-subdomain" });
130
+ }
131
+
132
+ const cyclicAccounts = detectCycles(parentByChild);
133
+ for (const cycle of cyclicAccounts.cycles) {
134
+ const ordered = [...cycle].sort();
135
+ const key = ordered.join(" -> ");
136
+ conflicts.push({
137
+ type: "cycle",
138
+ accountIds: ordered,
139
+ key,
140
+ detail: `Cyclic parent hints detected among ${ordered.join(", ")}; leaving those accounts as roots instead of hiding them in a loop.`,
141
+ });
142
+ }
143
+ for (const accountId of cyclicAccounts.accountIds) parentByChild.delete(accountId);
144
+
145
+ const roots: AccountHierarchyNode[] = [];
146
+ const orphanAccounts: string[] = [];
147
+ const linkedChildren = new Set<string>();
148
+ for (const account of accounts) {
149
+ const id = String(account.id);
150
+ const node = nodes.get(id)!;
151
+ const parent = parentByChild.get(id);
152
+ if (parent && nodes.has(parent.parentId)) {
153
+ node.parentAccountId = parent.parentId;
154
+ node.parentReason = parent.reason;
155
+ nodes.get(parent.parentId)!.children.push(node);
156
+ linkedChildren.add(id);
157
+ } else {
158
+ roots.push(node);
159
+ if (!account.domain && !parentHint(account)) orphanAccounts.push(id);
160
+ }
161
+ }
162
+ const sortTree = (list: AccountHierarchyNode[]) => {
163
+ list.sort((a, b) => a.name.localeCompare(b.name) || a.accountId.localeCompare(b.accountId));
164
+ for (const node of list) sortTree(node.children);
165
+ };
166
+ sortTree(roots);
167
+ return { generatedAt: snapshot.generatedAt, roots, orphanAccounts: orphanAccounts.sort(), conflicts, counts: { accounts: accounts.length, roots: roots.length, linkedChildren: linkedChildren.size, conflicts: conflicts.length } };
168
+ }
169
+
170
+ export function accountHierarchyToMarkdown(report: AccountHierarchyReport): string {
171
+ const lines = ["# Account hierarchy", "", `${report.counts.accounts} account(s), ${report.counts.roots} root(s), ${report.counts.linkedChildren} inferred child link(s), ${report.counts.conflicts} conflict(s).`, ""];
172
+ const walk = (nodes: AccountHierarchyNode[], depth: number) => {
173
+ for (const node of nodes) {
174
+ const suffix = [node.domain, node.parentReason ? `via ${node.parentReason}` : undefined].filter(Boolean).join("; ");
175
+ lines.push(`${" ".repeat(depth)}- ${node.name} (${node.accountId}${suffix ? ` — ${suffix}` : ""})`);
176
+ walk(node.children, depth + 1);
177
+ }
178
+ };
179
+ walk(report.roots, 0);
180
+ if (report.conflicts.length) {
181
+ lines.push("", "## Conflicts");
182
+ for (const conflict of report.conflicts) lines.push(`- ${conflict.type} ${conflict.key}: ${conflict.detail}`);
183
+ }
184
+ return lines.join("\n");
185
+ }