fullstackgtm 0.37.0 → 0.38.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 +50 -0
- package/INSTALL_FOR_AGENTS.md +8 -0
- package/README.md +14 -0
- package/dist/acquireLinkedIn.d.ts +41 -0
- package/dist/acquireLinkedIn.js +57 -0
- package/dist/assign.d.ts +83 -0
- package/dist/assign.js +146 -0
- package/dist/bin.js +14 -2
- package/dist/cli.js +91 -11
- package/dist/connectors/hubspot.js +5 -0
- package/dist/connectors/linkedin.d.ts +78 -0
- package/dist/connectors/linkedin.js +199 -0
- package/dist/enrich.d.ts +21 -1
- package/dist/enrich.js +127 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/reassign.d.ts +11 -2
- package/dist/reassign.js +13 -6
- package/dist/runReport.d.ts +9 -0
- package/dist/runReport.js +66 -0
- package/dist/types.d.ts +8 -0
- package/docs/api.md +24 -1
- package/docs/architecture.md +3 -0
- package/docs/linkedin-connector-spec.md +87 -0
- package/llms.txt +22 -1
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +3 -2
- package/src/acquireLinkedIn.ts +83 -0
- package/src/assign.ts +193 -0
- package/src/bin.ts +17 -4
- package/src/cli.ts +102 -12
- package/src/connectors/hubspot.ts +5 -0
- package/src/connectors/linkedin.ts +272 -0
- package/src/enrich.ts +137 -3
- package/src/index.ts +10 -0
- package/src/reassign.ts +24 -8
- package/src/runReport.ts +76 -0
- package/src/types.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,56 @@ 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.0] — 2026-06-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **CLI run observability — paired CLIs report each run to the hosted dashboard.**
|
|
15
|
+
When the CLI is paired (`login --via <hosted url>`), a best-effort run record —
|
|
16
|
+
command, status (`success`/`partial`/`error`), duration, headline counts, and
|
|
17
|
+
structured events (plan saved, meter charged) — is POSTed to the deployment's
|
|
18
|
+
run timeline after each command (`runReport.ts`: `reportCounts` / `reportEvent`
|
|
19
|
+
/ `flushRunReport`). Opt-in by pairing (an unpaired CLI sends nothing), 4s-capped,
|
|
20
|
+
swallows every error, and never affects the command's exit code; setup/inspection
|
|
21
|
+
verbs (`login`, `doctor`, `help`, `--version`) are skipped. The hosted dashboard
|
|
22
|
+
re-maps onto these runs (`/dashboard/runs`).
|
|
23
|
+
|
|
24
|
+
- **Lead assignment — acquired leads are never born ownerless.** A shared
|
|
25
|
+
`AssignmentPolicy` (`assign.ts`: `fixed` / `round-robin` / `territory` /
|
|
26
|
+
`account-owner`) routes an owner onto every record, consumed in two places:
|
|
27
|
+
- **`enrich acquire`** stamps the resolved owner into each `create_record`
|
|
28
|
+
payload (`EnrichConfig.acquire.assign`); the HubSpot connector maps it to
|
|
29
|
+
`hubspot_owner_id` at create time. Round-robin distributes by the within-run
|
|
30
|
+
index (deterministic, no persisted cursor); every result is gated by the
|
|
31
|
+
snapshot's active owners, so an unknown/inactive owner collapses to
|
|
32
|
+
unassigned rather than writing a bad owner. The CLI defaults to the portal's
|
|
33
|
+
sole active owner when no policy is set (or `--assign-owner <id>`), and warns
|
|
34
|
+
+ leaves leads unassigned when several owners exist and no policy says how to
|
|
35
|
+
route. `AcquireCounts` now reports `assigned` / `unassigned`.
|
|
36
|
+
- **`reassign --assign-unowned --to <ownerId>`** claims every existing
|
|
37
|
+
ownerless record (`ownerId:empty`) for an owner — the backfill twin of
|
|
38
|
+
acquire's create-time assignment.
|
|
39
|
+
|
|
40
|
+
- **`enrich acquire --source linkedin` — governed LinkedIn lead acquisition
|
|
41
|
+
(Phase 1, discovery + dry-run).** LinkedIn is a new discovery source on the
|
|
42
|
+
existing acquire spine: it reads a pre-built **HeyReach** lead list
|
|
43
|
+
(`--list <id>` or `acquire.discovery.linkedin.listId`), normalizes leads to
|
|
44
|
+
the canonical `Prospect` type, then reuses ICP scoring, pre-email dedup, the
|
|
45
|
+
acquire meter, and the dry-run → approve → apply gate unchanged. The LinkedIn
|
|
46
|
+
profile URL is the match key; cost is `$0`/record (the list is already built).
|
|
47
|
+
- **`LinkedInProvider`** interface (`connectors/linkedin.ts`) with a default
|
|
48
|
+
**HeyReach** adapter (`HEYREACH_BASE`, `X-API-KEY`) and a
|
|
49
|
+
`FakeLinkedInProvider` so the whole pipeline runs with no key and no network.
|
|
50
|
+
`createLinkedInProvider` rejects unknown providers (`heyreach` only for now;
|
|
51
|
+
`unipile` is the planned alternative behind the same interface).
|
|
52
|
+
- Auth: `login heyreach` or `HEYREACH_API_KEY`.
|
|
53
|
+
- **Read-only / no sending.** Phase 1 is discovery only — no
|
|
54
|
+
`linkedin_connect` / `linkedin_message` operations, no webhook ingestion
|
|
55
|
+
(Phase 2). Without `--save` nothing is written and there is zero send/ToS
|
|
56
|
+
exposure.
|
|
57
|
+
|
|
8
58
|
## [0.37.0] — 2026-06-19
|
|
9
59
|
|
|
10
60
|
### Added
|
package/INSTALL_FOR_AGENTS.md
CHANGED
|
@@ -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
|
+
}
|
package/dist/assign.d.ts
ADDED
|
@@ -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
|
-
|
|
4
|
-
|
|
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
|
});
|