fullstackgtm 0.37.0 → 0.38.1

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 CHANGED
@@ -5,6 +5,68 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
  The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.38.1] — 2026-06-23
11
+
12
+ ### Fixed
13
+
14
+ - **`enrich acquire` LinkedIn creates failed with HubSpot 400.** The resolve-first
15
+ search before a `create_record` filtered on the *canonical* match key
16
+ (`linkedin`) instead of the HubSpot property name (`hs_linkedin_url`); HubSpot
17
+ 400s on a filter against a property it doesn't have, so every LinkedIn-sourced
18
+ lead failed at apply. The connector now translates the match key through
19
+ `HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts` before searching (email is unaffected
20
+ — its canonical name already equals the property name). Regression test added.
21
+
22
+ ## [0.38.0] — 2026-06-23
23
+
24
+ ### Added
25
+
26
+ - **CLI run observability — paired CLIs report each run to the hosted dashboard.**
27
+ When the CLI is paired (`login --via <hosted url>`), a best-effort run record —
28
+ command, status (`success`/`partial`/`error`), duration, headline counts, and
29
+ structured events (plan saved, meter charged) — is POSTed to the deployment's
30
+ run timeline after each command (`runReport.ts`: `reportCounts` / `reportEvent`
31
+ / `flushRunReport`). Opt-in by pairing (an unpaired CLI sends nothing), 4s-capped,
32
+ swallows every error, and never affects the command's exit code; setup/inspection
33
+ verbs (`login`, `doctor`, `help`, `--version`) are skipped. The hosted dashboard
34
+ re-maps onto these runs (`/dashboard/runs`).
35
+
36
+ - **Lead assignment — acquired leads are never born ownerless.** A shared
37
+ `AssignmentPolicy` (`assign.ts`: `fixed` / `round-robin` / `territory` /
38
+ `account-owner`) routes an owner onto every record, consumed in two places:
39
+ - **`enrich acquire`** stamps the resolved owner into each `create_record`
40
+ payload (`EnrichConfig.acquire.assign`); the HubSpot connector maps it to
41
+ `hubspot_owner_id` at create time. Round-robin distributes by the within-run
42
+ index (deterministic, no persisted cursor); every result is gated by the
43
+ snapshot's active owners, so an unknown/inactive owner collapses to
44
+ unassigned rather than writing a bad owner. The CLI defaults to the portal's
45
+ sole active owner when no policy is set (or `--assign-owner <id>`), and warns
46
+ + leaves leads unassigned when several owners exist and no policy says how to
47
+ route. `AcquireCounts` now reports `assigned` / `unassigned`.
48
+ - **`reassign --assign-unowned --to <ownerId>`** claims every existing
49
+ ownerless record (`ownerId:empty`) for an owner — the backfill twin of
50
+ acquire's create-time assignment.
51
+
52
+ - **`enrich acquire --source linkedin` — governed LinkedIn lead acquisition
53
+ (Phase 1, discovery + dry-run).** LinkedIn is a new discovery source on the
54
+ existing acquire spine: it reads a pre-built **HeyReach** lead list
55
+ (`--list <id>` or `acquire.discovery.linkedin.listId`), normalizes leads to
56
+ the canonical `Prospect` type, then reuses ICP scoring, pre-email dedup, the
57
+ acquire meter, and the dry-run → approve → apply gate unchanged. The LinkedIn
58
+ profile URL is the match key; cost is `$0`/record (the list is already built).
59
+ - **`LinkedInProvider`** interface (`connectors/linkedin.ts`) with a default
60
+ **HeyReach** adapter (`HEYREACH_BASE`, `X-API-KEY`) and a
61
+ `FakeLinkedInProvider` so the whole pipeline runs with no key and no network.
62
+ `createLinkedInProvider` rejects unknown providers (`heyreach` only for now;
63
+ `unipile` is the planned alternative behind the same interface).
64
+ - Auth: `login heyreach` or `HEYREACH_API_KEY`.
65
+ - **Read-only / no sending.** Phase 1 is discovery only — no
66
+ `linkedin_connect` / `linkedin_message` operations, no webhook ingestion
67
+ (Phase 2). Without `--save` nothing is written and there is zero send/ToS
68
+ exposure.
69
+
8
70
  ## [0.37.0] — 2026-06-19
