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/dist/rules.js CHANGED
@@ -36,6 +36,91 @@ export function auditFindingId(ruleId, objectId) {
36
36
  export function patchOperationId(ruleId, objectId) {
37
37
  return `op_${stableHash(`${ruleId}:${objectId}`)}`;
38
38
  }
39
+ const OPEN_DEAL_FLAGS_ASSUMPTION = {
40
+ id: "open-deal-derived-from-closed-won-flags",
41
+ text: "Derived open-deal status from canonical isClosed/isWon flags; a deal is open when neither flag is true.",
42
+ source: "CanonicalDeal.isClosed/isWon",
43
+ confidence: "derived",
44
+ };
45
+ const MISSING_CLOSED_WON_FLAGS_ASSUMPTION = {
46
+ id: "missing-closed-won-flags-treated-open",
47
+ text: "Treated deals with missing isClosed/isWon flags as open so hygiene rules do not silently ignore active pipeline.",
48
+ source: "CanonicalDeal.isClosed/isWon",
49
+ confidence: "heuristic",
50
+ };
51
+ const ORPHAN_ACCOUNT_RELATIONSHIP_ASSUMPTION = {
52
+ id: "account-without-contacts-or-deals-treated-orphan",
53
+ text: "Treated an account as orphaned when the snapshot has no contacts and no deals linked to that account.",
54
+ source: "snapshot account/contact/deal associations",
55
+ confidence: "derived",
56
+ };
57
+ const DEAL_OWNER_POLICY_ASSUMPTION = {
58
+ id: "deal-owner-required-by-policy",
59
+ text: "Treated a missing or unknown deal owner as invalid when requireDealOwner policy is enabled.",
60
+ source: "GtmPolicy.requireDealOwner",
61
+ confidence: "heuristic",
62
+ };
63
+ const DEAL_ACCOUNT_POLICY_ASSUMPTION = {
64
+ id: "deal-account-required-by-policy",
65
+ text: "Treated a missing or unknown deal account link as invalid when requireAccountForDeal policy is enabled.",
66
+ source: "GtmPolicy.requireAccountForDeal",
67
+ confidence: "heuristic",
68
+ };
69
+ const DEAL_AMOUNT_POLICY_ASSUMPTION = {
70
+ id: "open-deal-amount-required-by-policy",
71
+ text: "Treated open deals with missing or zero amount as forecast-risk findings unless requireDealAmount is disabled.",
72
+ source: "GtmPolicy.requireDealAmount",
73
+ confidence: "heuristic",
74
+ };
75
+ const DUPLICATE_ACCOUNT_DOMAIN_ASSUMPTION = {
76
+ id: "same-normalized-domain-duplicate-account",
77
+ text: "Treated accounts with the same normalized domain as duplicate-account candidates.",
78
+ source: "CanonicalAccount.domain",
79
+ confidence: "heuristic",
80
+ };
81
+ const DUPLICATE_CONTACT_EMAIL_ASSUMPTION = {
82
+ id: "same-email-duplicate-contact",
83
+ text: "Treated contacts with the same lowercased email address as duplicate-contact candidates.",
84
+ source: "CanonicalContact.email",
85
+ confidence: "heuristic",
86
+ };
87
+ const DUPLICATE_OPEN_DEAL_ASSUMPTION = {
88
+ id: "same-account-and-name-duplicate-open-deal",
89
+ text: "Treated open deals with the same normalized name on the same account scope as duplicate-opportunity candidates.",
90
+ source: "CanonicalDeal.accountId/name",
91
+ confidence: "heuristic",
92
+ };
93
+ const ACCOUNT_WITH_OPEN_DEAL_NEEDS_CONTACT_ASSUMPTION = {
94
+ id: "open-pipeline-account-needs-contact",
95
+ text: "Treated accounts with open pipeline but no linked contacts as coverage gaps requiring a buying-committee contact.",
96
+ source: "snapshot account/contact/deal associations",
97
+ confidence: "heuristic",
98
+ };
99
+ const SINGLE_SOURCE_ACCOUNT_ASSUMPTION = {
100
+ id: "single-source-account-is-cross-system-gap",
101
+ text: "Treated an account present in only one connected system as a cross-system reconciliation gap.",
102
+ source: "ProviderIdentity.provider",
103
+ confidence: "heuristic",
104
+ };
105
+ function staleDealPolicyAssumption(days) {
106
+ return {
107
+ id: "stale-deal-days-policy",
108
+ text: `Treated open deals with no recorded activity for more than ${days} days as stale.`,
109
+ source: "GtmPolicy.staleDealDays",
110
+ confidence: "heuristic",
111
+ };
112
+ }
113
+ function closingSoonPolicyAssumption(windowDays, idleDays) {
114
+ return {
115
+ id: "closing-soon-idle-policy",
116
+ text: `Treated open deals closing within ${windowDays} days and idle for more than ${idleDays} days as silent slip risk.`,
117
+ source: "GtmPolicy.closingSoonDays/closingSoonIdleDays",
118
+ confidence: "heuristic",
119
+ };
120
+ }
121
+ function assumptionsIfFindings(findings, assumptions) {
122
+ return findings.length > 0 ? assumptions : undefined;
123
+ }
39
124
  export function stableHash(value) {
40
125
  let hash = 0;
41
126
  for (let index = 0; index < value.length; index += 1) {
@@ -150,7 +235,11 @@ export const orphanAccountRule = {
150
235
  approvalRequired: true,
151
236
  });
152
237
  }
153
- return { findings, operations };
238
+ return {
239
+ findings,
240
+ operations,
241
+ assumptions: assumptionsIfFindings(findings, [ORPHAN_ACCOUNT_RELATIONSHIP_ASSUMPTION]),
242
+ };
154
243
  },
