fullstackgtm 0.21.2 → 0.23.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 +80 -0
- package/README.md +15 -0
- package/dist/bulkUpdate.d.ts +16 -1
- package/dist/bulkUpdate.js +88 -5
- package/dist/cli.js +670 -8
- package/dist/dedupe.d.ts +14 -0
- package/dist/dedupe.js +140 -0
- package/dist/enrich.d.ts +220 -0
- package/dist/enrich.js +724 -0
- package/dist/enrichApollo.d.ts +59 -0
- package/dist/enrichApollo.js +192 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/marketReport.js +97 -44
- package/dist/reassign.d.ts +19 -0
- package/dist/reassign.js +87 -0
- package/dist/suggest.js +67 -5
- package/llms.txt +15 -0
- package/package.json +1 -1
- package/src/bulkUpdate.ts +109 -6
- package/src/cli.ts +756 -8
- package/src/dedupe.ts +182 -0
- package/src/enrich.ts +1016 -0
- package/src/enrichApollo.ts +250 -0
- package/src/index.ts +48 -1
- package/src/marketReport.ts +116 -62
- package/src/reassign.ts +117 -0
- package/src/suggest.ts +69 -5
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,86 @@ 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.23.1] — 2026-06-12
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **Literal `${vendorHeads}` leaked into expanded claim-group tables** — a
|
|
13
|
+
templating slip in the 0.22.0 narrative restructure left the vendor header
|
|
14
|
+
row unrendered (and masked a use-before-declaration). Fixed, plus a
|
|
15
|
+
regression guard: the HTML must contain no unrendered `${` placeholders
|
|
16
|
+
and expanded groups must carry real vendor headers.
|
|
17
|
+
- Claims section heading renamed to "Market Claims".
|
|
18
|
+
|
|
19
|
+
## [0.23.0] — 2026-06-12
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **The enrich layer (MVP)** — governed append/refresh of third-party data
|
|
24
|
+
into the CRM (spec: docs/enrich.md in the monorepo). Where every enrichment
|
|
25
|
+
vendor ships fire-and-forget writeback, enrich emits a diff you approve:
|
|
26
|
+
source data → deterministic matcher → fill-blanks patch plan → the
|
|
27
|
+
existing plans approve → apply chain, with the source payload stored as
|
|
28
|
+
`GtmEvidence` on the plan and `beforeValue` set on every operation for
|
|
29
|
+
apply-time compare-and-set.
|
|
30
|
+
- `enrich append [--source <id>] [--objects companies,contacts] [--save] [--config <path>]`
|
|
31
|
+
— pull (Apollo) or read staged ingest data (Clay), match via ordered
|
|
32
|
+
keys (unique-hit-wins, zero-hits-next-key, multi-hit → `onAmbiguous`
|
|
33
|
+
skip-with-candidates-recorded or `requires_human_record_selection`
|
|
34
|
+
placeholders into the suggest chain), emit a fill-blanks-only plan.
|
|
35
|
+
Without `--save`: dry-run diff, nothing written.
|
|
36
|
+
- `enrich refresh [--source <id>] [--stale-days <n>] [--save]` — work set
|
|
37
|
+
from run-store stamps older than the staleness window (per-field
|
|
38
|
+
`staleDays` → `policy.defaultStaleDays` → 90); operations only where the
|
|
39
|
+
source value actually changed, and only on fields the ledger proves
|
|
40
|
+
enrich stamped.
|
|
41
|
+
- `enrich ingest <file.csv|payload.json> --source <id> [--run-label <l>]`
|
|
42
|
+
— stage Clay CSV exports (dependency-free CSV parser) or webhook payload
|
|
43
|
+
JSON for a subsequent append/refresh.
|
|
44
|
+
- `enrich status [--runs] [--source <id>]` — last run per source, counts,
|
|
45
|
+
staleness distribution, interrupted-run cursor.
|
|
46
|
+
- `enrich.config.json` (sources / ordered match keys / field mappings /
|
|
47
|
+
policy) with strict up-front validation; the `system-only` and `always`
|
|
48
|
+
conflict-ladder rungs error as "not yet implemented (phase 2)" instead
|
|
49
|
+
of being silently accepted. MVP policy: `never` (fill blanks only).
|
|
50
|
+
- Apollo source client: raw `fetch` against the people-match /
|
|
51
|
+
organization-enrich endpoints, BYO key via `login apollo` (0600 cred
|
|
52
|
+
store) or `APOLLO_API_KEY`, and 429-aware retry with capped exponential
|
|
53
|
+
backoff honoring `Retry-After` — local to the Apollo client; the shared
|
|
54
|
+
connector contract is unchanged.
|
|
55
|
+
- Profile-scoped append-only run store
|
|
56
|
+
(`~/.fullstackgtm/profiles/<p>/enrich/runs/<runLabel>.json`): resume
|
|
57
|
+
checkpoint (cursor + already-paid-for payloads), per-record/per-field
|
|
58
|
+
`enrichedAt` staleness ledger, and the surface `enrich status` reads.
|
|
59
|
+
State stays local — no `fsgtm_enriched_at`-style properties are written
|
|
60
|
+
into the customer's portal.
|
|
61
|
+
- Every `enrich` subcommand catches `--help`/`-h` before config load,
|
|
62
|
+
credential resolution, or any network call. No scheduling/cron logic —
|
|
63
|
+
that is the horizontal schedule layer's job (docs/schedule.md).
|
|
64
|
+
|
|
65
|
+
## [0.22.0] — 2026-06-12
|
|
66
|
+
|
|
67
|
+
The report becomes a narrative: map → claims → where to attack.
|
|
68
|
+
|
|
69
|
+
### Changed
|
|
70
|
+
|
|
71
|
+
- **Reading order rebuilt around the insight.** The strategic map is now the
|
|
72
|
+
hero, directly under the header: contenders and their positions first.
|
|
73
|
+
The claim detail follows, then the report CLOSES on the reasoned takeaway.
|
|
74
|
+
The front-summary stat cards are gone (numbers without referents).
|
|
75
|
+
- **"Where to attack"** — a generated closing section that walks the open
|
|
76
|
+
fronts as an argument: each open claim names its closest quiet contenders,
|
|
77
|
+
whether the anchor already ships it quietly (promote candidate) or it's
|
|
78
|
+
unclaimed (first-mover), plus held ground (anchor-owned fronts to defend)
|
|
79
|
+
and crowded ground (saturated fronts where message budget buys least).
|
|
80
|
+
Ends by pointing at `market overlay` for evidence-backed directives.
|
|
81
|
+
- **Claims grouped and collapsed**: the matrix splits into Open / Contested /
|
|
82
|
+
Owned / Saturated `<details>` groups, default collapsed, each summary
|
|
83
|
+
carrying the skimmer's stats (count, definition of the state, anchor's
|
|
84
|
+
loud count within the group).
|
|
85
|
+
- **Evidence appendix grouped by vendor**, collapsed — receipts on demand.
|
|
86
|
+
All groups auto-expand on print (beforeprint).
|
|
87
|
+
|
|
8
88
|
## [0.21.2] — 2026-06-12
|
|
9
89
|
|
|
10
90
|
Scatter interactivity + honest sizing fallbacks.
|
package/README.md
CHANGED
|
@@ -126,6 +126,21 @@ The discipline matches the rest of the tool. Intensity readings are *proposals*
|
|
|
126
126
|
|
|
127
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
128
|
|
|
129
|
+
## Governed enrichment: a diff you approve before third-party data touches your CRM
|
|
130
|
+
|
|
131
|
+
Every enrichment vendor ships fire-and-forget writeback. The **enrich layer** inverts that: declare once which fields come from which source under which conflict policy (`enrich.config.json` — sources, ordered match keys, field mappings, policy), then `enrich append` fills the gaps and `enrich refresh` keeps them current — with every write passing through the normal dry-run → approval → apply contract, and every value traceable to the source payload that produced it.
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
echo "$APOLLO_API_KEY" | fullstackgtm login apollo # BYO key, stored 0600
|
|
135
|
+
fullstackgtm enrich append --provider hubspot # pull → match → dry-run diff, writes NOTHING
|
|
136
|
+
fullstackgtm enrich append --provider hubspot --save # persist the plan (needs_approval) + run record
|
|
137
|
+
fullstackgtm enrich ingest clay-export.csv --source clay # stage a push-style source (Clay CSV / webhook JSON)
|
|
138
|
+
fullstackgtm enrich refresh --source apollo --save # re-check stale stamped fields; ops only where the source changed
|
|
139
|
+
fullstackgtm enrich status --runs # last run per source, counts, staleness, interrupted-run cursor
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Matching is deterministic: ordered keys, unique hit wins, zero hits falls through to the next key, and multiple hits are never guessed away — they skip (recorded with candidate ids) or flow into the existing `suggest` → `plans approve` chain as `requires_human_record_selection` placeholders. The MVP conflict policy is `never`: enrich only fills blank fields, and `refresh` only re-touches fields its own run-store ledger proves it stamped (per-record/per-field `enrichedAt`, profile-scoped, never written into your portal as custom properties). The `system-only` and `always` rungs of the ladder are phase 2 and are refused explicitly, not silently accepted. Recurring execution belongs to the scheduler — enrich owns no cron logic.
|
|
143
|
+
|
|
129
144
|
### Working across organizations
|
|
130
145
|
|
|
131
146
|
Consultants and fractional operators hold credentials for several CRMs at once. A profile scopes stored logins *and* stored plans to one organization:
|
package/dist/bulkUpdate.d.ts
CHANGED
|
@@ -3,10 +3,23 @@ export type BulkUpdateOptions = {
|
|
|
3
3
|
objectType: "account" | "contact" | "deal";
|
|
4
4
|
/** raw --where expressions, AND-ed together; at least one is required */
|
|
5
5
|
where: string[];
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* canonical field → new value; one action only. A value of the form
|
|
8
|
+
* `from:<sourceField>` is resolved PER RECORD from the filter view at
|
|
9
|
+
* plan time (relational pseudo-fields like account.ownerId included);
|
|
10
|
+
* records whose source value is empty are skipped, not failed, and
|
|
11
|
+
* counted in the plan summary.
|
|
12
|
+
*/
|
|
7
13
|
set?: Record<string, string>;
|
|
8
14
|
/** propose archive_record instead of field writes */
|
|
9
15
|
archive?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* bypass the archive duplicate guard: by default --archive refuses when a
|
|
18
|
+
* matched account/contact shares its identity key (normalized domain /
|
|
19
|
+
* lowercased email) with another record — those are duplicates, and
|
|
20
|
+
* archiving a duplicate discards its data where merging preserves it
|
|
21
|
+
*/
|
|
22
|
+
forceArchiveDuplicates?: boolean;
|
|
10
23
|
/** propose create_task on each matched record with this subject/body text */
|
|
11
24
|
createTask?: string;
|
|
12
25
|
/** explicit preconditions (field=value), re-verified at apply time */
|
|
@@ -28,6 +41,8 @@ type WhereClause = {
|
|
|
28
41
|
raw: string;
|
|
29
42
|
};
|
|
30
43
|
export declare function parseWhere(expr: string): WhereClause;
|
|
44
|
+
/** True when `field` is filterable for this object type (relational pseudo-fields included). */
|
|
45
|
+
export declare function isFilterableField(objectType: BulkUpdateOptions["objectType"], field: string): boolean;
|
|
31
46
|
export declare function parseGuard(raw: string): PlanGuard;
|
|
32
47
|
/** Ids of records matching a filter — used for apply-time filter re-verification. */
|
|
33
48
|
export declare function eligibleIds(snapshot: CanonicalGtmSnapshot, objectType: BulkUpdateOptions["objectType"], where: string[]): Set<string>;
|
package/dist/bulkUpdate.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* `account.ownerId`, `account.contactCount`; accounts get `contactCount`
|
|
28
28
|
* and `openDealCount`.
|
|
29
29
|
*/
|
|
30
|
+
import { normalizeDomain } from "./merge.js";
|
|
30
31
|
import { stableHash } from "./rules.js";
|
|
31
32
|
const FIELD_PATTERN = "[a-zA-Z][a-zA-Z0-9_]*(?:\\.[a-zA-Z][a-zA-Z0-9_]*)?";
|
|
32
33
|
export function parseWhere(expr) {
|
|
@@ -91,6 +92,10 @@ const VALID_FIELDS = {
|
|
|
91
92
|
contact: new Set(["id", "crmId", "accountId", "firstName", "lastName", "email", "phone", "title", "ownerId", "lastSyncAt", ...RELATIONAL_FIELDS]),
|
|
92
93
|
deal: new Set(["id", "crmId", "accountId", "ownerId", "name", "amount", "currency", "stage", "closeDate", "dealType", "forecastCategory", "nextStep", "probability", "isClosed", "isWon", "lastActivityAt", "lastSyncAt", ...RELATIONAL_FIELDS]),
|
|
93
94
|
};
|
|
95
|
+
/** True when `field` is filterable for this object type (relational pseudo-fields included). */
|
|
96
|
+
export function isFilterableField(objectType, field) {
|
|
97
|
+
return VALID_FIELDS[objectType].has(field);
|
|
98
|
+
}
|
|
94
99
|
function assertValidFields(objectType, clauses, context) {
|
|
95
100
|
for (const clause of clauses) {
|
|
96
101
|
if (!VALID_FIELDS[objectType].has(clause.field)) {
|
|
@@ -192,16 +197,66 @@ export function buildBulkUpdatePlan(snapshot, options) {
|
|
|
192
197
|
const clauses = options.where.map(parseWhere);
|
|
193
198
|
assertValidFields(options.objectType, clauses, "--where");
|
|
194
199
|
const WRITABLE_BLOCKLIST = new Set(["id", "crmId", "contactCount", "openDealCount", "openDealStages"]);
|
|
195
|
-
|
|
200
|
+
// `from:<sourceField>` values resolve per record from the filter view —
|
|
201
|
+
// the source is validated with the same strictness as filters (relational
|
|
202
|
+
// pseudo-fields allowed; the WRITTEN field still must be canonical).
|
|
203
|
+
const assignments = [];
|
|
204
|
+
for (const [field, value] of Object.entries(options.set ?? {})) {
|
|
196
205
|
if (!VALID_FIELDS[options.objectType].has(field) || WRITABLE_BLOCKLIST.has(field) || field.includes(".")) {
|
|
197
206
|
throw new Error(`Cannot --set "${field}" on ${options.objectType}s — not a writable canonical field.`);
|
|
198
207
|
}
|
|
208
|
+
if (value.startsWith("from:")) {
|
|
209
|
+
const fromField = value.slice("from:".length);
|
|
210
|
+
if (!VALID_FIELDS[options.objectType].has(fromField)) {
|
|
211
|
+
throw new Error(`Cannot --set ${field}=from:${fromField} on ${options.objectType}s — unknown source field "${fromField}". Valid fields: ${[...VALID_FIELDS[options.objectType]].join(", ")}.`);
|
|
212
|
+
}
|
|
213
|
+
assignments.push({ field, fromField });
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
assignments.push({ field, literal: value });
|
|
217
|
+
}
|
|
199
218
|
}
|
|
200
219
|
const views = buildViews(snapshot, options.objectType);
|
|
201
220
|
const matched = views.filter(({ view }) => clauses.every((c) => matches(view, c)));
|
|
202
221
|
if (matched.length > maxOperations) {
|
|
203
222
|
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
223
|
}
|
|
224
|
+
// Archive duplicate guard: archiving a record that shares its identity key
|
|
225
|
+
// with another active record discards data a merge would preserve. Refuse
|
|
226
|
+
// and point at `dedupe` unless explicitly overridden. Deals are exempt —
|
|
227
|
+
// they carry no identity key.
|
|
228
|
+
if (options.archive && options.objectType !== "deal" && !options.forceArchiveDuplicates) {
|
|
229
|
+
const keyName = options.objectType === "account" ? "domain" : "email";
|
|
230
|
+
const keyOf = (record) => options.objectType === "account"
|
|
231
|
+
? normalizeDomain(record.domain)
|
|
232
|
+
: (record.email?.trim().toLowerCase() || undefined);
|
|
233
|
+
const allRecords = snapshot[COLLECTIONS[options.objectType]];
|
|
234
|
+
const byKey = new Map();
|
|
235
|
+
for (const record of allRecords) {
|
|
236
|
+
const key = keyOf(record);
|
|
237
|
+
if (!key)
|
|
238
|
+
continue;
|
|
239
|
+
const existing = byKey.get(key) ?? [];
|
|
240
|
+
existing.push(record);
|
|
241
|
+
byKey.set(key, existing);
|
|
242
|
+
}
|
|
243
|
+
const collisions = [];
|
|
244
|
+
for (const { record } of matched) {
|
|
245
|
+
const key = keyOf(record);
|
|
246
|
+
if (!key)
|
|
247
|
+
continue;
|
|
248
|
+
const others = (byKey.get(key) ?? []).filter((other) => other.id !== record.id);
|
|
249
|
+
if (others.length === 0)
|
|
250
|
+
continue;
|
|
251
|
+
const label = record.name ?? record.email ?? "";
|
|
252
|
+
collisions.push(`${options.objectType} ${record.id}${label ? ` "${label}"` : ""} shares ${keyName} "${key}" with ${others
|
|
253
|
+
.map((other) => `${other.id}${other.name ? ` "${other.name}"` : ""}`)
|
|
254
|
+
.join(", ")}`);
|
|
255
|
+
}
|
|
256
|
+
if (collisions.length > 0) {
|
|
257
|
+
throw new Error(`Refusing to archive: ${collisions.length} matched record(s) look like duplicates of other records — archiving a duplicate DISCARDS its data, merging preserves it. Use \`fullstackgtm dedupe ${options.objectType} --key ${keyName}\` (merge_records) instead, or pass --force-archive-duplicates to archive anyway.\n - ${collisions.join("\n - ")}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
205
260
|
// Preconditions: explicit --require, plus every equality filter on a real
|
|
206
261
|
// (re-readable, non-relational) field. The premise the plan was built on
|
|
207
262
|
// is re-verified per record at apply time.
|
|
@@ -236,7 +291,9 @@ export function buildBulkUpdatePlan(snapshot, options) {
|
|
|
236
291
|
: `set ${Object.entries(options.set).map(([k, v]) => `${k}=${v}`).join(", ")}`;
|
|
237
292
|
const reason = options.reason ?? `bulk-update: ${action} where ${whereText}`;
|
|
238
293
|
const operations = [];
|
|
239
|
-
|
|
294
|
+
// records skipped because a from:<sourceField> value was empty, per source
|
|
295
|
+
const skippedBySource = new Map();
|
|
296
|
+
for (const { record, view } of matched) {
|
|
240
297
|
const objectId = String(record.id);
|
|
241
298
|
const groupId = `grp_${options.objectType}_${objectId}`;
|
|
242
299
|
const preconditions = preconditionSpecs.map((p) => ({
|
|
@@ -279,7 +336,30 @@ export function buildBulkUpdatePlan(snapshot, options) {
|
|
|
279
336
|
});
|
|
280
337
|
continue;
|
|
281
338
|
}
|
|
282
|
-
for
|
|
339
|
+
// Resolve every assignment for this record BEFORE emitting any of its
|
|
340
|
+
// operations: a record whose from:<sourceField> resolves empty is
|
|
341
|
+
// skipped whole (its operations share a groupId — half a record's
|
|
342
|
+
// updates is exactly what grouping exists to prevent).
|
|
343
|
+
const resolved = [];
|
|
344
|
+
let emptySource = null;
|
|
345
|
+
for (const assignment of assignments) {
|
|
346
|
+
if (assignment.fromField !== undefined) {
|
|
347
|
+
const value = fieldValue(view, assignment.fromField);
|
|
348
|
+
if (value === "") {
|
|
349
|
+
emptySource = assignment.fromField;
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
resolved.push({ field: assignment.field, value });
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
resolved.push({ field: assignment.field, value: assignment.literal });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (emptySource !== null) {
|
|
359
|
+
skippedBySource.set(emptySource, (skippedBySource.get(emptySource) ?? 0) + 1);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
for (const { field, value } of resolved) {
|
|
283
363
|
operations.push({
|
|
284
364
|
...shared,
|
|
285
365
|
id: `op_${stableHash(`bulk-set:${options.objectType}:${objectId}:${field}:${value}`)}`,
|
|
@@ -300,13 +380,16 @@ export function buildBulkUpdatePlan(snapshot, options) {
|
|
|
300
380
|
if (failure)
|
|
301
381
|
throw new Error(`${failure} The guard already fails against the current snapshot — the plan would never apply.`);
|
|
302
382
|
}
|
|
383
|
+
const skippedText = [...skippedBySource.entries()]
|
|
384
|
+
.map(([sourceField, count]) => ` ${count} skipped: empty ${sourceField}.`)
|
|
385
|
+
.join("");
|
|
303
386
|
return {
|
|
304
|
-
id: `patch_plan_${stableHash(`bulk:${snapshot.provider}:${snapshot.generatedAt}:${whereText}:${action}:${operations.length}`)}`,
|
|
387
|
+
id: `patch_plan_${stableHash(`bulk:${snapshot.provider}:${snapshot.generatedAt}:${options.objectType}:${whereText}:${action}:${operations.length}`)}`,
|
|
305
388
|
title: `Bulk update: ${options.objectType}s where ${whereText}`,
|
|
306
389
|
createdAt: snapshot.generatedAt,
|
|
307
390
|
status: operations.length > 0 ? "needs_approval" : "draft",
|
|
308
391
|
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).` : ""}`,
|
|
392
|
+
summary: `${matched.length} ${COLLECTIONS[options.objectType]} matched (${whereText}); ${operations.length} proposed dry-run operations (${action}).${skippedText}${guards.length > 0 ? ` ${guards.length} apply-time guard(s).` : ""}`,
|
|
310
393
|
findings: [],
|
|
311
394
|
operations,
|
|
312
395
|
filter: { objectType: options.objectType, where: options.where },
|