fullstackgtm 0.17.0 → 0.19.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 +72 -0
- package/INSTALL_FOR_AGENTS.md +10 -5
- package/README.md +17 -0
- package/dist/bulkUpdate.d.ts +37 -0
- package/dist/bulkUpdate.js +315 -0
- package/dist/cli.js +93 -2
- package/dist/connector.d.ts +6 -0
- package/dist/connector.js +158 -17
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/market.d.ts +16 -0
- package/dist/market.js +27 -0
- package/dist/marketAxes.d.ts +77 -0
- package/dist/marketAxes.js +199 -0
- package/dist/marketReport.js +114 -1
- package/dist/mcp.js +13 -2
- package/dist/types.d.ts +44 -0
- package/docs/api.md +29 -2
- package/llms.txt +16 -0
- package/package.json +1 -1
- package/src/bulkUpdate.ts +375 -0
- package/src/cli.ts +97 -2
- package/src/connector.ts +169 -23
- package/src/index.ts +15 -0
- package/src/market.ts +41 -0
- package/src/marketAxes.ts +268 -0
- package/src/marketReport.ts +134 -1
- package/src/mcp.ts +15 -2
- package/src/types.ts +39 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,78 @@ 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
|
+
## [0.19.0] — 2026-06-11
|
|
9
|
+
|
|
10
|
+
Governed bulk writes, plus fixes from the 0.18 published-artifact verification.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`fullstackgtm bulk-update <account|contact|deal>`** — generic writes
|
|
15
|
+
through the same plan gate as everything else: `--where` filters (`=`,
|
|
16
|
+
`!=`, `~` substring, `:empty`/`:notempty`, `|` alternation) select records,
|
|
17
|
+
`--set`/`--archive`/`--create-task` define the change, and the result is a
|
|
18
|
+
dry-run patch plan needing explicit approval before `apply` — never a
|
|
19
|
+
direct write. `--require <field>=<value>` preconditions and
|
|
20
|
+
`--guard <object>:<where>:<none|some>` cross-record eligibility checks are
|
|
21
|
+
re-verified at apply time against the live CRM (mid-apply rechecks shrink
|
|
22
|
+
the audit→apply TOCTOU window); `--max-operations` caps blast radius.
|
|
23
|
+
- Eval tool card teaches agents to encode eligibility conditions in filters
|
|
24
|
+
rather than hand-selecting record ids.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- `--help` no longer executes: every `market` subcommand now short-circuits
|
|
29
|
+
to usage before config loads, credential checks, or side effects
|
|
30
|
+
(`market capture --help` used to *run the capture* — live fetches and
|
|
31
|
+
manifest writes; `market axes --help` ran the analysis). Top-level
|
|
32
|
+
commands without bespoke help (`audit`, `snapshot`, `suggest`, …) print
|
|
33
|
+
usage on `--help` instead of executing (`audit --help` used to silently
|
|
34
|
+
run the sample audit).
|
|
35
|
+
- `market report --format html` no longer crashes with a bare TypeError on
|
|
36
|
+
axes missing pole labels: `parseMarketConfig` now requires
|
|
37
|
+
`negativePole`/`positivePole` on every axis and says so.
|
|
38
|
+
- The 0.18.0 entry below documented the axis shape with a `poles` field that
|
|
39
|
+
never existed; the real fields are `negativePole`/`positivePole` (entry
|
|
40
|
+
corrected in place).
|
|
41
|
+
- `forcedToolCall` is now actually exported from the package root, as the
|
|
42
|
+
0.17.0 entry claimed.
|
|
43
|
+
- MCP `fullstackgtm_market_worksheet`/`_observe` with no
|
|
44
|
+
`market.config.json` in the server cwd return a "run `fullstackgtm market
|
|
45
|
+
init`" hint instead of a raw ENOENT.
|
|
46
|
+
|
|
47
|
+
## [0.18.0] — 2026-06-11
|
|
48
|
+
|
|
49
|
+
Axis discovery: earn a strategic 2×2 from the observations instead of
|
|
50
|
+
asserting one.
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- **Axes as config** — `axes` in `market.config.json`: each axis is a
|
|
55
|
+
claim-scoring rubric (`{ id, label, negativePole, positivePole, rubric, status, claimScores }`,
|
|
56
|
+
null = axis doesn't apply to that claim); a vendor's position is the
|
|
57
|
+
intensity-weighted mean (loud=1, quiet=½) of the claims it voices.
|
|
58
|
+
`primaryAxes: [x, y]` picks the report's strategic map. Config validation
|
|
59
|
+
rejects axes scoring unknown claims.
|
|
60
|
+
- **`fullstackgtm market axes`** — the discovery math, pure and
|
|
61
|
+
dependency-free: PCA (power iteration) over the vendor × claim intensity
|
|
62
|
+
matrix — PC1 is the category's own primary axis, PC2 the
|
|
63
|
+
maximum-differentiation direction orthogonal to it; triangulation of every
|
|
64
|
+
configured axis against the PCs (a real axis is *derivable* from the data,
|
|
65
|
+
not just felt); and an orthogonality screen (|r| ≥ 0.75 = one axis twice —
|
|
66
|
+
sometimes the finding: the category couples the ideas and the empty
|
|
67
|
+
quadrant is the white space). Fully-unobservable vendors are excluded,
|
|
68
|
+
never zeroed.
|
|
69
|
+
- **Report: strategic map** — section 03 renders the primary 2×2 (positions
|
|
70
|
+
computed, not asserted; dot size = LOUD count; axis status in the caption)
|
|
71
|
+
when axes are configured; the evidence appendix renumbers accordingly. The
|
|
72
|
+
report deliberately carries only the one earned 2×2 — best foot forward
|
|
73
|
+
for the client; axis exploration (every pairing, r, verdicts) is `market
|
|
74
|
+
axes` territory for the analyst or agent doing the iterating.
|
|
75
|
+
- **Golden regression**: the 280-cell creative-intelligence validation
|
|
76
|
+
dataset ships as a test fixture — PCA must recover the buyer axis as PC1
|
|
77
|
+
(|r| ≥ 0.9) and value-mode as PC2 (|r| ≥ 0.85), and flag the documented
|
|
78
|
+
buyer × operating-model redundancy.
|
|
79
|
+
|
|
8
80
|
## [0.17.0] — 2026-06-11
|
|
9
81
|
|
|
10
82
|
Market map classification: intensity readings become a one-command step, and
|
package/INSTALL_FOR_AGENTS.md
CHANGED
|
@@ -49,11 +49,16 @@ In an agent sandbox, prefer rung 1 or 2. Never echo tokens into argv —
|
|
|
49
49
|
environments — login flows then print verification URLs instead of opening
|
|
50
50
|
the OS browser.
|
|
51
51
|
|
|
52
|
-
LLM calls (`call parse`, `call score`): set
|
|
53
|
-
`OPENAI_API_KEY` in the environment, or have the human
|
|
54
|
-
`echo "$KEY" | fullstackgtm login anthropic` once. Without a key, use
|
|
55
|
-
`call parse --deterministic` (free keyword baseline, no prompt)
|
|
56
|
-
|
|
52
|
+
LLM calls (`call parse`, `call score`, `market classify`): set
|
|
53
|
+
`ANTHROPIC_API_KEY` or `OPENAI_API_KEY` in the environment, or have the human
|
|
54
|
+
run `echo "$KEY" | fullstackgtm login anthropic` once. Without a key, use
|
|
55
|
+
`call parse --deterministic` (free keyword baseline, no prompt) — or, for the
|
|
56
|
+
market map, classify it yourself: `fullstackgtm market worksheet --vendor <id>`
|
|
57
|
+
returns the claims, judging rules, and captured page texts; submit your
|
|
58
|
+
readings via `market observe --from <file>`. Quote evidence VERBATIM from the
|
|
59
|
+
page texts — every span is checked character-for-character against the stored
|
|
60
|
+
capture, and paraphrased quotes are rejected. In non-interactive contexts the
|
|
61
|
+
CLI never prompts — it fails with this guidance.
|
|
57
62
|
|
|
58
63
|
Provider prerequisites (what the human must create, and which scopes) are in
|
|
59
64
|
the README's **"Connect your CRM"** section: HubSpot needs a private app with
|
package/README.md
CHANGED
|
@@ -109,6 +109,23 @@ npx fullstackgtm report --provider hubspot --client "Acme" --out acme-health.htm
|
|
|
109
109
|
|
|
110
110
|
`report` renders the same audit as a deliverable — severity counts up front, a prose summary, per-rule detail with example records, and next steps — as markdown or self-contained HTML (printable, emailable, no external assets).
|
|
111
111
|
|
|
112
|
+
## The market map: the category, observed
|
|
113
|
+
|
|
114
|
+
Your CRM records what happened in your own deals; nothing records the shape of the category you sell into. The **market map** does: vendors and a claim taxonomy live in a reviewable `market.config.json`, vendor pages are captured into a content-addressed cache, every vendor × claim cell gets a messaging-intensity reading (LOUD / QUIET / ABSENT — with UNOBSERVABLE for failed captures, never a fake absence), and deterministic front states fall out per claim: open, contested, owned, saturated.
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
fullstackgtm market init --category creative-intelligence # seed vendors + claims, edit by hand
|
|
118
|
+
fullstackgtm market capture # fetch pages → content-addressed captures
|
|
119
|
+
fullstackgtm market classify # LLM readings (BYO key), every quote verified
|
|
120
|
+
fullstackgtm market fronts --diff run-1 # what changed since last run
|
|
121
|
+
fullstackgtm market report --format html --out map.html # the client-ready field report
|
|
122
|
+
fullstackgtm market refresh # all of the above, weekly, one command
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The discipline matches the rest of the tool. Intensity readings are *proposals* — from the LLM (`classify`, same bring-your-own-key seam as `call parse`, provenance-marked) or from any agent/human (`market worksheet` → `market observe`) — and **every quoted evidence span is verified character-for-character against the stored capture it cites** before an observation is accepted. Quotes that aren't on the page bounce. Everything downstream of the store is deterministic: same observations, same map.
|
|
126
|
+
|
|
127
|
+
`market axes` is for earning a strategic 2×2 instead of asserting one: PCA over the intensity matrix (PC1 = the category's own primary axis, PC2 = the most differentiating direction orthogonal to it), triangulation of your configured axes against the data, and an orthogonality screen that flags two axes that are secretly one. Axes are claim-scoring rubrics in the config; the report renders the primary pair as the strategic map. Captures and observations are profile-scoped (`~/.fullstackgtm/market/<category>`), so one client's category intel never bleeds into another's.
|
|
128
|
+
|
|
112
129
|
### Working across organizations
|
|
113
130
|
|
|
114
131
|
Consultants and fractional operators hold credentials for several CRMs at once. A profile scopes stored logins *and* stored plans to one organization:
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CanonicalGtmSnapshot, PatchPlan, PlanGuard } from "./types.ts";
|
|
2
|
+
export type BulkUpdateOptions = {
|
|
3
|
+
objectType: "account" | "contact" | "deal";
|
|
4
|
+
/** raw --where expressions, AND-ed together; at least one is required */
|
|
5
|
+
where: string[];
|
|
6
|
+
/** canonical field → new value; one action only */
|
|
7
|
+
set?: Record<string, string>;
|
|
8
|
+
/** propose archive_record instead of field writes */
|
|
9
|
+
archive?: boolean;
|
|
10
|
+
/** propose create_task on each matched record with this subject/body text */
|
|
11
|
+
createTask?: string;
|
|
12
|
+
/** explicit preconditions (field=value), re-verified at apply time */
|
|
13
|
+
require?: string[];
|
|
14
|
+
/**
|
|
15
|
+
* plan-level guards, raw form "<objectType>:<where>[;<where>…]:<none|some>",
|
|
16
|
+
* re-evaluated against a fresh snapshot at apply time; failure aborts the
|
|
17
|
+
* entire plan
|
|
18
|
+
*/
|
|
19
|
+
guard?: string[];
|
|
20
|
+
reason?: string;
|
|
21
|
+
/** refuse to build plans larger than this (default 500 operations) */
|
|
22
|
+
maxOperations?: number;
|
|
23
|
+
};
|
|
24
|
+
type WhereClause = {
|
|
25
|
+
field: string;
|
|
26
|
+
op: "eq" | "neq" | "contains" | "notcontains" | "empty" | "notempty";
|
|
27
|
+
value?: string;
|
|
28
|
+
raw: string;
|
|
29
|
+
};
|
|
30
|
+
export declare function parseWhere(expr: string): WhereClause;
|
|
31
|
+
export declare function parseGuard(raw: string): PlanGuard;
|
|
32
|
+
/** Ids of records matching a filter — used for apply-time filter re-verification. */
|
|
33
|
+
export declare function eligibleIds(snapshot: CanonicalGtmSnapshot, objectType: BulkUpdateOptions["objectType"], where: string[]): Set<string>;
|
|
34
|
+
/** Evaluate a plan guard against a snapshot. Returns null when satisfied, else a failure detail. */
|
|
35
|
+
export declare function evaluateGuard(snapshot: CanonicalGtmSnapshot, guard: PlanGuard): string | null;
|
|
36
|
+
export declare function buildBulkUpdatePlan(snapshot: CanonicalGtmSnapshot, options: BulkUpdateOptions): PatchPlan;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Governed generic writes: `bulk-update` builds a dry-run patch plan from a
|
|
3
|
+
* filter over the canonical snapshot plus an action (field assignments, an
|
|
4
|
+
* archive directive, or a task to create). It NEVER writes — the plan flows
|
|
5
|
+
* through the same plans-approve → apply gate as audit plans.
|
|
6
|
+
*
|
|
7
|
+
* Safety model, in layers:
|
|
8
|
+
* - every set_field captures the record's live value as `beforeValue`
|
|
9
|
+
* (apply refuses the write if the written field drifted);
|
|
10
|
+
* - every equality filter on a real record field becomes an automatic
|
|
11
|
+
* PRECONDITION, re-verified at apply time (the plan's premise must still
|
|
12
|
+
* hold — guards against drift on fields other than the one written);
|
|
13
|
+
* - `--require field=value` adds explicit preconditions on top;
|
|
14
|
+
* - all operations for one record share a groupId, so multi-field updates
|
|
15
|
+
* are all-or-nothing per record.
|
|
16
|
+
*
|
|
17
|
+
* Filter grammar (each --where is AND-ed):
|
|
18
|
+
* field=value case-insensitive equality
|
|
19
|
+
* field!=value case-insensitive inequality
|
|
20
|
+
* field~value case-insensitive substring
|
|
21
|
+
* field:empty unset or empty string
|
|
22
|
+
* field:notempty set and non-empty
|
|
23
|
+
*
|
|
24
|
+
* Fields are canonical (ownerId, stage, closeDate, amount, domain, name,
|
|
25
|
+
* email, isClosed, accountId, …). Relational pseudo-fields are available in
|
|
26
|
+
* filters: deals and contacts get `account.name`, `account.domain`,
|
|
27
|
+
* `account.ownerId`, `account.contactCount`; accounts get `contactCount`
|
|
28
|
+
* and `openDealCount`.
|
|
29
|
+
*/
|
|
30
|
+
import { stableHash } from "./rules.js";
|
|
31
|
+
const FIELD_PATTERN = "[a-zA-Z][a-zA-Z0-9_]*(?:\\.[a-zA-Z][a-zA-Z0-9_]*)?";
|
|
32
|
+
export function parseWhere(expr) {
|
|
33
|
+
const empty = expr.match(new RegExp(`^(${FIELD_PATTERN}):(empty|notempty)$`));
|
|
34
|
+
if (empty)
|
|
35
|
+
return { field: empty[1], op: empty[2], raw: expr };
|
|
36
|
+
const neq = expr.match(new RegExp(`^(${FIELD_PATTERN})!=(.*)$`));
|
|
37
|
+
if (neq)
|
|
38
|
+
return { field: neq[1], op: "neq", value: neq[2], raw: expr };
|
|
39
|
+
const notContains = expr.match(new RegExp(`^(${FIELD_PATTERN})!~(.*)$`));
|
|
40
|
+
if (notContains)
|
|
41
|
+
return { field: notContains[1], op: "notcontains", value: notContains[2], raw: expr };
|
|
42
|
+
const contains = expr.match(new RegExp(`^(${FIELD_PATTERN})~(.*)$`));
|
|
43
|
+
if (contains)
|
|
44
|
+
return { field: contains[1], op: "contains", value: contains[2], raw: expr };
|
|
45
|
+
const eq = expr.match(new RegExp(`^(${FIELD_PATTERN})=(.*)$`));
|
|
46
|
+
if (eq)
|
|
47
|
+
return { field: eq[1], op: "eq", value: eq[2], raw: expr };
|
|
48
|
+
throw new Error(`Cannot parse --where "${expr}". Use field=value, field!=value, field~substring, field!~substring, field:empty, or field:notempty.`);
|
|
49
|
+
}
|
|
50
|
+
function fieldValue(view, field) {
|
|
51
|
+
const value = view[field];
|
|
52
|
+
if (value === undefined || value === null)
|
|
53
|
+
return "";
|
|
54
|
+
return String(value);
|
|
55
|
+
}
|
|
56
|
+
function matches(view, clause) {
|
|
57
|
+
const actual = fieldValue(view, clause.field).toLowerCase();
|
|
58
|
+
// `|` alternation: eq/contains match ANY alternative; neq/notcontains
|
|
59
|
+
// must hold against ALL alternatives.
|
|
60
|
+
const alternatives = (clause.value ?? "").toLowerCase().split("|");
|
|
61
|
+
switch (clause.op) {
|
|
62
|
+
case "eq":
|
|
63
|
+
return alternatives.some((a) => actual === a);
|
|
64
|
+
case "neq":
|
|
65
|
+
return alternatives.every((a) => actual !== a);
|
|
66
|
+
case "contains":
|
|
67
|
+
return alternatives.some((a) => actual.includes(a));
|
|
68
|
+
case "notcontains":
|
|
69
|
+
return alternatives.every((a) => !actual.includes(a));
|
|
70
|
+
case "empty":
|
|
71
|
+
return actual === "";
|
|
72
|
+
case "notempty":
|
|
73
|
+
return actual !== "";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const COLLECTIONS = {
|
|
77
|
+
account: "accounts",
|
|
78
|
+
contact: "contacts",
|
|
79
|
+
deal: "deals",
|
|
80
|
+
};
|
|
81
|
+
const RELATIONAL_FIELDS = ["account.name", "account.domain", "account.ownerId", "account.contactCount", "account.openDealStages"];
|
|
82
|
+
/**
|
|
83
|
+
* Filterable fields per object type. Filters/requires/guards referencing any
|
|
84
|
+
* other field are rejected at plan time: a typo'd field silently evaluating
|
|
85
|
+
* to empty would make a ":none" guard pass vacuously — a safety assertion
|
|
86
|
+
* that never fires. Strictness turns typos into immediate, correctable
|
|
87
|
+
* errors.
|
|
88
|
+
*/
|
|
89
|
+
const VALID_FIELDS = {
|
|
90
|
+
account: new Set(["id", "crmId", "name", "domain", "industry", "ownerId", "employeeCount", "annualRevenue", "lastActivityAt", "lastSyncAt", "contactCount", "openDealCount", "openDealStages"]),
|
|
91
|
+
contact: new Set(["id", "crmId", "accountId", "firstName", "lastName", "email", "phone", "title", "ownerId", "lastSyncAt", ...RELATIONAL_FIELDS]),
|
|
92
|
+
deal: new Set(["id", "crmId", "accountId", "ownerId", "name", "amount", "currency", "stage", "closeDate", "dealType", "forecastCategory", "nextStep", "probability", "isClosed", "isWon", "lastActivityAt", "lastSyncAt", ...RELATIONAL_FIELDS]),
|
|
93
|
+
};
|
|
94
|
+
function assertValidFields(objectType, clauses, context) {
|
|
95
|
+
for (const clause of clauses) {
|
|
96
|
+
if (!VALID_FIELDS[objectType].has(clause.field)) {
|
|
97
|
+
throw new Error(`Unknown field "${clause.field}" in ${context} "${clause.raw}" for ${objectType}s. Valid fields: ${[...VALID_FIELDS[objectType]].join(", ")}.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Fields that are derived in the canonical model (no provider property to
|
|
103
|
+
* re-read at apply time) or relational — excluded from auto-preconditions.
|
|
104
|
+
*/
|
|
105
|
+
const NON_READABLE_FIELDS = new Set([
|
|
106
|
+
// derived in the canonical model — no provider property to re-read
|
|
107
|
+
"isClosed", "isWon", "forecastCategory", "probability", "lastActivityAt", "lastSyncAt",
|
|
108
|
+
// identity/bookkeeping fields — preconditions on these are meaningless
|
|
109
|
+
"id", "crmId", "provider", "identities",
|
|
110
|
+
]);
|
|
111
|
+
/** Build the filter-evaluation view: record fields + relational pseudo-fields. */
|
|
112
|
+
function buildViews(snapshot, objectType) {
|
|
113
|
+
const accountsById = new Map(snapshot.accounts.map((a) => [a.id, a]));
|
|
114
|
+
const contactCountByAccount = new Map();
|
|
115
|
+
for (const c of snapshot.contacts) {
|
|
116
|
+
if (c.accountId)
|
|
117
|
+
contactCountByAccount.set(c.accountId, (contactCountByAccount.get(c.accountId) ?? 0) + 1);
|
|
118
|
+
}
|
|
119
|
+
const openDealCountByAccount = new Map();
|
|
120
|
+
const openDealStagesByAccount = new Map();
|
|
121
|
+
for (const d of snapshot.deals) {
|
|
122
|
+
if (d.accountId && !d.isClosed) {
|
|
123
|
+
openDealCountByAccount.set(d.accountId, (openDealCountByAccount.get(d.accountId) ?? 0) + 1);
|
|
124
|
+
const stages = openDealStagesByAccount.get(d.accountId) ?? [];
|
|
125
|
+
if (d.stage)
|
|
126
|
+
stages.push(d.stage);
|
|
127
|
+
openDealStagesByAccount.set(d.accountId, stages);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const records = snapshot[COLLECTIONS[objectType]];
|
|
131
|
+
return records.map((record) => {
|
|
132
|
+
const view = { ...record };
|
|
133
|
+
if (objectType === "account") {
|
|
134
|
+
view.contactCount = contactCountByAccount.get(String(record.id)) ?? 0;
|
|
135
|
+
view.openDealCount = openDealCountByAccount.get(String(record.id)) ?? 0;
|
|
136
|
+
view.openDealStages = (openDealStagesByAccount.get(String(record.id)) ?? []).join(",");
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
const account = record.accountId ? accountsById.get(String(record.accountId)) : undefined;
|
|
140
|
+
view["account.name"] = account?.name ?? "";
|
|
141
|
+
view["account.domain"] = account?.domain ?? "";
|
|
142
|
+
view["account.ownerId"] = account?.ownerId ?? "";
|
|
143
|
+
view["account.contactCount"] = account ? (contactCountByAccount.get(account.id) ?? 0) : 0;
|
|
144
|
+
view["account.openDealStages"] = account ? (openDealStagesByAccount.get(account.id) ?? []).join(",") : "";
|
|
145
|
+
}
|
|
146
|
+
return { record, view };
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
export function parseGuard(raw) {
|
|
150
|
+
const first = raw.indexOf(":");
|
|
151
|
+
const last = raw.lastIndexOf(":");
|
|
152
|
+
if (first === -1 || last === first) {
|
|
153
|
+
throw new Error(`Cannot parse --guard "${raw}". Use <account|contact|deal>:<where>[;<where>…]:<none|some>.`);
|
|
154
|
+
}
|
|
155
|
+
const objectType = raw.slice(0, first);
|
|
156
|
+
const expect = raw.slice(last + 1);
|
|
157
|
+
const where = raw.slice(first + 1, last).split(";").map((s) => s.trim()).filter(Boolean);
|
|
158
|
+
if (!["account", "contact", "deal"].includes(objectType) || !["none", "some"].includes(expect) || where.length === 0) {
|
|
159
|
+
throw new Error(`Cannot parse --guard "${raw}". Use <account|contact|deal>:<where>[;<where>…]:<none|some>.`);
|
|
160
|
+
}
|
|
161
|
+
// validate eagerly at plan time: a typo'd guard must fail loudly, never
|
|
162
|
+
// pass vacuously at apply time
|
|
163
|
+
assertValidFields(objectType, where.map(parseWhere), "--guard");
|
|
164
|
+
return { objectType: objectType, where, expect: expect, description: raw };
|
|
165
|
+
}
|
|
166
|
+
/** Ids of records matching a filter — used for apply-time filter re-verification. */
|
|
167
|
+
export function eligibleIds(snapshot, objectType, where) {
|
|
168
|
+
const clauses = where.map(parseWhere);
|
|
169
|
+
const views = buildViews(snapshot, objectType);
|
|
170
|
+
return new Set(views.filter(({ view }) => clauses.every((c) => matches(view, c))).map(({ record }) => String(record.id)));
|
|
171
|
+
}
|
|
172
|
+
/** Evaluate a plan guard against a snapshot. Returns null when satisfied, else a failure detail. */
|
|
173
|
+
export function evaluateGuard(snapshot, guard) {
|
|
174
|
+
const clauses = guard.where.map(parseWhere);
|
|
175
|
+
const views = buildViews(snapshot, guard.objectType);
|
|
176
|
+
const matchCount = views.filter(({ view }) => clauses.every((c) => matches(view, c))).length;
|
|
177
|
+
const ok = guard.expect === "none" ? matchCount === 0 : matchCount > 0;
|
|
178
|
+
if (ok)
|
|
179
|
+
return null;
|
|
180
|
+
return `Guard failed: expected ${guard.expect === "none" ? "no" : "at least one"} ${guard.objectType}(s) matching [${guard.where.join(" AND ")}], found ${matchCount}.`;
|
|
181
|
+
}
|
|
182
|
+
export function buildBulkUpdatePlan(snapshot, options) {
|
|
183
|
+
const maxOperations = options.maxOperations ?? 500;
|
|
184
|
+
if (options.where.length === 0) {
|
|
185
|
+
throw new Error("bulk-update requires at least one --where filter — refusing to build an unscoped mass write.");
|
|
186
|
+
}
|
|
187
|
+
const hasSet = options.set && Object.keys(options.set).length > 0;
|
|
188
|
+
const actions = [hasSet, options.archive, options.createTask !== undefined].filter(Boolean).length;
|
|
189
|
+
if (actions !== 1) {
|
|
190
|
+
throw new Error("bulk-update needs exactly one action: --set <field>=<value> (repeatable), --archive, or --create-task <text>.");
|
|
191
|
+
}
|
|
192
|
+
const clauses = options.where.map(parseWhere);
|
|
193
|
+
assertValidFields(options.objectType, clauses, "--where");
|
|
194
|
+
const WRITABLE_BLOCKLIST = new Set(["id", "crmId", "contactCount", "openDealCount", "openDealStages"]);
|
|
195
|
+
for (const field of Object.keys(options.set ?? {})) {
|
|
196
|
+
if (!VALID_FIELDS[options.objectType].has(field) || WRITABLE_BLOCKLIST.has(field) || field.includes(".")) {
|
|
197
|
+
throw new Error(`Cannot --set "${field}" on ${options.objectType}s — not a writable canonical field.`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const views = buildViews(snapshot, options.objectType);
|
|
201
|
+
const matched = views.filter(({ view }) => clauses.every((c) => matches(view, c)));
|
|
202
|
+
if (matched.length > maxOperations) {
|
|
203
|
+
throw new Error(`Filter matched ${matched.length} ${COLLECTIONS[options.objectType]} — above the ${maxOperations}-record safety cap. Narrow the --where filter or raise --max-operations explicitly.`);
|
|
204
|
+
}
|
|
205
|
+
// Preconditions: explicit --require, plus every equality filter on a real
|
|
206
|
+
// (re-readable, non-relational) field. The premise the plan was built on
|
|
207
|
+
// is re-verified per record at apply time.
|
|
208
|
+
const writtenFields = new Set(Object.keys(options.set ?? {}));
|
|
209
|
+
const preconditionSpecs = [];
|
|
210
|
+
for (const raw of options.require ?? []) {
|
|
211
|
+
const clause = parseWhere(raw);
|
|
212
|
+
if (clause.op !== "eq" || clause.field.includes(".") || (clause.value ?? "").includes("|")) {
|
|
213
|
+
throw new Error(`--require must be a direct single-value field equality (field=value), got "${raw}".`);
|
|
214
|
+
}
|
|
215
|
+
assertValidFields(options.objectType, [clause], "--require");
|
|
216
|
+
preconditionSpecs.push({ field: clause.field, expectedValue: clause.value ?? "" });
|
|
217
|
+
}
|
|
218
|
+
for (const clause of clauses) {
|
|
219
|
+
if (clause.op !== "eq")
|
|
220
|
+
continue;
|
|
221
|
+
if ((clause.value ?? "").includes("|"))
|
|
222
|
+
continue; // alternations are not single-value preconditions
|
|
223
|
+
if (clause.field.includes(".") || NON_READABLE_FIELDS.has(clause.field))
|
|
224
|
+
continue;
|
|
225
|
+
if (writtenFields.has(clause.field))
|
|
226
|
+
continue; // beforeValue already guards it
|
|
227
|
+
if (preconditionSpecs.some((p) => p.field === clause.field))
|
|
228
|
+
continue;
|
|
229
|
+
preconditionSpecs.push({ field: clause.field, expectedValue: clause.value ?? "" });
|
|
230
|
+
}
|
|
231
|
+
const whereText = options.where.join(" AND ");
|
|
232
|
+
const action = options.archive
|
|
233
|
+
? `archive`
|
|
234
|
+
: options.createTask !== undefined
|
|
235
|
+
? `create task "${options.createTask}"`
|
|
236
|
+
: `set ${Object.entries(options.set).map(([k, v]) => `${k}=${v}`).join(", ")}`;
|
|
237
|
+
const reason = options.reason ?? `bulk-update: ${action} where ${whereText}`;
|
|
238
|
+
const operations = [];
|
|
239
|
+
for (const { record } of matched) {
|
|
240
|
+
const objectId = String(record.id);
|
|
241
|
+
const groupId = `grp_${options.objectType}_${objectId}`;
|
|
242
|
+
const preconditions = preconditionSpecs.map((p) => ({
|
|
243
|
+
field: p.field,
|
|
244
|
+
// expected value is the record's CURRENT canonical value, not the
|
|
245
|
+
// filter literal — preserves casing/format the provider will echo back
|
|
246
|
+
expectedValue: record[p.field] ?? p.expectedValue,
|
|
247
|
+
}));
|
|
248
|
+
const shared = {
|
|
249
|
+
objectType: options.objectType,
|
|
250
|
+
objectId,
|
|
251
|
+
reason,
|
|
252
|
+
approvalRequired: true,
|
|
253
|
+
sourceRuleOrPolicy: "bulk-update",
|
|
254
|
+
...(preconditions.length > 0 ? { preconditions } : {}),
|
|
255
|
+
groupId,
|
|
256
|
+
};
|
|
257
|
+
if (options.archive) {
|
|
258
|
+
operations.push({
|
|
259
|
+
...shared,
|
|
260
|
+
id: `op_${stableHash(`bulk-archive:${options.objectType}:${objectId}:${whereText}`)}`,
|
|
261
|
+
operation: "archive_record",
|
|
262
|
+
beforeValue: null,
|
|
263
|
+
afterValue: null,
|
|
264
|
+
riskLevel: "high",
|
|
265
|
+
rollback: "Archived records can be restored from the provider's recycle bin within its retention window.",
|
|
266
|
+
});
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (options.createTask !== undefined) {
|
|
270
|
+
operations.push({
|
|
271
|
+
...shared,
|
|
272
|
+
id: `op_${stableHash(`bulk-task:${options.objectType}:${objectId}:${options.createTask}`)}`,
|
|
273
|
+
operation: "create_task",
|
|
274
|
+
field: "follow_up_task",
|
|
275
|
+
beforeValue: null,
|
|
276
|
+
afterValue: options.createTask,
|
|
277
|
+
riskLevel: "low",
|
|
278
|
+
rollback: "Delete the created task.",
|
|
279
|
+
});
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
for (const [field, value] of Object.entries(options.set)) {
|
|
283
|
+
operations.push({
|
|
284
|
+
...shared,
|
|
285
|
+
id: `op_${stableHash(`bulk-set:${options.objectType}:${objectId}:${field}:${value}`)}`,
|
|
286
|
+
operation: "set_field",
|
|
287
|
+
field,
|
|
288
|
+
beforeValue: record[field] ?? null,
|
|
289
|
+
afterValue: value,
|
|
290
|
+
riskLevel: "medium",
|
|
291
|
+
rollback: `Set ${field} back to ${JSON.stringify(record[field] ?? null)}.`,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const guards = (options.guard ?? []).map(parseGuard);
|
|
296
|
+
// guards must hold at plan time too — building a plan whose guard already
|
|
297
|
+
// fails is a footgun, surface it immediately
|
|
298
|
+
for (const guard of guards) {
|
|
299
|
+
const failure = evaluateGuard(snapshot, guard);
|
|
300
|
+
if (failure)
|
|
301
|
+
throw new Error(`${failure} The guard already fails against the current snapshot — the plan would never apply.`);
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
id: `patch_plan_${stableHash(`bulk:${snapshot.provider}:${snapshot.generatedAt}:${whereText}:${action}:${operations.length}`)}`,
|
|
305
|
+
title: `Bulk update: ${options.objectType}s where ${whereText}`,
|
|
306
|
+
createdAt: snapshot.generatedAt,
|
|
307
|
+
status: operations.length > 0 ? "needs_approval" : "draft",
|
|
308
|
+
dryRun: true,
|
|
309
|
+
summary: `${matched.length} ${COLLECTIONS[options.objectType]} matched (${whereText}); ${operations.length} proposed dry-run operations (${action}).${guards.length > 0 ? ` ${guards.length} apply-time guard(s).` : ""}`,
|
|
310
|
+
findings: [],
|
|
311
|
+
operations,
|
|
312
|
+
filter: { objectType: options.objectType, where: options.where },
|
|
313
|
+
...(guards.length > 0 ? { guards } : {}),
|
|
314
|
+
};
|
|
315
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -19,10 +19,12 @@ import { builtinAuditRules } from "./rules.js";
|
|
|
19
19
|
import { sampleSnapshot } from "./sampleData.js";
|
|
20
20
|
import { normalizeTranscript, parseCall, suggestCallDeal } from "./calls.js";
|
|
21
21
|
import { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, loadCaptureTexts, loadMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, } from "./market.js";
|
|
22
|
+
import { assessAxes, axesReportToText } from "./marketAxes.js";
|
|
22
23
|
import { buildWorksheet, classifyMarket } from "./marketClassify.js";
|
|
23
24
|
import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
|
|
24
25
|
import { DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
|
|
25
26
|
import { resolveRecord } from "./resolve.js";
|
|
27
|
+
import { buildBulkUpdatePlan } from "./bulkUpdate.js";
|
|
26
28
|
import { suggestValues } from "./suggest.js";
|
|
27
29
|
function usage() {
|
|
28
30
|
return `FullStackGTM — audit GTM data across providers, propose reviewable patch plans,
|
|
@@ -64,6 +66,7 @@ Usage:
|
|
|
64
66
|
fullstackgtm market worksheet --vendor <id> [--out <path>]
|
|
65
67
|
fullstackgtm market observe --from <observations.json> [--unverified]
|
|
66
68
|
fullstackgtm market fronts [--run <label>] [--diff <prior-run>] [--json]
|
|
69
|
+
fullstackgtm market axes [--run <label>] [--json]
|
|
67
70
|
fullstackgtm market report [--run <label>] [--format md|html] [--out <path>]
|
|
68
71
|
fullstackgtm market refresh [--run <label>] [--model m]
|
|
69
72
|
the live competitive map: capture vendor pages (content-addressed),
|
|
@@ -72,6 +75,18 @@ Usage:
|
|
|
72
75
|
against the stored capture it cites before it's accepted — then
|
|
73
76
|
compute deterministic front states and drift, render the field
|
|
74
77
|
report. refresh = capture → classify → drift → report in one step
|
|
78
|
+
fullstackgtm bulk-update <account|contact|deal> --where <expr> [--where …] (--set <field>=<value> [--set …] | --archive | --create-task <text>) [--require <field>=<value> …] [--guard <object>:<where>[;<where>]:<none|some> …] [source options] [--save] [--json] [--out <path>]
|
|
79
|
+
governed generic writes: filter the snapshot
|
|
80
|
+
(field=value, field!=value, field~substr, field!~substr,
|
|
81
|
+
field:empty, field:notempty, '|' = any-of; canonical fields
|
|
82
|
+
like ownerId, stage, closeDate, amount; relational
|
|
83
|
+
pseudo-fields account.name/domain/ownerId/contactCount/
|
|
84
|
+
openDealStages on deals and contacts, contactCount/
|
|
85
|
+
openDealCount/openDealStages on accounts) into a dry-run
|
|
86
|
+
patch plan. The full filter is re-verified per record at
|
|
87
|
+
apply time (incl. mid-apply rechecks); equality filters
|
|
88
|
+
double as preconditions; per-record ops apply
|
|
89
|
+
all-or-nothing; guards assert cross-record conditions.
|
|
75
90
|
fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
|
|
76
91
|
derive values for requires_human_* placeholders
|
|
77
92
|
from snapshot evidence, with confidence + reasons
|
|
@@ -749,7 +764,9 @@ function buildCallPlan(parsed, deal, proposed, current, extraNextSteps) {
|
|
|
749
764
|
async function marketCommand(args) {
|
|
750
765
|
const [subcommand, ...rest] = args;
|
|
751
766
|
const configPath = () => resolve(process.cwd(), option(rest, "--config") ?? "market.config.json");
|
|
752
|
-
|
|
767
|
+
// Catch --help anywhere before loadMarketConfig/credential checks run —
|
|
768
|
+
// several subcommands (capture, refresh) have side effects on bare invocation.
|
|
769
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h" || rest.includes("--help") || rest.includes("-h")) {
|
|
753
770
|
console.log(`Usage:
|
|
754
771
|
market init --category <name> [--out <path>] write a starter market.config.json
|
|
755
772
|
market capture [--config <path>] [--run <label>]
|
|
@@ -757,9 +774,17 @@ market classify [--run <label>] [--capture-run <label>] [--vendor <id>] [--model
|
|
|
757
774
|
market worksheet --vendor <id> [--capture-run <label>] [--out <path>]
|
|
758
775
|
market observe --from <observations.json> [--unverified]
|
|
759
776
|
market fronts [--config <path>] [--run <label>] [--diff <prior-run>] [--json]
|
|
777
|
+
market axes [--config <path>] [--run <label>] [--json]
|
|
760
778
|
market report [--config <path>] [--run <label>] [--format md|html] [--out <path>]
|
|
761
779
|
market refresh [--run <label>] [--model m] capture → classify → fronts drift → HTML report
|
|
762
780
|
|
|
781
|
+
axes runs the axis-discovery math: PCA over the vendor × claim intensity
|
|
782
|
+
matrix (PC1 = the category's primary axis, PC2 = the max-differentiation
|
|
783
|
+
direction orthogonal to it), triangulation of configured axes against the
|
|
784
|
+
PCs, and an orthogonality screen (|r|>0.75 = one axis twice). Axes live in
|
|
785
|
+
the config as claim-scoring rubrics; the report's strategic map and axis
|
|
786
|
+
lab render from them.
|
|
787
|
+
|
|
763
788
|
classify uses your Anthropic/OpenAI key (like call parse) to read the stored
|
|
764
789
|
captures and propose intensity readings; worksheet is the no-key path (an
|
|
765
790
|
agent or human fills it, submits via observe). Either way, every quoted span
|
|
@@ -947,7 +972,17 @@ recomputed deterministically on every invocation — never stored.`);
|
|
|
947
972
|
}
|
|
948
973
|
return;
|
|
949
974
|
}
|
|
950
|
-
|
|
975
|
+
if (subcommand === "axes") {
|
|
976
|
+
const set = await loadSet();
|
|
977
|
+
const report = assessAxes(config, set);
|
|
978
|
+
if (rest.includes("--json")) {
|
|
979
|
+
console.log(JSON.stringify(report, null, 2));
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
console.log(axesReportToText(report));
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
throw new Error(`Unknown market subcommand: ${subcommand} (try: init, capture, classify, worksheet, observe, fronts, axes, report, refresh)`);
|
|
951
986
|
}
|
|
952
987
|
/**
|
|
953
988
|
* The resolve gate: exit 0 = safe to create, exit 2 = match found (exists or
|
|
@@ -981,6 +1016,51 @@ async function resolveCommand(args) {
|
|
|
981
1016
|
if (result.verdict !== "safe_to_create")
|
|
982
1017
|
process.exitCode = 2;
|
|
983
1018
|
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Governed generic writes: build a dry-run patch plan from a snapshot filter
|
|
1021
|
+
* plus field assignments (or --archive). Never writes — approve and apply the
|
|
1022
|
+
* plan like any audit plan; compare-and-set protects every operation.
|
|
1023
|
+
*/
|
|
1024
|
+
async function bulkUpdateCommand(args) {
|
|
1025
|
+
const [objectType, ...rest] = args;
|
|
1026
|
+
if (!objectType || !["account", "contact", "deal"].includes(objectType)) {
|
|
1027
|
+
throw new Error("Usage: fullstackgtm bulk-update <account|contact|deal> --where <field=value|field!=value|field~substr|field:empty|field:notempty> [--where …] (--set <field>=<value> [--set …] | --archive) [source options] [--reason <text>] [--max-operations <n>] [--save] [--out <path>] [--json]");
|
|
1028
|
+
}
|
|
1029
|
+
const where = repeatedOption(rest, "--where");
|
|
1030
|
+
const set = {};
|
|
1031
|
+
for (const pair of repeatedOption(rest, "--set")) {
|
|
1032
|
+
const separator = pair.indexOf("=");
|
|
1033
|
+
if (separator === -1)
|
|
1034
|
+
throw new Error(`--set must look like <field>=<value>, got "${pair}"`);
|
|
1035
|
+
set[pair.slice(0, separator)] = pair.slice(separator + 1);
|
|
1036
|
+
}
|
|
1037
|
+
const snapshot = await readSnapshot(rest);
|
|
1038
|
+
const plan = buildBulkUpdatePlan(snapshot, {
|
|
1039
|
+
objectType: objectType,
|
|
1040
|
+
where,
|
|
1041
|
+
set: Object.keys(set).length > 0 ? set : undefined,
|
|
1042
|
+
archive: rest.includes("--archive"),
|
|
1043
|
+
createTask: option(rest, "--create-task") ?? undefined,
|
|
1044
|
+
require: repeatedOption(rest, "--require"),
|
|
1045
|
+
guard: repeatedOption(rest, "--guard"),
|
|
1046
|
+
reason: option(rest, "--reason") ?? undefined,
|
|
1047
|
+
maxOperations: numericOption(rest, "--max-operations"),
|
|
1048
|
+
});
|
|
1049
|
+
const out = option(rest, "--out");
|
|
1050
|
+
if (out) {
|
|
1051
|
+
writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(plan, null, 2)}\n`);
|
|
1052
|
+
}
|
|
1053
|
+
if (rest.includes("--save")) {
|
|
1054
|
+
await createFilePlanStore().save(plan);
|
|
1055
|
+
console.error(`Saved plan ${plan.id} (${plan.operations.length} operations). Review with \`fullstackgtm plans show ${plan.id}\`, approve with \`fullstackgtm plans approve ${plan.id} --operations <ids|all>\`, then \`fullstackgtm apply --plan-id ${plan.id} --provider <name>\`.`);
|
|
1056
|
+
}
|
|
1057
|
+
if (rest.includes("--json")) {
|
|
1058
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
1059
|
+
}
|
|
1060
|
+
else {
|
|
1061
|
+
console.log(patchPlanToMarkdown(plan));
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
984
1064
|
async function suggest(args) {
|
|
985
1065
|
const planId = option(args, "--plan-id");
|
|
986
1066
|
const planPath = option(args, "--plan");
|
|
@@ -1690,6 +1770,13 @@ export async function runCli(argv) {
|
|
|
1690
1770
|
console.log(readPackageInfo().version);
|
|
1691
1771
|
return;
|
|
1692
1772
|
}
|
|
1773
|
+
// Commands without bespoke help fall back to the top-level usage on --help
|
|
1774
|
+
// instead of executing (audit used to silently run the sample audit).
|
|
1775
|
+
// call/market/bulk-update print their own richer help.
|
|
1776
|
+
if (!["call", "market", "bulk-update"].includes(command) && (args.includes("--help") || args.includes("-h"))) {
|
|
1777
|
+
console.log(usage());
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1693
1780
|
if (command === "login") {
|
|
1694
1781
|
await login(args);
|
|
1695
1782
|
return;
|
|
@@ -1730,6 +1817,10 @@ export async function runCli(argv) {
|
|
|
1730
1817
|
await resolveCommand(args);
|
|
1731
1818
|
return;
|
|
1732
1819
|
}
|
|
1820
|
+
if (command === "bulk-update") {
|
|
1821
|
+
await bulkUpdateCommand(args);
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1733
1824
|
if (command === "market") {
|
|
1734
1825
|
await marketCommand(args);
|
|
1735
1826
|
return;
|
package/dist/connector.d.ts
CHANGED
|
@@ -18,6 +18,12 @@ export type ApplyPatchPlanOptions = {
|
|
|
18
18
|
* `readField`.
|
|
19
19
|
*/
|
|
20
20
|
checkConflicts?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* For plans carrying a filter or guards: re-run the snapshot checks after
|
|
23
|
+
* the first applied write and then every N applied writes, so a record
|
|
24
|
+
* edited mid-apply is conflicted out instead of overwritten. Default 25.
|
|
25
|
+
*/
|
|
26
|
+
recheckEvery?: number;
|
|
21
27
|
};
|
|
22
28
|
/**
|
|
23
29
|
* Apply an approved subset of a patch plan through a connector.
|