155
244
  };
156
245
  export const missingDealOwnerRule = {
@@ -180,7 +269,11 @@ export const missingDealOwnerRule = {
180
269
  approvalRequired: true,
181
270
  });
182
271
  }
183
- return { findings, operations };
272
+ return {
273
+ findings,
274
+ operations,
275
+ assumptions: assumptionsIfFindings(findings, [DEAL_OWNER_POLICY_ASSUMPTION]),
276
+ };
184
277
  },
185
278
  };
186
279
  export const missingDealAccountRule = {
@@ -210,7 +303,11 @@ export const missingDealAccountRule = {
210
303
  approvalRequired: true,
211
304
  });
212
305
  }
213
- return { findings, operations };
306
+ return {
307
+ findings,
308
+ operations,
309
+ assumptions: assumptionsIfFindings(findings, [DEAL_ACCOUNT_POLICY_ASSUMPTION]),
310
+ };
214
311
  },
215
312
  };
216
313
  export const pastCloseDateRule = {
@@ -240,7 +337,14 @@ export const pastCloseDateRule = {
240
337
  approvalRequired: true,
241
338
  });
242
339
  }
243
- return { findings, operations };
340
+ return {
341
+ findings,
342
+ operations,
343
+ assumptions: assumptionsIfFindings(findings, [
344
+ OPEN_DEAL_FLAGS_ASSUMPTION,
345
+ MISSING_CLOSED_WON_FLAGS_ASSUMPTION,
346
+ ]),
347
+ };
244
348
  },
245
349
  };
246
350
  export const staleDealRule = {
@@ -280,7 +384,15 @@ export const staleDealRule = {
280
384
  approvalRequired: true,
281
385
  });
282
386
  }
283
- return { findings, operations };
387
+ return {
388
+ findings,
389
+ operations,
390
+ assumptions: assumptionsIfFindings(findings, [
391
+ OPEN_DEAL_FLAGS_ASSUMPTION,
392
+ MISSING_CLOSED_WON_FLAGS_ASSUMPTION,
393
+ staleDealPolicyAssumption(policy.staleDealDays),
394
+ ]),
395
+ };
284
396
  },
285
397
  };
286
398
  export const missingDealAmountRule = {
@@ -312,7 +424,15 @@ export const missingDealAmountRule = {
312
424
  approvalRequired: true,
313
425
  });
314
426
  }
315
- return { findings, operations };
427
+ return {
428
+ findings,
429
+ operations,
430
+ assumptions: assumptionsIfFindings(findings, [
431
+ OPEN_DEAL_FLAGS_ASSUMPTION,
432
+ MISSING_CLOSED_WON_FLAGS_ASSUMPTION,
433
+ DEAL_AMOUNT_POLICY_ASSUMPTION,
434
+ ]),
435
+ };
316
436
  },
317
437
  };
318
438
  function duplicateGroups(items, keyOf) {
@@ -365,7 +485,11 @@ export const duplicateAccountDomainRule = {
365
485
  rollback: "IRREVERSIBLE: provider merges cannot be unmerged. The pre-apply snapshot retains every record's field values; recreate a record manually from it if a merge was wrong.",
366
486
  });
