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.
- package/CHANGELOG.md +44 -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/connector.js +96 -25
- package/dist/connectors/hubspot.d.ts +2 -1
- package/dist/connectors/hubspot.js +170 -4
- package/dist/connectors/salesforce.d.ts +2 -1
- package/dist/connectors/salesforce.js +135 -0
- 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 +30 -0
- package/docs/api.md +22 -4
- 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/connector.ts +94 -20
- package/src/connectors/hubspot.ts +164 -5
- package/src/connectors/salesforce.ts +136 -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 +32 -0
package/src/audit.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
PipelineFindingType,
|
|
10
10
|
PatchOperation,
|
|
11
11
|
PatchPlan,
|
|
12
|
+
PatchPlanAssumption,
|
|
12
13
|
SourceFreshness,
|
|
13
14
|
} from "./types.ts";
|
|
14
15
|
|
|
@@ -53,6 +54,7 @@ export function auditSnapshot(
|
|
|
53
54
|
const context = { snapshot, policy, index: buildSnapshotIndex(snapshot) };
|
|
54
55
|
const findings: AuditFinding[] = [];
|
|
55
56
|
const operations: PatchOperation[] = [];
|
|
57
|
+
const assumptionsById = new Map<string, PatchPlanAssumption>();
|
|
56
58
|
|
|
57
59
|
for (const rule of rules) {
|
|
58
60
|
try {
|
|
@@ -63,6 +65,9 @@ export function auditSnapshot(
|
|
|
63
65
|
const result = rule.evaluate(context);
|
|
64
66
|
findings.push(...result.findings);
|
|
65
67
|
operations.push(...result.operations);
|
|
68
|
+
for (const assumption of result.assumptions ?? []) {
|
|
69
|
+
if (!assumptionsById.has(assumption.id)) assumptionsById.set(assumption.id, assumption);
|
|
70
|
+
}
|
|
66
71
|
try {
|
|
67
72
|
onRule?.(rule.id, "done", result.findings.length);
|
|
68
73
|
} catch {
|
|
@@ -70,6 +75,7 @@ export function auditSnapshot(
|
|
|
70
75
|
}
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
const assumptions = Array.from(assumptionsById.values());
|
|
73
79
|
|
|
74
80
|
const evidence = buildEvidence(snapshot, findings, policy.today);
|
|
75
81
|
const pipelineFindings = buildPipelineFindings(findings, operations, evidence, policy.today);
|
|
@@ -85,6 +91,7 @@ export function auditSnapshot(
|
|
|
85
91
|
findings,
|
|
86
92
|
pipelineFindings,
|
|
87
93
|
evidence,
|
|
94
|
+
assumptions: assumptions.length > 0 ? assumptions : undefined,
|
|
88
95
|
operations,
|
|
89
96
|
};
|
|
90
97
|
}
|
package/src/backfill.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
PatchPlan,
|
|
22
22
|
} from "./types.ts";
|
|
23
23
|
import type { StripePaidInvoice } from "./connectors/stripe.ts";
|
|
24
|
+
import { FREE_EMAIL_DOMAINS } from "./freeEmailDomains.ts";
|
|
24
25
|
import { normalizeDomain } from "./merge.ts";
|
|
25
26
|
|
|
26
27
|
// Mirrors stableHash in rules.ts (FNV-1a); duplicated to keep backfill.ts
|
|
@@ -36,22 +37,6 @@ function fnv1a(value: string): string {
|
|
|
36
37
|
|
|
37
38
|
export const DEFAULT_BACKFILL_MATCH_PROPERTY = "stripe_invoice_id";
|
|
38
39
|
|
|
39
|
-
// Freemail domains never become a company domain: stamping "gmail.com" on an
|
|
40
|
-
// account (or resolving a company BY gmail.com) would collapse unrelated
|
|
41
|
-
// customers onto one record. Mirrors FREE_MAIL in connectors/signalSources.ts
|
|
42
|
-
// (duplicated to keep backfill.ts importable without connector code, the
|
|
43
|
-
// fnv1a precedent above).
|
|
44
|
-
const FREE_MAIL = new Set([
|
|
45
|
-
"gmail.com",
|
|
46
|
-
"yahoo.com",
|
|
47
|
-
"hotmail.com",
|
|
48
|
-
"outlook.com",
|
|
49
|
-
"icloud.com",
|
|
50
|
-
"aol.com",
|
|
51
|
-
"proton.me",
|
|
52
|
-
"protonmail.com",
|
|
53
|
-
]);
|
|
54
|
-
|
|
55
40
|
export type StripeBackfillOptions = {
|
|
56
41
|
/** Pipeline id or case-insensitive label; absent = the portal's default pipeline. */
|
|
57
42
|
pipeline?: string;
|
|
@@ -183,7 +168,7 @@ export function buildStripeBackfillPlan(
|
|
|
183
168
|
let companyDomain = normalizeDomain(account?.domain);
|
|
184
169
|
let accountIsNew = false;
|
|
185
170
|
if (!account && createMissingAccounts) {
|
|
186
|
-
const usableDomain = domain && !
|
|
171
|
+
const usableDomain = domain && !FREE_EMAIL_DOMAINS.has(domain) ? domain : undefined;
|
|
187
172
|
const name = invoice.customerName?.trim() || usableDomain;
|
|
188
173
|
if (name) {
|
|
189
174
|
companyName = name;
|
package/src/cli/fix.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
|
|
2
2
|
|
|
3
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { auditSnapshot, defaultPolicy } from "../audit.ts";
|
|
6
6
|
import { loadConfig, mergePolicy, resolveConfiguredRules } from "../config.ts";
|
|
@@ -8,6 +8,10 @@ import { applyPatchPlan } from "../connector.ts";
|
|
|
8
8
|
import { patchPlanToMarkdown } from "../format.ts";
|
|
9
9
|
import { createFilePlanStore } from "../planStore.ts";
|
|
10
10
|
import { resolveRecord, type ResolveCandidate } from "../resolve.ts";
|
|
11
|
+
import { parseAssignmentPolicy } from "../assign.ts";
|
|
12
|
+
import { buildLeadRoutePlan } from "../route.ts";
|
|
13
|
+
import { accountHierarchyToMarkdown, buildAccountHierarchy } from "../hierarchy.ts";
|
|
14
|
+
import { buildRelationshipMap, relationshipMapToMarkdown } from "../relationships.ts";
|
|
11
15
|
import { buildBulkUpdatePlan } from "../bulkUpdate.ts";
|
|
12
16
|
import { buildDedupePlan, type DedupeOptions } from "../dedupe.ts";
|
|
13
17
|
import { buildReassignPlans, type ReassignObjectType } from "../reassign.ts";
|
|
@@ -371,3 +375,66 @@ function snapshotSourceHint(args: string[]) {
|
|
|
371
375
|
if (input) return `--input ${input} `;
|
|
372
376
|
return "";
|
|
373
377
|
}
|
|
378
|
+
|
|
379
|
+
function parseJsonOrFile(value: string): unknown {
|
|
380
|
+
const candidate = resolve(process.cwd(), value);
|
|
381
|
+
const text = existsSync(candidate) ? readFileSync(candidate, "utf8") : value;
|
|
382
|
+
return JSON.parse(text);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export async function routeCommand(args: string[]) {
|
|
386
|
+
const [subcommand, ...rest] = args;
|
|
387
|
+
if (subcommand !== "leads") {
|
|
388
|
+
throw new Error("Usage: fullstackgtm route leads [source options] [--match domain|company|both] [--no-inherit-owner] [--reassign-owned] [--policy <json|path>] [--save] [--json] [--out <path>]");
|
|
389
|
+
}
|
|
390
|
+
const match = option(rest, "--match") ?? "domain";
|
|
391
|
+
if (!["domain", "company", "both"].includes(match)) throw new Error("--match must be domain, company, or both");
|
|
392
|
+
const policyRaw = option(rest, "--policy");
|
|
393
|
+
const snapshot = await readSnapshot(rest);
|
|
394
|
+
const result = buildLeadRoutePlan(snapshot, {
|
|
395
|
+
matchByDomain: match === "domain" || match === "both",
|
|
396
|
+
matchByCompanyName: match === "company" || match === "both",
|
|
397
|
+
inheritAccountOwner: !rest.includes("--no-inherit-owner"),
|
|
398
|
+
reassignExistingOwner: rest.includes("--reassign-owned"),
|
|
399
|
+
assignmentPolicy: policyRaw ? parseAssignmentPolicy(parseJsonOrFile(policyRaw)) : undefined,
|
|
400
|
+
maxOperations: numericOption(rest, "--max-operations"),
|
|
401
|
+
reason: option(rest, "--reason") ?? undefined,
|
|
402
|
+
});
|
|
403
|
+
if (rest.includes("--json")) {
|
|
404
|
+
const out = option(rest, "--out");
|
|
405
|
+
if (out) writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(result.plan, null, 2)}\n`);
|
|
406
|
+
if (saveRequested(rest)) await createFilePlanStore().save(result.plan);
|
|
407
|
+
console.error(`Route dry-run: ${JSON.stringify(result.counts)}`);
|
|
408
|
+
console.log(JSON.stringify({ counts: result.counts, plan: result.plan }, null, 2));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
console.error(
|
|
412
|
+
`Route dry-run: ${result.counts.linkOperations} link(s), ${result.counts.ownerOperations} owner assignment(s), ${result.counts.ambiguousAccountMatches} ambiguous match(es).`,
|
|
413
|
+
);
|
|
414
|
+
await emitPlan(result.plan, rest);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export async function hierarchyCommand(args: string[]) {
|
|
418
|
+
const [subcommand, ...rest] = args;
|
|
419
|
+
if (subcommand !== "report") throw new Error("Usage: fullstackgtm hierarchy report [source options] [--json|--out <path>]");
|
|
420
|
+
const snapshot = await readSnapshot(rest);
|
|
421
|
+
const report = buildAccountHierarchy(snapshot);
|
|
422
|
+
const out = option(rest, "--out");
|
|
423
|
+
const rendered = rest.includes("--json") ? `${JSON.stringify(report, null, 2)}\n` : `${accountHierarchyToMarkdown(report)}\n`;
|
|
424
|
+
if (out) writeFileSync(resolve(process.cwd(), out), rendered);
|
|
425
|
+
console.log(rendered.trimEnd());
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export async function relationshipsCommand(args: string[]) {
|
|
429
|
+
const [subcommand, ...rest] = args;
|
|
430
|
+
if (subcommand !== "account") throw new Error("Usage: fullstackgtm relationships account (--account-id <id>|--domain <d>) [source options] [--json|--out <path>]");
|
|
431
|
+
const accountId = option(rest, "--account-id") ?? undefined;
|
|
432
|
+
const domain = option(rest, "--domain") ?? undefined;
|
|
433
|
+
if (!accountId && !domain) throw new Error("relationships account needs --account-id <id> or --domain <domain>");
|
|
434
|
+
const snapshot = await readSnapshot(rest);
|
|
435
|
+
const map = buildRelationshipMap(snapshot, { accountId, domain });
|
|
436
|
+
const out = option(rest, "--out");
|
|
437
|
+
const rendered = rest.includes("--json") ? `${JSON.stringify(map, null, 2)}\n` : `${relationshipMapToMarkdown(map)}\n`;
|
|
438
|
+
if (out) writeFileSync(resolve(process.cwd(), out), rendered);
|
|
439
|
+
console.log(rendered.trimEnd());
|
|
440
|
+
}
|
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;
|
package/src/connector.ts
CHANGED
|
@@ -138,6 +138,17 @@ function normalizeForComparison(value: unknown): string | null {
|
|
|
138
138
|
return String(value);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
function isApplyBatchCandidate(operation: PatchOperation): boolean {
|
|
142
|
+
return (
|
|
143
|
+
((operation.operation === "set_field" || operation.operation === "clear_field") && Boolean(operation.field)) ||
|
|
144
|
+
operation.operation === "archive_record"
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sameApplyBatchKind(a: PatchOperation, b: PatchOperation): boolean {
|
|
149
|
+
return a.operation === b.operation && a.objectType === b.objectType;
|
|
150
|
+
}
|
|
151
|
+
|
|
141
152
|
/**
|
|
142
153
|
* Apply an approved subset of a patch plan through a connector.
|
|
143
154
|
*
|
|
@@ -267,6 +278,11 @@ export async function applyPatchPlan(
|
|
|
267
278
|
if (checkConflicts && connector.readField) {
|
|
268
279
|
for (const operation of plan.operations) {
|
|
269
280
|
if (!approved.has(operation.id) || conflicts.has(operation.id)) continue;
|
|
281
|
+
// Generic batch connectors (Salesforce) do their own batched CAS read for
|
|
282
|
+
// eligible field writes, so don't pre-read those one-by-one here.
|
|
283
|
+
if (connector.applyBatch && isApplyBatchCandidate(operation) && !operation.groupId && !operation.preconditions?.length) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
270
286
|
let conflict: PatchOperationResult | null = null;
|
|
271
287
|
if (operation.field && FIELD_WRITE_OPERATIONS.has(operation.operation)) {
|
|
272
288
|
const current = await connector.readField(
|
|
@@ -349,31 +365,34 @@ export async function applyPatchPlan(
|
|
|
349
365
|
let lastNotified = results.length;
|
|
350
366
|
const notifyProgress = () => {
|
|
351
367
|
if ((!options.onOperation && !options.progress) || results.length === lastNotified) return;
|
|
352
|
-
lastNotified
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
368
|
+
while (lastNotified < results.length) {
|
|
369
|
+
const last = results[lastNotified];
|
|
370
|
+
lastNotified += 1;
|
|
371
|
+
if (last.status === "applied") progressCounts.applied += 1;
|
|
372
|
+
else if (last.status === "failed") progressCounts.failed += 1;
|
|
373
|
+
else if (last.status === "conflict") progressCounts.conflicts += 1;
|
|
374
|
+
else progressCounts.skipped += 1;
|
|
375
|
+
// Shared vocabulary: operation id + status only — a conflict/failure
|
|
376
|
+
// detail can echo field values, which never leave the machine.
|
|
377
|
+
options.progress?.opResult(last.operationId, last.status);
|
|
378
|
+
options.progress?.items(results.length - resultsBefore, plan.operations.length);
|
|
379
|
+
if (options.onOperation) {
|
|
380
|
+
try {
|
|
381
|
+
options.onOperation({
|
|
382
|
+
completed: lastNotified - resultsBefore,
|
|
383
|
+
total: plan.operations.length,
|
|
384
|
+
...progressCounts,
|
|
385
|
+
});
|
|
386
|
+
} catch {
|
|
387
|
+
// progress is presentation-only
|
|
388
|
+
}
|
|
371
389
|
}
|
|
372
390
|
}
|
|
373
391
|
};
|
|
374
392
|
|
|
375
393
|
emitStage("operations");
|
|
376
|
-
for (
|
|
394
|
+
for (let opIndex = 0; opIndex < plan.operations.length; opIndex++) {
|
|
395
|
+
const operation = plan.operations[opIndex];
|
|
377
396
|
// Report the previous iteration's result (guarded: no-op if it pushed
|
|
378
397
|
// nothing). One-iteration lag keeps this a two-line hook instead of a
|
|
379
398
|
// notify after all ten push sites; the loop-exit call below flushes the last.
|
|
@@ -441,6 +460,61 @@ export async function applyPatchPlan(
|
|
|
441
460
|
continue;
|
|
442
461
|
}
|
|
443
462
|
|
|
463
|
+
if (connector.applyBatch && isApplyBatchCandidate(operation) && (checkConflicts || operation.operation === "archive_record") && !operation.groupId && !operation.preconditions?.length) {
|
|
464
|
+
const chunk: PatchOperation[] = [];
|
|
465
|
+
const applyBatchLimit = Math.max(1, connector.applyBatchLimit ?? 200);
|
|
466
|
+
for (let scan = opIndex; scan < plan.operations.length && chunk.length < applyBatchLimit; scan++) {
|
|
467
|
+
const candidate = plan.operations[scan];
|
|
468
|
+
if (!sameApplyBatchKind(operation, candidate) || !isApplyBatchCandidate(candidate)) break;
|
|
469
|
+
const candidateOverride = options.valueOverrides?.[candidate.id];
|
|
470
|
+
if (
|
|
471
|
+
!approved.has(candidate.id) ||
|
|
472
|
+
(requiresHumanInput(candidate.afterValue) && candidateOverride === undefined) ||
|
|
473
|
+
conflicts.has(candidate.id) ||
|
|
474
|
+
Boolean(candidate.groupId) ||
|
|
475
|
+
Boolean(candidate.preconditions?.length) ||
|
|
476
|
+
(staleByFilter && staleByFilter.has(candidate.objectId)) ||
|
|
477
|
+
irreversibleStale.has(candidate.id)
|
|
478
|
+
) {
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
chunk.push(candidateOverride === undefined ? candidate : { ...candidate, afterValue: candidateOverride });
|
|
482
|
+
}
|
|
483
|
+
if (chunk.length > 1) {
|
|
484
|
+
try {
|
|
485
|
+
const batchResults = await connector.applyBatch(chunk);
|
|
486
|
+
const byOperationId = new Map(batchResults.map((result) => [result.operationId, result]));
|
|
487
|
+
for (const batchOperation of chunk) {
|
|
488
|
+
const result = byOperationId.get(batchOperation.id) ?? {
|
|
489
|
+
operationId: batchOperation.id,
|
|
490
|
+
status: "failed" as const,
|
|
491
|
+
detail: "Batch connector did not return a result for this operation.",
|
|
492
|
+
};
|
|
493
|
+
results.push(result);
|
|
494
|
+
if (result.status === "applied" || result.status === "failed") attempted += 1;
|
|
495
|
+
if (result.status === "applied") applied += 1;
|
|
496
|
+
}
|
|
497
|
+
const appliedInBatch = batchResults.filter((result) => result.status === "applied").length;
|
|
498
|
+
appliedSinceRecheck += appliedInBatch;
|
|
499
|
+
if (appliedInBatch > 0 && (applied === appliedInBatch || appliedSinceRecheck >= recheckEvery)) {
|
|
500
|
+
appliedSinceRecheck = 0;
|
|
501
|
+
await refreshSnapshotChecks();
|
|
502
|
+
}
|
|
503
|
+
} catch (error) {
|
|
504
|
+
for (const batchOperation of chunk) {
|
|
505
|
+
results.push({
|
|
506
|
+
operationId: batchOperation.id,
|
|
507
|
+
status: "failed",
|
|
508
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
509
|
+
});
|
|
510
|
+
attempted += 1;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
opIndex += chunk.length - 1;
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
444
518
|
const resolved: PatchOperation =
|
|
445
519
|
override === undefined ? operation : { ...operation, afterValue: override };
|
|
446
520
|
|