fullstackgtm 0.47.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 (52) hide show
  1. package/CHANGELOG.md +38 -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/connectors/hubspot.d.ts +2 -1
  14. package/dist/connectors/salesforce.d.ts +2 -1
  15. package/dist/connectors/signalSources.js +2 -11
  16. package/dist/format.js +31 -5
  17. package/dist/freeEmailDomains.d.ts +1 -0
  18. package/dist/freeEmailDomains.js +14 -0
  19. package/dist/hierarchy.d.ts +29 -0
  20. package/dist/hierarchy.js +164 -0
  21. package/dist/index.d.ts +6 -2
  22. package/dist/index.js +5 -1
  23. package/dist/mcp.js +19 -15
  24. package/dist/relationships.d.ts +39 -0
  25. package/dist/relationships.js +101 -0
  26. package/dist/route.d.ts +46 -0
  27. package/dist/route.js +228 -0
  28. package/dist/rules.d.ts +1 -0
  29. package/dist/rules.js +172 -12
  30. package/dist/types.d.ts +15 -0
  31. package/docs/api.md +20 -3
  32. package/docs/architecture.md +5 -2
  33. package/package.json +1 -1
  34. package/skills/fullstackgtm/SKILL.md +2 -2
  35. package/src/audit.ts +7 -0
  36. package/src/backfill.ts +2 -17
  37. package/src/cli/fix.ts +68 -1
  38. package/src/cli/help.ts +83 -2
  39. package/src/cli/suggest.ts +58 -4
  40. package/src/cli.ts +38 -13
  41. package/src/connectors/hubspot.ts +3 -1
  42. package/src/connectors/salesforce.ts +3 -1
  43. package/src/connectors/signalSources.ts +2 -12
  44. package/src/format.ts +33 -5
  45. package/src/freeEmailDomains.ts +14 -0
  46. package/src/hierarchy.ts +185 -0
  47. package/src/index.ts +25 -0
  48. package/src/mcp.ts +22 -16
  49. package/src/relationships.ts +115 -0
  50. package/src/route.ts +278 -0
  51. package/src/rules.ts +192 -12
  52. package/src/types.ts +17 -0
package/src/cli/help.ts CHANGED
@@ -50,6 +50,12 @@ Usage:
50
50
  fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]
51
51
  the create gate: exit 0 = safe to create, exit 2 = match
52
52
  found (exists/ambiguous) — call before ANY record creation
53
+ fullstackgtm route leads [source options] [--match domain|company|both] [--no-inherit-owner] [--reassign-owned] [--policy <json|path>] [--save|--json|--out <path>]
54
+ lead-to-account matching + owner routing as a governed plan
55
+ fullstackgtm hierarchy report [source options] [--json|--out <path>]
56
+ account hierarchy view from native parents + subdomains
57
+ fullstackgtm relationships account (--account-id <id>|--domain <d>) [source options] [--json|--out <path>]
58
+ relationship map from contacts, deals, and activity evidence
53
59
  fullstackgtm market init --category <name> start a market map: vendors + claim taxonomy as reviewable config
54
60
  fullstackgtm market capture [--config <path>] [--run <label>]
55
61
  fullstackgtm market classify [--run <label>] [--vendor <id>] [--model m] [--out <path>]
@@ -420,7 +426,32 @@ export const HELP: Record<string, HelpEntry> = {
420
426
  seeAlso: ["dedupe", "audit"],
421
427
  },
422
428
 
