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 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:
@@ -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
- /** canonical field → new value; one action only */
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>;
@@ -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
- for (const field of Object.keys(options.set ?? {})) {
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
- for (const { record } of matched) {
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 (const [field, value] of Object.entries(options.set)) {
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 },