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.
- package/CHANGELOG.md +38 -6
- package/INSTALL_FOR_AGENTS.md +3 -3
- package/README.md +16 -8
- package/dist/audit.js +7 -0
- package/dist/backfill.js +2 -16
- package/dist/cli/fix.d.ts +3 -0
- package/dist/cli/fix.js +70 -1
- package/dist/cli/help.d.ts +6 -0
- package/dist/cli/help.js +76 -2
- package/dist/cli/suggest.d.ts +2 -1
- package/dist/cli/suggest.js +49 -3
- package/dist/cli.js +40 -15
- package/dist/connectors/hubspot.d.ts +2 -1
- package/dist/connectors/salesforce.d.ts +2 -1
- package/dist/connectors/signalSources.js +2 -11
- package/dist/format.js +31 -5
- package/dist/freeEmailDomains.d.ts +1 -0
- package/dist/freeEmailDomains.js +14 -0
- package/dist/hierarchy.d.ts +29 -0
- package/dist/hierarchy.js +164 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +5 -1
- package/dist/mcp.js +19 -15
- package/dist/relationships.d.ts +39 -0
- package/dist/relationships.js +101 -0
- package/dist/route.d.ts +46 -0
- package/dist/route.js +228 -0
- package/dist/rules.d.ts +1 -0
- package/dist/rules.js +172 -12
- package/dist/types.d.ts +15 -0
- package/docs/api.md +20 -3
- package/docs/architecture.md +5 -2
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +2 -2
- package/src/audit.ts +7 -0
- package/src/backfill.ts +2 -17
- package/src/cli/fix.ts +68 -1
- package/src/cli/help.ts +83 -2
- package/src/cli/suggest.ts +58 -4
- package/src/cli.ts +38 -13
- package/src/connectors/hubspot.ts +3 -1
- package/src/connectors/salesforce.ts +3 -1
- package/src/connectors/signalSources.ts +2 -12
- package/src/format.ts +33 -5
- package/src/freeEmailDomains.ts +14 -0
- package/src/hierarchy.ts +185 -0
- package/src/index.ts +25 -0
- package/src/mcp.ts +22 -16
- package/src/relationships.ts +115 -0
- package/src/route.ts +278 -0
- package/src/rules.ts +192 -12
- 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"]],
|
package/src/cli/suggest.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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.
|
|
68
|
-
|
|
69
|
-
|
|
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(
|
|
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.
|
|
98
|
-
// help.ts
|
|
99
|
-
// auto-execute the correction, so a typo can never
|
|
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 =
|
|
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
|
-
|
|
109
|
-
|
|
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):
|
|
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
|
-
):
|
|
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 ||
|
|
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
+
]);
|
package/src/hierarchy.ts
ADDED
|
@@ -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
|
+
}
|