9
71
 
10
72
  ### Added
@@ -69,6 +69,14 @@ the environment, or have the human run `echo "$KEY" | fullstackgtm login apollo`
69
69
  once. Without it, `enrich ingest <file> --source clay` still stages push-style
70
70
  data keyless.
71
71
 
72
+ `enrich acquire` differs from `append`/`refresh`: instead of filling blanks on
73
+ existing records it creates net-new, ICP-targeted leads via governed
74
+ `create_record` plans (resolve-first dedupe re-checks the key at apply, never
75
+ double-creates) and is capped by a per-profile windowed meter bounding record
76
+ count and provider spend per day and per month. It still flows through
77
+ dry-run → approve → apply and never auto-writes — expect it to refuse rather
78
+ than silently exceed the meter.
79
+
72
80
  Provider prerequisites (what the human must create, and which scopes) are in
73
81
  the README's **"Connect your CRM"** section: HubSpot needs a private app with
74
82
  four `crm.objects.*.read` scopes (plus write scopes only for `apply`);
package/README.md CHANGED
@@ -128,6 +128,7 @@ fullstackgtm bulk-update deal --where "stage=closedwon" --where "amount:empty" \
128
128
  --set amount=from:account.annualrevenue --save # per-record derived values; empty sources skipped, never guessed
129
129
  fullstackgtm dedupe account --key domain --keep richest --save # one merge_records op per duplicate group
130
130
  fullstackgtm reassign --from 411 --to 902 --except-deal-stage closing --save # ownership handoff playbook
131
+ fullstackgtm reassign --assign-unowned --to 902 --save # claim every ownerless record for an owner
131
132
  fullstackgtm fix --rule missing-deal-owner --provider hubspot --yes # audit one rule → suggest → approve → apply, one command
132
133
  ```
133
134
 
@@ -194,6 +195,17 @@ The **ICP** (`icp.json`) is the single targeting artifact: it generates each pro
194
195
 
195
196
  Every run is **metered**: a per-profile budget caps both record count and provider spend (per-day + per-month, whichever binds first), charged only for creates that actually land. `enrich acquire` runs with just an `icp.json` and a `login` — sensible defaults for budget, provider, and create-mapping ship in the box.
196
197
 
198
+ **Discovery sources** are zero-config presets behind `--source`: `explorium` and `pipe0` run ICP-driven people search, while `linkedin` (Phase 1 — discovery + dry-run) reads a pre-built **HeyReach** lead list:
199
+
200
+ ```bash
201
+ echo "$HEYREACH_API_KEY" | fullstackgtm login heyreach # or set HEYREACH_API_KEY
202
+ fullstackgtm enrich acquire --source linkedin --list <listId> --provider hubspot # score → dedup → dry-run diff
203
+ ```
204
+
205
+ LinkedIn is just another discovery source on the same scored → deduped → metered → dry-run→approve→apply spine; the LinkedIn profile URL is the match key and the list costs `$0`/record. **It never sends** — connect/message operations are out of scope for Phase 1, so without `--save` there is zero send/ToS exposure.
206
+
207
+ **Leads are never born ownerless.** An `acquire.assign` policy stamps an owner onto every created lead at create time (mapped to HubSpot `hubspot_owner_id`), routed by one of four strategies — `fixed`, `round-robin` (distributed deterministically, no rotation state to drift), `territory` (by geo / industry / size / department / title), or `account-owner` (inherit the matched company's owner). With no policy and a single-owner portal, acquire defaults every lead to that owner (or pass `--assign-owner <id>`); with multiple owners and no policy it warns and leaves them unassigned rather than guess. To clear *existing* ownerless records, `reassign --assign-unowned --to <ownerId>` applies the same intent as a backfill.
208
+
197
209
  ## Schedules: declare a cadence once, keep the governance contract under automation
198
210
 
199
211
  Everything the CLI produces is accurate the moment it runs and silently stale afterward. The **schedule layer** is the horizontal fix — any read/plan-side command on a cron cadence, one component, every verb (no feature owns its own cron logic):
@@ -271,6 +283,8 @@ fullstackgtm login --via https://gtm.yourco.com
271
283
 
272
284
  An admin connects HubSpot **once** in the hosted dashboard (the org's OAuth tokens live encrypted in the deployment). Pairing a CLI is a device-flow handshake: the CLI prints a code, an admin or manager approves it in the dashboard, and the CLI receives a long-lived broker token (stored hashed server-side, revocable per CLI). From then on, every provider command silently exchanges the broker token for a short-lived CRM access token minted from the org's stored sync credentials — and inherits the org's field mappings. No one pastes CRM tokens; revoking a laptop is one row.
273
285
 
286
+ **Run observability (paired CLIs).** Once paired, each command best-effort reports a run record — command, status (`success`/`partial`/`error`), duration, headline counts (e.g. ops emitted, leads created, est. cost), and structured events (plan saved, meter charged) — to the deployment's **run timeline** in the dashboard. It's opt-in by pairing (an unpaired CLI sends nothing), a 4-second-capped POST that swallows every error, and never changes the command's exit code. Setup/inspection verbs (`login`, `doctor`, `help`, `--version`) are skipped.
287
+
274
288
  ### Individuals: no deployment needed
275
289
 
276
290
  ```bash