367
487
  }
368
- return { findings, operations };
488
+ return {
489
+ findings,
490
+ operations,
491
+ assumptions: assumptionsIfFindings(findings, [DUPLICATE_ACCOUNT_DOMAIN_ASSUMPTION]),
492
+ };
369
493
  },
370
494
  };
371
495
  export const duplicateContactEmailRule = {
@@ -402,7 +526,11 @@ export const duplicateContactEmailRule = {
402
526
  rollback: "IRREVERSIBLE: provider merges cannot be unmerged. The pre-apply snapshot retains every record's field values; recreate a record manually from it if a merge was wrong.",
403
527
  });
404
528
  }
405
- return { findings, operations };
529
+ return {
530
+ findings,
531
+ operations,
532
+ assumptions: assumptionsIfFindings(findings, [DUPLICATE_CONTACT_EMAIL_ASSUMPTION]),
533
+ };
406
534
  },
407
535
  };
408
536
  export const duplicateOpenDealRule = {
@@ -449,7 +577,15 @@ export const duplicateOpenDealRule = {
449
577
  rollback: "IRREVERSIBLE: provider merges cannot be unmerged. The pre-apply snapshot retains every record's field values; recreate a record manually from it if a merge was wrong.",
450
578
  });
451
579
  }
452
- return { findings, operations };
580
+ return {
581
+ findings,
582
+ operations,
583
+ assumptions: assumptionsIfFindings(findings, [
584
+ OPEN_DEAL_FLAGS_ASSUMPTION,
585
+ MISSING_CLOSED_WON_FLAGS_ASSUMPTION,
586
+ DUPLICATE_OPEN_DEAL_ASSUMPTION,
587
+ ]),
588
+ };
453
589
  },
454
590
  };
455
591
  export const activeDealAccountWithoutContactsRule = {
@@ -491,7 +627,15 @@ export const activeDealAccountWithoutContactsRule = {
491
627
  approvalRequired: true,
492
628
  });
493
629
  }
494
- return { findings, operations };
630
+ return {
631
+ findings,
632
+ operations,
633
+ assumptions: assumptionsIfFindings(findings, [
634
+ OPEN_DEAL_FLAGS_ASSUMPTION,
635
+ MISSING_CLOSED_WON_FLAGS_ASSUMPTION,
636
+ ACCOUNT_WITH_OPEN_DEAL_NEEDS_CONTACT_ASSUMPTION,
637
+ ]),
638
+ };
495
639
  },
496
640
  };
497
641
  export const closingSoonInactiveRule = {
@@ -536,12 +680,21 @@ export const closingSoonInactiveRule = {
536
680
  approvalRequired: true,
537
681
  });
538
682
  }
539
- return { findings, operations };
683
+ return {
684
+ findings,
685
+ operations,
686
+ assumptions: assumptionsIfFindings(findings, [
687
+ OPEN_DEAL_FLAGS_ASSUMPTION,
688
+ MISSING_CLOSED_WON_FLAGS_ASSUMPTION,
689
+ closingSoonPolicyAssumption(windowDays, idleDays),
690
+ ]),
691
+ };
540
692
  },
541
693
  };
542
694
  export const accountSingleSourceRule = {
543
695
  id: "account-single-source",
544
696
  title: "Account exists in only one connected system",
697
+ emitsOperations: false,
545
698
  description: "On merged multi-system snapshots, flags accounts known to just one source — the seams where GTM systems disagree.",
546
699
  category: "cross-system",
547
700
  evaluate: ({ snapshot }) => {
@@ -566,9 +719,16 @@ export const accountSingleSourceRule = {
566
719
  recommendation: "Check whether the account is missing from the other systems or matched under a different domain/name.",
567
720
  });
568
721
  }
569
- return { findings, operations: [] };
722
+ return {
723
+ findings,
724
+ operations: [],
725
+ assumptions: assumptionsIfFindings(findings, [SINGLE_SOURCE_ACCOUNT_ASSUMPTION]),
726
+ };
570
727
  },
571
728
  };