429
+ hierarchy: {
430
+ summary: "account hierarchy report (native parents + subdomain inference)",
431
+ phase: "Prevent",
432
+ synopsis: ["fullstackgtm hierarchy report [source options] [--json|--out <path>]"],
433
+ detail:
434
+ "Builds a report-only account tree from provider-native parent ids (when present in raw payloads) and deterministic subdomain inference. It surfaces duplicate-domain and ambiguous-parent conflicts instead of guessing writes.",
435
+ seeAlso: ["route", "relationships"],
436
+ },
437
+ relationships: {
438
+ summary: "relationship map for an account from contacts/deals/activity evidence",
439
+ phase: "Prevent",
440
+ synopsis: ["fullstackgtm relationships account (--account-id <id>|--domain <d>) [source options] [--json|--out <path>]"],
441
+ detail:
442
+ "Builds a stakeholder map with inferred buyer roles, sentiment from activity subjects, open deals, and missing-role gaps. Read-only evidence surface; no CRM writes.",
443
+ seeAlso: ["call", "route"],
444
+ },
445
+
423
446
  // Remediate — governed writes (all produce plans; nothing writes outside approve → apply)
447
+ route: {
448
+ summary: "lead-to-account matching and owner routing plan",
449
+ phase: "Remediate",
450
+ synopsis: ["fullstackgtm route leads [source options] [--match domain|company|both] [--no-inherit-owner] [--reassign-owned] [--policy <json|path>] [--save|--json|--out <path>]"],
451
+ detail:
452
+ "Matches contact-shaped leads to accounts by domain and/or company name, inherits account owners (or applies an assignment policy), and emits every change as a dry-run patch plan for approve → apply. Ambiguous matches are surfaced, never guessed.",
453
+ seeAlso: ["resolve", "reassign"],
454
+ },
424
455
  fix: {
425
456
  summary: "one-shot composite: audit one rule → suggest → approve → apply",
426
457
  phase: "Remediate",
@@ -615,15 +646,65 @@ export const HELP: Record<string, HelpEntry> = {
615
646
  // `--help` to themselves, so commandHelp() only renders these via `help <verb>`.
616
647
  export const BESPOKE_HELP = ["init", "call", "market", "tam", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
617
648
 
649
+ export const GLOBAL_FLAGS = ["--help", "--full"];
650
+ export const GLOBAL_SHORT_FLAGS = ["-h"];
651
+ export const SOURCE_FLAGS = ["--provider", "--token-env", "--input", "--demo", "--sample", "--seed", "--today"];
652
+ export const AUDIT_FLAGS = ["--config", "--rules", "--stale-days", "--fail-on", "--save", "--dry-run", "--json", "--out", "--full"];
653
+
654
+ // Complete per-command flag registry used by runCli's fail-closed flag
655
+ // validation. Keep this next to HELP so focused help, machine capabilities,
656
+ // and the parser safety gate have one command inventory to reconcile against.
657
+ export const COMMAND_FLAGS: Record<string, string[]> = {
658
+ init: ["--source", "--provider", "--out", "--force"],
659
+ login: ["--via", "--hosted", "--private-token", "--token", "--key", "--api-key", "--client-id", "--client-secret", "--scopes", "--port", "--oauth", "--device", "--instance-url", "--login-url", "--no-validate"],
660
+ logout: [],
661
+ doctor: ["--json"],
662
+ capabilities: ["--json"],
663
+ "robot-docs": [],
664
+ profiles: ["--json"],
665
+ health: ["--json"],
666
+ snapshot: [...SOURCE_FLAGS, "--since", "--out", "--archive"],
667
+ audit: [...SOURCE_FLAGS, ...AUDIT_FLAGS],
668
+ report: [...SOURCE_FLAGS, ...AUDIT_FLAGS, "--plan", "--client", "--title", "--prepared-by", "--format", "--max-examples"],
669
+ diff: ["--before", "--after", "--config", "--stale-days", "--today", "--json", "--fail-on-new-findings"],
670
+ rules: ["--config", "--json"],
671
+ resolve: [...SOURCE_FLAGS, "--name", "--domain", "--email", "--account-id", "--json"],
672
+ hierarchy: [...SOURCE_FLAGS, "--json", "--out"],
673
+ relationships: [...SOURCE_FLAGS, "--account-id", "--domain", "--json", "--out"],
674
+ route: [...SOURCE_FLAGS, "--match", "--no-inherit-owner", "--reassign-owned", "--policy", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
675
+ fix: [...SOURCE_FLAGS, "--config", "--rule", "--provider", "--min-confidence", "--include-creates", "--yes", "--confirm", "--dry-run"],
676
+ "bulk-update": [...SOURCE_FLAGS, "--where", "--set", "--guard", "--require", "--create-task", "--archive", "--force-archive-duplicates", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
677
+ dedupe: [...SOURCE_FLAGS, "--key", "--keep", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
678
+ reassign: [...SOURCE_FLAGS, "--from", "--assign-unowned", "--to", "--objects", "--where", "--except-deal-stage", "--include-closed-deals", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
679
+ backfill: [...SOURCE_FLAGS, "--since", "--pipeline", "--match-property", "--skip-unmatched", "--save", "--dry-run", "--json"],
680
+ enrich: [...SOURCE_FLAGS, "--config", "--source", "--icp", "--input", "--provider", "--stale-days", "--assign-owner", "--objects", "--max", "--staged-run", "--run-label", "--label", "--runs", "--save", "--dry-run", "--json", "--out"],
681
+ call: [...SOURCE_FLAGS, "--transcript", "--title", "--source", "--model", "--deterministic", "--heuristics", "--llm", "--json", "--ndjson", "--out", "--call", "--call-type", "--rubric", "--list", "--list-rubrics", "--attendees", "--domain", "--deal", "--save", "--dry-run"],
682
+ suggest: [...SOURCE_FLAGS, "--plan-id", "--plan", "--json", "--out"],
683
+ plans: ["--status", "--operations", "--value", "--values-from", "--min-confidence", "--include-creates", "--json"],
684
+ apply: ["--plan", "--plan-id", "--provider", "--channel", "--token-env", "--approve", "--value", "--json", "--config"],
685
+ "audit-log": ["--in", "--out", "--json"],
686
+ merge: ["--input", "--out", "--json"],
687
+ market: ["--category", "--config", "--vendor", "--snapshot", "--task-account", "--task-deal", "--capture-run", "--run", "--prior-run", "--diff", "--from", "--calls", "--anchor", "--min-mentions", "--max-claims", "--promote-lift", "--model", "--format", "--auto", "--unverified", "--save", "--dry-run", "--json", "--out"],
688
+ tam: [...SOURCE_FLAGS, "--source", "--name", "--icp", "--accounts", "--acv", "--acv-basis", "--acv-from-crm", "--deal-period", "--buyers-per-account", "--cross-checks", "--max", "--max-credits", "--usd-per-credit", "--dry-run", "--confirm", "--cron", "--label", "--save", "--json", "--out"],
689
+ icp: [...SOURCE_FLAGS, "--name", "--signals-from", "--with-history", "--prompt", "--min-score", "--from-judge", "--golden", "--against-outcomes", "--min-accuracy", "--model", "--label", "--save", "--json", "--out"],
690
+ signals: ["--bucket", "--source", "--connector", "--connector-opt", "--watchlist", "--keywords", "--from", "--label", "--since", "--account", "--contact", "--unjudged", "--touch", "--result", "--save", "--json", "--explain"],
691
+ draft: ["--from-judge", "--min-score", "--prompt", "--model", "--channel", "--save", "--dry-run", "--json", "--out"],
692
+ schedule: ["--cron", "--label", "--provider", "--trigger", "--runs", "--json"],
693
+ };
694
+
695
+ export const FLAGS_WITH_VALUES = new Set([
696
+ "--account", "--account-id", "--accounts", "--acv", "--acv-basis", "--after", "--anchor", "--api-key", "--approve", "--archive", "--assign-owner", "--attendees", "--before", "--bucket", "--buyers-per-account", "--call", "--call-type", "--calls", "--capture-run", "--category", "--channel", "--client", "--client-id", "--client-secret", "--config", "--connector", "--connector-opt", "--contact", "--cron", "--cross-checks", "--deal", "--deal-period", "--diff", "--domain", "--email", "--except-deal-stage", "--format", "--from", "--from-judge", "--golden", "--guard", "--icp", "--in", "--input", "--instance-url", "--keep", "--key", "--keywords", "--label", "--login-url", "--match", "--match-property", "--max", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--min-accuracy", "--min-confidence", "--min-mentions", "--min-score", "--model", "--name", "--objects", "--operations", "--out", "--pipeline", "--plan", "--plan-id", "--policy", "--port", "--prepared-by", "--prior-run", "--profile", "--promote-lift", "--prompt", "--provider", "--reason", "--require", "--result", "--rubric", "--rule", "--rules", "--run", "--run-label", "--runs", "--scopes", "--seed", "--set", "--signals-from", "--since", "--snapshot", "--source", "--staged-run", "--stale-days", "--status", "--task-account", "--task-deal", "--title", "--to", "--today", "--token", "--token-env", "--touch", "--transcript", "--trigger", "--usd-per-credit", "--value", "--values-from", "--vendor", "--via", "--watchlist", "--where",
697
+ ]);
698
+
618
699
  // Lifecycle-grouped front door. One line per verb, organized by the
619
700
  // Prevent→Detect→Remediate→Verify loop so a new user sees ~6 jobs, not 22 verbs.
620
701
  export function shortUsage() {
621
702
  const groups: Array<[string, string[]]> = [
622
703
  ["Get started", ["init"]],
623
704
  ["Setup & health", ["login", "logout", "doctor", "capabilities", "robot-docs", "profiles", "health"]],
624
- ["Detect — read-only", ["audit", "report", "snapshot", "diff", "rules"]],
705
+ ["Detect — read-only", ["audit", "report", "snapshot", "diff", "rules", "hierarchy", "relationships"]],
625
706
  ["Prevent — gate writes", ["resolve"]],
626
- ["Remediate — governed writes", ["fix", "bulk-update", "dedupe", "reassign", "enrich", "backfill"]],
707
+ ["Remediate — governed writes", ["fix", "bulk-update", "dedupe", "reassign", "route", "enrich", "backfill"]],
627
708
  ["Calls → evidence", ["call"]],
628
709
  ["Govern — the plan/apply spine", ["suggest", "plans", "apply", "audit-log", "merge"]],
629
710
  ["Market intelligence", ["market", "tam"]],
@@ -12,7 +12,14 @@
12
12
  // itself; this holds uniformly for read and write verbs, so a typo can never
13
13
  // silently change what a write-shaped invocation stages.
14
14
 
15
- import { HELP, usage } from "./help.ts";
15
+ import {
16
+ COMMAND_FLAGS,
17
+ FLAGS_WITH_VALUES,
18
+ GLOBAL_FLAGS,
19
+ GLOBAL_SHORT_FLAGS,
20
+ HELP,
21
+ usage,
22
+ } from "./help.ts";
16
23
 
17
24
  export function levenshtein(a: string, b: string): number {
18
25
  const dp = Array.from({ length: a.length + 1 }, () => Array<number>(b.length + 1).fill(0));
@@ -65,7 +72,7 @@ export function documentedFlags(): Set<string> {
65
72
  export type FlagTypo = {
66
73
  given: string;
67
74
  /** Human-readable correction, e.g. `--json` or `--rules missing-deal-owner`. */
68
- suggestion: string;
75
+ suggestion: string | null;
69
76
  /** The argv tokens that replace `given` in the corrected command. */
70
77
  replacement: string[];
71
78
  };
@@ -108,6 +115,53 @@ export function detectFlagTypo(args: string[]): FlagTypo | null {
108
115
  return null;
109
116
  }
110
117
 
118
+ function isFlagShaped(token: string): boolean {
119
+ return token !== "-" && token.startsWith("-");
120
+ }
121
+
122
+ function suggestCommandFlag(unknown: string, allowed: Set<string>): string | null {
123
+ const candidates = [...allowed].filter((flag) => flag.startsWith("--"));
124
+ const prefix = candidates
125
+ .filter((flag) => unknown.startsWith(`${flag}-`) || flag.startsWith(unknown))
126
+ .sort((a, b) => b.length - a.length)[0];
127
+ if (prefix) return prefix;
128
+ return nearest(unknown.toLowerCase().replace(/_/g, "-"), candidates, 3);
129
+ }
130
+
131
+ export function detectUnknownFlag(command: string, args: string[]): FlagTypo | null {
132
+ const commandFlags = COMMAND_FLAGS[command];
133
+ if (!commandFlags) return null;
134
+ const allowed = new Set([...GLOBAL_FLAGS, ...commandFlags]);
135
+ for (let index = 0; index < args.length; index += 1) {
136
+ const token = args[index];
137
+ if (token === "--") break;
138
+ if (GLOBAL_SHORT_FLAGS.includes(token)) continue;
139
+ if (!token.startsWith("-")) continue;
140
+
141
+ if (!token.startsWith("--")) {
142
+ return { given: token, suggestion: null, replacement: [] };
143
+ }
144
+
145
+ const equalsIndex = token.indexOf("=");
146
+ const flag = equalsIndex === -1 ? token : token.slice(0, equalsIndex);
147
+ if (allowed.has(flag)) {
148
+ if (equalsIndex !== -1) {
149
+ const value = token.slice(equalsIndex + 1);
150
+ const replacement = value === "" ? [flag] : [flag, value];
151
+ return { given: token, suggestion: replacement.join(" "), replacement };
152
+ }
153
+ if (FLAGS_WITH_VALUES.has(flag) && args[index + 1] !== undefined && !isFlagShaped(args[index + 1])) {
154
+ index += 1;
155
+ }
156
+ continue;
157
+ }
158
+
159
+ const suggestion = suggestCommandFlag(flag, allowed);
160
+ return { given: flag, suggestion, replacement: suggestion ? [suggestion] : [] };
161
+ }
162
+ return null;
163
+ }
164
+
111
165
  /** Nearest known command, derived from the same HELP table. */
112
166
  export function suggestCommand(command: string): string | null {
113
167
  if (!command) return null;
@@ -143,8 +197,8 @@ export function unknownFlagEnvelope(command: string, args: string[], typo: FlagT
143
197
  code: "UNKNOWN_FLAG" as const,
144
198
  message: `Unknown flag: ${typo.given}`,
145
199
  hints: [
146
- `Did you mean: ${typo.suggestion}`,
147
- `Try: ${correctedCommand(command, args, typo)}`,
200
+ ...(typo.suggestion ? [`Did you mean: ${typo.suggestion}`] : []),
201
+ ...(typo.replacement.length > 0 ? [`Try: ${correctedCommand(command, args, typo)}`] : []),
148
202
  ],
149
203
  },
150
204
  };
package/src/cli.ts CHANGED
@@ -2,7 +2,7 @@ import { activeProfile, listProfiles, setActiveProfile } from "./credentials.ts"
2
2
  import { capabilitiesCommand, printCommandHelpJson, robotDocsCommand, unknownCommandEnvelope } from "./cli/capabilities.ts";
3
3
  import { BESPOKE_HELP, commandHelp, HELP, shortUsage, stylizeShortUsage, usage } from "./cli/help.ts";
4
4
  import { readPackageInfo } from "./cli/shared.ts";
5
- import { correctedCommand, detectFlagTypo, suggestCommand, unknownFlagEnvelope } from "./cli/suggest.ts";
5
+ import { correctedCommand, detectFlagTypo, detectUnknownFlag, suggestCommand, unknownFlagEnvelope } from "./cli/suggest.ts";
6
6
  import { colorEnabled, paint } from "./cli/ui.ts";
7
7
 
8
8
  // Verb modules load lazily inside their dispatch branch below. The dispatcher
@@ -62,13 +62,19 @@ export async function runCli(argv: string[]) {
62
62
  }
63
63
  if (command === "help") {
64
64
  const [topic] = args;
65
- if (topic && topic !== "--full" && !topic.startsWith("-")) {
65
+ if (topic && !topic.startsWith("-") && args.includes("--json")) {
66
66
  // `help <cmd> --json` → machine-readable help derived from the same
67
- // HELP entry the plain text renders from. Plain output is unchanged.
68
- if (args.includes("--json")) printCommandHelpJson(topic);
69
- else console.log(commandHelp(topic));
67
+ // HELP entry the plain text renders from. JSON takes precedence over
68
+ // --full so automated callers get a stable shape.
69
+ printCommandHelpJson(topic);
70
+ } else if (args.includes("--full")) {
71
+ // --full is the global escape hatch to the complete reference, even
72
+ // when a topic is given (help <cmd> --full), unless --json is requested.
73
+ console.log(usage());
74
+ } else if (topic && !topic.startsWith("-")) {
75
+ console.log(commandHelp(topic));
70
76
  } else {
71
- console.log(args.includes("--full") || topic === "--full" ? usage() : styledShortUsage());
77
+ console.log(styledShortUsage());
72
78
  }
73
79
  return;
74
80
  }
@@ -94,19 +100,26 @@ export async function runCli(argv: string[]) {
94
100
 
95
101
  // Flag typos used to be silently dropped by the per-verb parsers —
96
102
  // `audit --demo --jsn` exited 0 and printed markdown where the agent asked
97
- // for JSON. Near-miss unknown flags (≤ 1 edit from a flag documented in
98
- // help.ts) now stop with the exact corrected command. Suggest-only: never
99
- // auto-execute the correction, so a typo can never change what a
100
- // write-shaped invocation stages.
103
+ // for JSON. Every flag-shaped token is now checked against the per-command
104
+ // registry in help.ts, so unknown flags fail closed instead of being ignored.
105
+ // Suggest-only: never auto-execute the correction, so a typo can never
106
+ // silently change what a write-shaped invocation stages.
101
107
  if (command in HELP) {
102
- const typo = detectFlagTypo(args);
108
+ const typo = detectUnknownFlag(command, args);
103
109
  if (typo) {
104
110
  if (args.includes("--json")) {
105
111
  console.log(JSON.stringify(unknownFlagEnvelope(command, args, typo), null, 2));
106
112
  } else {
113
+ console.error(`Unknown flag for ${command}: ${typo.given}`);
114
+ // Keep the old near-miss shape too; existing agents key off it.
107
115
  console.error(`Unknown flag: ${typo.given}`);
108
- console.error(`Did you mean: ${typo.suggestion}`);
109
- console.error(`Try: ${correctedCommand(command, args, typo)}`);
116
+ if (typo.suggestion) {
117
+ console.error(`Did you mean ${typo.suggestion}?`);
118
+ console.error(`Did you mean: ${typo.suggestion}`);
119
+ if (typo.replacement.length > 0) {
120
+ console.error(`Try: ${correctedCommand(command, args, typo)}`);
121
+ }
122
+ }
110
123
  }
111
124
  process.exitCode = 1;
112
125
  return;
@@ -165,6 +178,18 @@ export async function runCli(argv: string[]) {
165
178
  await (await import("./cli/fix.ts")).resolveCommand(args);
166
179
  return;
167
180
  }
181
+ if (command === "route") {
182
+ await (await import("./cli/fix.ts")).routeCommand(args);
183
+ return;
184
+ }
185
+ if (command === "hierarchy") {
186
+ await (await import("./cli/fix.ts")).hierarchyCommand(args);
187
+ return;
188
+ }
189
+ if (command === "relationships") {
190
+ await (await import("./cli/fix.ts")).relationshipsCommand(args);
191
+ return;
192
+ }
168
193
  if (command === "bulk-update") {
169
194
  await (await import("./cli/fix.ts")).bulkUpdateCommand(args);
170
195
  return;
@@ -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): 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;
@@ -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
- ): 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;
@@ -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
+ }