@@ -0,0 +1,41 @@
1
+ /**
2
+ * LinkedIn → acquire bridge (Phase 1, step 2).
3
+ *
4
+ * Connects the provider-agnostic LinkedIn discovery layer (connectors/linkedin.ts)
5
+ * to the `enrich acquire` spine: pull prospects from a LinkedIn source, map them
6
+ * to the canonical `Prospect`, and hand them to the existing ICP-score → dedup →
7
+ * create_record → meter → dry-run/apply pipeline unchanged.
8
+ *
9
+ * Kept out of cli.ts on purpose: the only remaining wiring is a ~3-line
10
+ * discovery branch in `acquireFromApi` (call `createLinkedInProvider` +
11
+ * `discoverLinkedInProspects`), a `builtinAcquirePreset('linkedin')` entry, the
12
+ * source allowlist/help, and a `login heyreach` credential. That lands once
13
+ * cli.ts is clean of other in-flight work — see docs/linkedin-connector-spec.md.
14
+ *
15
+ * HeyReach reads a pre-populated lead LIST (not an ICP-driven search), and the
16
+ * natural identity key is the LinkedIn profile URL — so acquire's matchKey is
17
+ * `linkedin`, which means the spine skips pipe0 email resolution automatically.
18
+ */
19
+ import { type HeyReachProviderOptions, type LinkedInProspect, type LinkedInProvider } from "./connectors/linkedin.ts";
20
+ import type { Prospect } from "./connectors/prospectSources.ts";
21
+ /** Map a normalized LinkedIn prospect into the canonical acquire `Prospect`. */
22
+ export declare function linkedInProspectToProspect(lp: LinkedInProspect): Prospect;
23
+ export type DiscoverLinkedInOptions = {
24
+ /** Which source (HeyReach lead-list id) to read; provider may have a default. */
25
+ sourceId?: string;
26
+ /** Hard cap on prospects pulled. */
27
+ max?: number;
28
+ };
29
+ /**
30
+ * Discovery branch for `acquireFromApi`: read prospects from a LinkedIn source
31
+ * and return them as canonical `Prospect`s. Read-only — never sends. The
32
+ * provider is injected so this is fully testable against the fake (no key).
33
+ */
34
+ export declare function discoverLinkedInProspects(provider: LinkedInProvider, options?: DiscoverLinkedInOptions): Promise<Prospect[]>;
35
+ export type CreateLinkedInProviderOptions = Omit<HeyReachProviderOptions, "apiKey">;
36
+ /**
37
+ * Build the execution provider for a LinkedIn acquire source. HeyReach is the
38
+ * default; `unipile` is the planned alternative (same interface). Called by the
39
+ * cli acquire branch with the stored/env API key.
40
+ */
41
+ export declare function createLinkedInProvider(source: string, apiKey: string, options?: CreateLinkedInProviderOptions): LinkedInProvider;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * LinkedIn → acquire bridge (Phase 1, step 2).
3
+ *
4
+ * Connects the provider-agnostic LinkedIn discovery layer (connectors/linkedin.ts)
5
+ * to the `enrich acquire` spine: pull prospects from a LinkedIn source, map them
6
+ * to the canonical `Prospect`, and hand them to the existing ICP-score → dedup →
7
+ * create_record → meter → dry-run/apply pipeline unchanged.
8
+ *
9
+ * Kept out of cli.ts on purpose: the only remaining wiring is a ~3-line
10
+ * discovery branch in `acquireFromApi` (call `createLinkedInProvider` +
11
+ * `discoverLinkedInProspects`), a `builtinAcquirePreset('linkedin')` entry, the
12
+ * source allowlist/help, and a `login heyreach` credential. That lands once
13
+ * cli.ts is clean of other in-flight work — see docs/linkedin-connector-spec.md.
14
+ *
15
+ * HeyReach reads a pre-populated lead LIST (not an ICP-driven search), and the
16
+ * natural identity key is the LinkedIn profile URL — so acquire's matchKey is
17
+ * `linkedin`, which means the spine skips pipe0 email resolution automatically.
18
+ */
19
+ import { createHeyReachProvider, } from "./connectors/linkedin.js";
20
+ /** Map a normalized LinkedIn prospect into the canonical acquire `Prospect`. */
21
+ export function linkedInProspectToProspect(lp) {
22
+ return {
23
+ firstName: lp.firstName || undefined,
24
+ lastName: lp.lastName || undefined,
25
+ fullName: lp.fullName,
26
+ // Title drives ICP scoring; fall back to the headline when no title is set.
27
+ jobTitle: lp.jobTitle ?? lp.headline,
28
+ companyName: lp.company,
29
+ linkedin: lp.profileUrl || undefined,
30
+ email: lp.email,
31
+ // The profile URL is LinkedIn's stable native id — use it for traceability.
32
+ sourceId: lp.profileUrl || undefined,
33
+ };
34
+ }
35
+ /**
36
+ * Discovery branch for `acquireFromApi`: read prospects from a LinkedIn source
37
+ * and return them as canonical `Prospect`s. Read-only — never sends. The
38
+ * provider is injected so this is fully testable against the fake (no key).
39
+ */
40
+ export async function discoverLinkedInProspects(provider, options = {}) {
41
+ const raw = await provider.fetchProspects({ sourceId: options.sourceId, max: options.max });
42
+ return raw.map(linkedInProspectToProspect);
43
+ }
44
+ /**
45
+ * Build the execution provider for a LinkedIn acquire source. HeyReach is the
46
+ * default; `unipile` is the planned alternative (same interface). Called by the
47
+ * cli acquire branch with the stored/env API key.
48
+ */
49
+ export function createLinkedInProvider(source, apiKey, options = {}) {
50
+ switch (source) {
51
+ case "linkedin":
52
+ case "heyreach":
53
+ return createHeyReachProvider({ apiKey, ...options });
54
+ default:
55
+ throw new Error(`Unknown LinkedIn execution provider "${source}" (supported: heyreach).`);
56
+ }
57
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * AssignmentPolicy — the shared rule that decides which owner a record belongs
3
+ * to. One policy, two consumers:
4
+ * 1. `enrich acquire` stamps the resolved owner into each `create_record`
5
+ * payload, so a lead is never born ownerless, and
6
+ * 2. `reassign --assign-unowned` applies the SAME policy to backfill existing
7
+ * ownerless records.
8
+ *
9
+ * Pure + zero-dep: `resolveAssignment` takes plain data (the record's routing
10
+ * attributes + the candidate owners + the within-run index) and returns an
11
+ * ownerId or null. Round-robin distributes by the within-run index, so it needs
12
+ * no persisted rotation cursor — the same plan always resolves the same way
13
+ * (deterministic apply). Every result is gated by the set of KNOWN active
14
+ * owners: a policy that points at an unknown or inactive owner yields null, and
15
+ * the caller leaves that record unassigned + surfaces it — never a bad write.
16
+ */
17
+ export type AssignmentStrategy = "fixed" | "round-robin" | "territory" | "account-owner";
18
+ /** The attributes a territory rule can route on, lifted from a record/prospect. */
19
+ export type AssignmentContext = {
20
+ /** ISO country code, lowercased (e.g. "us"). */
21
+ geo?: string;
22
+ /** human industry label, lowercased (e.g. "software"). */
23
+ industry?: string;
24
+ /** provider-agnostic employee band (e.g. "11-50"). */
25
+ employeeBand?: string;
26
+ /** department, lowercased (e.g. "sales"). */
27
+ department?: string;
28
+ /** raw job title, lowercased — matched against rule titleKeywords as substrings. */
29
+ title?: string;
30
+ /** owner of the associated account, if any — enables "account-owner" inherit. */
31
+ accountOwnerId?: string;
32
+ };
33
+ /**
34
+ * One territory clause. Every present key must match (AND across keys); within a
35
+ * key the candidate value must be one of the listed values (OR). `titleKeywords`
36
+ * matches when the context title CONTAINS any keyword as a substring.
37
+ */
38
+ export type TerritoryRule = {
39
+ when: Partial<{
40
+ geo: string[];
41
+ industry: string[];
42
+ employeeBand: string[];
43
+ department: string[];
44
+ titleKeywords: string[];
45
+ }>;
46
+ ownerId: string;
47
+ };
48
+ export type AssignmentPolicy = {
49
+ strategy: "fixed";
50
+ ownerId: string;
51
+ } | {
52
+ strategy: "round-robin";
53
+ ownerIds: string[];
54
+ } | {
55
+ strategy: "territory";
56
+ rules: TerritoryRule[];
57
+ fallbackOwnerId?: string;
58
+ } | {
59
+ strategy: "account-owner";
60
+ fallbackOwnerId?: string;
61
+ };
62
+ export type AssignmentResult = {
63
+ /** the resolved owner, or null when the policy can't place this record. */
64
+ ownerId: string | null;
65
+ /** short human label for why (audit trail): "fixed", "round-robin", "territory:0", "account-owner", "fallback". */
66
+ rule?: string;
67
+ };
68
+ /**
69
+ * Validate + normalize a raw assignment policy (from enrich.config.json or a
70
+ * CLI flag). Throws on shape errors so a misconfigured policy fails loud at
71
+ * plan time, never silently mis-routes.
72
+ */
73
+ export declare function parseAssignmentPolicy(raw: unknown): AssignmentPolicy;
74
+ /** Does the context satisfy every present clause of a territory rule? */
75
+ export declare function matchesTerritory(rule: TerritoryRule, ctx: AssignmentContext): boolean;
76
+ /**
77
+ * Resolve the owner for one record. `index` is the record's 0-based position
78
+ * within the run (used by round-robin for deterministic, even within-run
79
+ * distribution without persisted state). `knownOwnerIds` is the set of active
80
+ * owners from the snapshot; any result outside it collapses to null so callers
81
+ * never write an owner the CRM won't accept.
82
+ */
83
+ export declare function resolveAssignment(policy: AssignmentPolicy, ctx: AssignmentContext, index: number, knownOwnerIds: ReadonlySet<string>): AssignmentResult;
package/dist/assign.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * AssignmentPolicy — the shared rule that decides which owner a record belongs
3
+ * to. One policy, two consumers:
4
+ * 1. `enrich acquire` stamps the resolved owner into each `create_record`
5
+ * payload, so a lead is never born ownerless, and
6
+ * 2. `reassign --assign-unowned` applies the SAME policy to backfill existing
7
+ * ownerless records.
8
+ *
9
+ * Pure + zero-dep: `resolveAssignment` takes plain data (the record's routing
10
+ * attributes + the candidate owners + the within-run index) and returns an
11
+ * ownerId or null. Round-robin distributes by the within-run index, so it needs
12
+ * no persisted rotation cursor — the same plan always resolves the same way
13
+ * (deterministic apply). Every result is gated by the set of KNOWN active
14
+ * owners: a policy that points at an unknown or inactive owner yields null, and
15
+ * the caller leaves that record unassigned + surfaces it — never a bad write.
16
+ */
17
+ const STRATEGIES = ["fixed", "round-robin", "territory", "account-owner"];
18
+ function asStringArray(value, label) {
19
+ if (!Array.isArray(value) || value.length === 0 || value.some((v) => typeof v !== "string" || !v.trim())) {
20
+ throw new Error(`assign: ${label} must be a non-empty array of strings`);
21
+ }
22
+ return value.map((v) => v.trim());
23
+ }
24
+ /**
25
+ * Validate + normalize a raw assignment policy (from enrich.config.json or a
26
+ * CLI flag). Throws on shape errors so a misconfigured policy fails loud at
27
+ * plan time, never silently mis-routes.
28
+ */
29
+ export function parseAssignmentPolicy(raw) {
30
+ if (!raw || typeof raw !== "object")
31
+ throw new Error("assign: expected a policy object");
32
+ const p = raw;
33
+ const strategy = p.strategy;
34
+ if (typeof strategy !== "string" || !STRATEGIES.includes(strategy)) {
35
+ throw new Error(`assign: "strategy" must be one of ${STRATEGIES.join(" | ")}`);
36
+ }
37
+ switch (strategy) {
38
+ case "fixed": {
39
+ if (typeof p.ownerId !== "string" || !p.ownerId.trim()) {
40
+ throw new Error('assign: fixed strategy needs a non-empty "ownerId"');
41
+ }
42
+ return { strategy, ownerId: p.ownerId.trim() };
43
+ }
44
+ case "round-robin": {
45
+ const ownerIds = asStringArray(p.ownerIds, "round-robin ownerIds");
46
+ return { strategy, ownerIds };
47
+ }
48
+ case "territory": {
49
+ if (!Array.isArray(p.rules) || p.rules.length === 0) {
50
+ throw new Error('assign: territory strategy needs a non-empty "rules" array');
51
+ }
52
+ const rules = p.rules.map((r, i) => {
53
+ if (!r || typeof r !== "object")
54
+ throw new Error(`assign: territory rule ${i} must be an object`);
55
+ const rule = r;
56
+ if (typeof rule.ownerId !== "string" || !rule.ownerId.trim()) {
57
+ throw new Error(`assign: territory rule ${i} needs a non-empty "ownerId"`);
58
+ }
59
+ const whenRaw = (rule.when ?? {});
60
+ const when = {};
61
+ for (const key of ["geo", "industry", "employeeBand", "department", "titleKeywords"]) {
62
+ if (whenRaw[key] !== undefined)
63
+ when[key] = asStringArray(whenRaw[key], `territory rule ${i} when.${key}`);
64
+ }
65
+ if (Object.keys(when).length === 0) {
66
+ throw new Error(`assign: territory rule ${i} "when" needs at least one clause (else it matches everything — use a fallbackOwnerId instead)`);
67
+ }
68
+ return { when, ownerId: rule.ownerId.trim() };
69
+ });
70
+ const fallbackOwnerId = typeof p.fallbackOwnerId === "string" && p.fallbackOwnerId.trim() ? p.fallbackOwnerId.trim() : undefined;
71
+ return { strategy, rules, ...(fallbackOwnerId ? { fallbackOwnerId } : {}) };
72
+ }
73
+ case "account-owner": {
74
+ const fallbackOwnerId = typeof p.fallbackOwnerId === "string" && p.fallbackOwnerId.trim() ? p.fallbackOwnerId.trim() : undefined;
75
+ return { strategy, ...(fallbackOwnerId ? { fallbackOwnerId } : {}) };
76
+ }
77
+ default:
78
+ throw new Error(`assign: unsupported strategy "${strategy}"`);
79
+ }
80
+ }
81
+ function clauseMatches(values, candidate) {
82
+ if (candidate === undefined)
83
+ return false;
84
+ return values.some((v) => v.toLowerCase() === candidate.toLowerCase());
85
+ }
86
+ function titleMatches(keywords, title) {
87
+ if (!title)
88
+ return false;
89
+ const lower = title.toLowerCase();
90
+ return keywords.some((k) => lower.includes(k.toLowerCase()));
91
+ }
92
+ /** Does the context satisfy every present clause of a territory rule? */
93
+ export function matchesTerritory(rule, ctx) {
94
+ const { when } = rule;
95
+ if (when.geo && !clauseMatches(when.geo, ctx.geo))
96
+ return false;
97
+ if (when.industry && !clauseMatches(when.industry, ctx.industry))
98
+ return false;
99
+ if (when.employeeBand && !clauseMatches(when.employeeBand, ctx.employeeBand))
100
+ return false;
101
+ if (when.department && !clauseMatches(when.department, ctx.department))
102
+ return false;
103
+ if (when.titleKeywords && !titleMatches(when.titleKeywords, ctx.title))
104
+ return false;
105
+ return true;
106
+ }
107
+ /** Gate a candidate ownerId by the known-active set: returns it, or null. */
108
+ function known(ownerId, knownOwnerIds) {
109
+ return ownerId && knownOwnerIds.has(ownerId) ? ownerId : null;
110
+ }
111
+ /**
112
+ * Resolve the owner for one record. `index` is the record's 0-based position
113
+ * within the run (used by round-robin for deterministic, even within-run
114
+ * distribution without persisted state). `knownOwnerIds` is the set of active
115
+ * owners from the snapshot; any result outside it collapses to null so callers
116
+ * never write an owner the CRM won't accept.
117
+ */
118
+ export function resolveAssignment(policy, ctx, index, knownOwnerIds) {
119
+ switch (policy.strategy) {
120
+ case "fixed":
121
+ return { ownerId: known(policy.ownerId, knownOwnerIds), rule: "fixed" };
122
+ case "round-robin": {
123
+ const pool = policy.ownerIds.filter((id) => knownOwnerIds.has(id));
124
+ if (pool.length === 0)
125
+ return { ownerId: null, rule: "round-robin" };
126
+ const safeIndex = ((index % pool.length) + pool.length) % pool.length;
127
+ return { ownerId: pool[safeIndex], rule: "round-robin" };
128
+ }
129
+ case "territory": {
130
+ for (let i = 0; i < policy.rules.length; i++) {
131
+ if (matchesTerritory(policy.rules[i], ctx)) {
132
+ const hit = known(policy.rules[i].ownerId, knownOwnerIds);
133
+ if (hit)
134
+ return { ownerId: hit, rule: `territory:${i}` };
135
+ }
136
+ }
137
+ return { ownerId: known(policy.fallbackOwnerId, knownOwnerIds), rule: "fallback" };
138
+ }
139
+ case "account-owner": {
140
+ const inherited = known(ctx.accountOwnerId, knownOwnerIds);
141
+ if (inherited)
142
+ return { ownerId: inherited, rule: "account-owner" };
143
+ return { ownerId: known(policy.fallbackOwnerId, knownOwnerIds), rule: "fallback" };
144
+ }
145
+ }
146
+ }
package/dist/bin.js CHANGED
@@ -1,6 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { runCli } from "./cli.js";
3
- runCli(process.argv.slice(2)).catch((error) => {
4
- console.error(error instanceof Error ? error.message : String(error));
3
+ import { flushRunReport } from "./runReport.js";
4
+ const args = process.argv.slice(2);
5
+ const startedAt = Date.now();
6
+ runCli(args)
7
+ .then(async () => {
8
+ // exitCode 2 (audit findings / create-gate) or 1 (no value) = "partial":
9
+ // the command ran but flagged something. No exitCode = clean success.
10
+ const status = process.exitCode && process.exitCode !== 0 ? "partial" : "success";
11
+ await flushRunReport(args, status, startedAt);
12
+ })
13
+ .catch(async (error) => {
14
+ const message = error instanceof Error ? error.message : String(error);
15
+ await flushRunReport(args, "error", startedAt, message);
16
+ console.error(message);
5
17
  process.exitCode = 1;
6
18
  });