729
+ export function isFindingsOnlyRule(rule) {
730
+ return rule.emitsOperations === false;
731
+ }
572
732
  export const builtinAuditRules = [
573
733
  orphanAccountRule,
574
734
  missingDealOwnerRule,
package/dist/types.d.ts CHANGED
@@ -58,6 +58,13 @@ export type CreateRecordPayload = {
58
58
  dealPipeline?: string;
59
59
  };
60
60
  export type AuditFindingSeverity = "info" | "warning" | "critical";
61
+ export type PatchPlanAssumptionConfidence = "derived" | "heuristic" | "guess";
62
+ export type PatchPlanAssumption = {
63
+ id: string;
64
+ text: string;
65
+ source?: string;
66
+ confidence?: PatchPlanAssumptionConfidence;
67
+ };
61
68
  /**
62
69
  * One claim that a canonical record exists in an external system. A record
63
70
  * merged from several providers carries one identity per provider.
@@ -323,6 +330,10 @@ export type PatchPlan = {
323
330
  * Account/contact hygiene findings are NOT here; read `findings` for those. */
324
331
  pipelineFindings?: PipelineFinding[];
325
332
  evidence?: GtmEvidence[];
333
+ /** Assumptions used to turn audit observations into findings and operations. */
334
+ assumptions?: PatchPlanAssumption[];
335
+ /** Human decisions or unresolved questions that affect how the plan should be applied. */
336
+ openQuestions?: string[];
326
337
  operations: PatchOperation[];
327
338
  /**
328
339
  * The filter this plan's operations were selected by. Re-evaluated per
@@ -374,6 +385,8 @@ export type GtmRuleContext = {
374
385
  export type GtmRuleResult = {
375
386
  findings: AuditFinding[];
376
387
  operations: PatchOperation[];
388
+ /** Rule-level assumptions that explain the policy or heuristic behind returned findings. */
389
+ assumptions?: PatchPlanAssumption[];
377
390
  };
378
391
  /**
379
392
  * A deterministic audit rule. Rules read the snapshot through the context and
@@ -385,6 +398,8 @@ export type GtmAuditRule = {
385
398
  description: string;
386
399
  /** Grouping for docs and discovery, e.g. "hygiene", "forecast", "coverage". */
387
400
  category?: string;
401
+ /** False for findings-only rules whose evaluator intentionally emits no patch operations. */
402
+ emitsOperations?: boolean;
388
403
  evaluate: (context: GtmRuleContext) => GtmRuleResult;
389
404
  };
390
405
  export type PatchOperationResult = {
package/docs/api.md CHANGED
@@ -69,7 +69,8 @@ release.
69
69
  Commands: `init`, `login` / `logout`, `snapshot`, `audit`, `report`, `diff`, `merge`, `plans`,
70
70
  `apply`, `suggest`, `audit-log` (`export` / `verify`),
71
71
  `call` (`parse` / `classify` / `score` / `link` / `plan`), `resolve`,
72
- `bulk-update`, `dedupe`, `reassign`, `fix`, `health`,
72
+ `hierarchy` (`report`), `relationships` (`account`),
73
+ `route` (`leads`), `bulk-update`, `dedupe`, `reassign`, `fix`, `health`,
73
74
  `market` (`init` / `capture` / `classify` / `worksheet` / `observe` / `fronts` /
74
75
  `axes` / `overlay` / `scale` / `report` / `refresh`),
75
76
  `tam` (`estimate` / `accounts` / `status` / `report` / `populate`),
@@ -160,8 +161,8 @@ per-rule detail with capped examples, and next steps. `auditReportToMarkdown` /
160
161
 
161
162
  ## Governed write verbs
162
163
 
163
- Plan builders behind `bulk-update`, `dedupe`, and `reassign` — every one
164
- emits a standard dry-run `PatchPlan` for the normal approve → apply chain:
164
+ Plan builders behind `route`, `bulk-update`, `dedupe`, and `reassign` — every
165
+ one emits a standard dry-run `PatchPlan` for the normal approve → apply chain:
165
166
 
166
167
  - `buildBulkUpdatePlan(snapshot, options: BulkUpdateOptions)` with
167
168
  `parseWhere` (filter expressions: `=`, `!=`, `~`, `!~`, `:empty`,
@@ -179,6 +180,22 @@ emits a standard dry-run `PatchPlan` for the normal approve → apply chain:
179
180
  `assignUnowned` (CLI `--assign-unowned`) it targets ownerless records
180
181
  (`ownerId:empty`) and claims them for `--to` — the backfill twin of
181
182
  `enrich acquire`'s create-time assignment, for clearing existing ownerless debt.
183
+ - `buildLeadRoutePlan(snapshot, options: LeadRouteOptions)` — matches contacts
184
+ to accounts by email domain and optional company-name fallback, skips free
185
+ email domains and ambiguous matches, then emits approval-gated account-link
186
+ and owner-assignment operations. Owner routing can inherit active account
187
+ owners for ownerless contacts or use the shared deterministic
188
+ `AssignmentPolicy`; existing owners are preserved unless
189
+ `reassignExistingOwner` (CLI `--reassign-owned`) is set.
190
+
191
+ Read-only prevention reports:
192
+
193
+ - `buildAccountHierarchy(snapshot)` / `accountHierarchyToMarkdown(report)` —
194
+ provider parent hints plus subdomain inference, with duplicate-domain,
195
+ ambiguous-parent, and parent-cycle conflicts surfaced rather than written.
196
+ - `buildRelationshipMap(snapshot, { accountId | domain })` /
197
+ `relationshipMapToMarkdown(map)` — account stakeholders, inferred roles,
198
+ activity sentiment evidence, open deals, and missing-role gaps.
182
199
 
183
200
  `fix` is CLI-only composition of existing surfaces (audit → suggest →
184
201
  approve → apply for one rule).
@@ -57,9 +57,12 @@ provider ──fetchSnapshot()──► CanonicalGtmSnapshot
57
57
  - `auditLog.ts` — hash-chained, signed export of every apply run.
58
58
 
59
59
  **Governed write verbs (each builds a plan; never writes directly)**
60
- - `bulkUpdate.ts`, `dedupe.ts`, `reassign.ts` — filtered/duplicate/ownership
61
- plan builders. `merge.ts` snapshot diff/merge + entity resolution.
60
+ - `bulkUpdate.ts`, `dedupe.ts`, `reassign.ts`, `route.ts` — filtered,
61
+ duplicate, ownership handoff, and lead-to-account/owner routing plan builders.
62
+ `merge.ts` — snapshot diff/merge + entity resolution.
62
63
  - `resolve.ts` — the create-gate (exists / ambiguous / safe_to_create).
64
+ - `hierarchy.ts`, `relationships.ts` — read-only account hierarchy and
65
+ stakeholder relationship-map reports for planning/prevention workflows.
63
66
 
64
67
  **Connectors** (`connectors/`)
65
68
  - `hubspot.ts`, `salesforce.ts`, `stripe.ts` + their `*Auth.ts` OAuth/device
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
@@ -94,7 +94,7 @@ Tools over stdio: `fullstackgtm_audit` (read-only), `fullstackgtm_rules`,
94
94
  This CLI ships governed **primitives** — there is no `outbound` mega-command.
95
95
  **You (the agent) are the orchestrator:** chain these verbs into the play the
96
96
  operator wants, surface the one approve gate, and bridge the last mile to the
97
- sender (**the package never sends**). [docs/recipes.md](https://github.com/fullstackgtm/core/blob/main/packages/fullstackgtm/docs/recipes.md)
97
+ sender (**the package never sends**). [docs/recipes.md](https://github.com/fullstackgtm/core/blob/main/docs/recipes.md)
98
98
  has five worked plays — cold-start lead-fill, the trigger→judge→draft outbound
99
99
  loop, scheduled-continuous, ABM-from-companies, and hygiene-gated outbound.
100
100
  `fullstackgtm init` scaffolds a workspace (icp.json + enrich config + a PLAYBOOK
@@ -102,7 +102,7 @@ pointing at those recipes) to start from.
102
102
 
103
103
  ## Going deeper
104
104
 
105
- - [docs/recipes.md](https://github.com/fullstackgtm/core/blob/main/packages/fullstackgtm/docs/recipes.md) — five composable GTM plays over the primitives (cold-start, outbound loop, scheduled, ABM, hygiene-gated)
105
+ - [docs/recipes.md](https://github.com/fullstackgtm/core/blob/main/docs/recipes.md) — five composable GTM plays over the primitives (cold-start, outbound loop, scheduled, ABM, hygiene-gated)
106
106
  - [llms.txt](https://github.com/fullstackgtm/core/blob/main/llms.txt) — the full invariant map per layer (calls, market, write verbs, enrich, schedule, engagement/health)
107
107
  - [INSTALL_FOR_AGENTS.md](https://github.com/fullstackgtm/core/blob/main/INSTALL_FOR_AGENTS.md) — deterministic install-and-verify with expected outputs
108
108
  - [docs/api.md](https://github.com/fullstackgtm/core/blob/main/docs/api.md) — semver-covered surfaces: canonical model, rule interface, plan/apply contract, connectors, config, CLI, MCP
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 && !FREE_MAIL.has(domain) ? domain : undefined;
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
+ }