fullstackgtm 0.39.0 → 0.41.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.
@@ -0,0 +1,515 @@
1
+ import { mkdirSync, readFileSync, readdirSync, appendFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.js";
4
+ export const SIGNAL_BUCKETS = ["demand", "funding", "job", "company", "social"];
5
+ /**
6
+ * Zero-config preset. `signals fetch` works with no `signals.config.json`
7
+ * (preset-first, like enrich). The four free buckets carry the system; `demand`
8
+ * is schema-reserved (privileged source, unbuilt) so its `sources` stay empty.
9
+ */
10
+ export const DEFAULT_SIGNALS_CONFIG = {
11
+ watchlist: {},
12
+ buckets: {
13
+ demand: { sources: [], weight: 2.0 },
14
+ funding: { sources: ["news"], weight: 1.6 },
15
+ job: { sources: ["greenhouse", "lever", "ashby"], keywords: [], weight: 1.0 },
16
+ company: { sources: ["web"], weight: 1.0 },
17
+ social: { sources: ["web"], weight: 0.4 },
18
+ },
19
+ dedupWindowDays: 30,
20
+ repostedRoleBoost: 0.5,
21
+ };
22
+ // ---------------------------------------------------------------------------
23
+ // Domain normalization — mirrors enrich's normalizeKeyValue("domain", ...)
24
+ // (strip protocol/www/path, lowercased) so a signal account key lines up with
25
+ // the canonical snapshot / enrich matcher.
26
+ export function normalizeAccountDomain(value) {
27
+ const text = String(value ?? "").trim().toLowerCase();
28
+ if (!text)
29
+ return "";
30
+ return text
31
+ .replace(/^https?:\/\//, "")
32
+ .replace(/^www\./, "")
33
+ .replace(/\/.*$/, "");
34
+ }
35
+ // ---------------------------------------------------------------------------
36
+ // Stable hashing (FNV-1a) — mirrors enrich.ts fnv1a, duplicated to keep this
37
+ // file importable without the audit engine (the market.ts/enrich.ts precedent).
38
+ function fnv1a(value) {
39
+ let hash = 0x811c9dc5;
40
+ for (let i = 0; i < value.length; i += 1) {
41
+ hash ^= value.charCodeAt(i);
42
+ hash = Math.imul(hash, 0x01000193);
43
+ }
44
+ return (hash >>> 0).toString(16).padStart(8, "0");
45
+ }
46
+ /** Dedup key: a signal is "the same change" iff (account, bucket, trigger) match. */
47
+ export function dedupKey(signal) {
48
+ return `${signal.accountDomain}|${signal.bucket}|${signal.trigger}`;
49
+ }
50
+ export function signalId(signal) {
51
+ return `sig_${fnv1a(dedupKey(signal))}`;
52
+ }
53
+ function outcomeId(o) {
54
+ return `out_${fnv1a(`${o.accountDomain}|${o.touchId ?? ""}|${o.result}|${o.recordedAt}`)}`;
55
+ }
56
+ // ---------------------------------------------------------------------------
57
+ // Config parsing — strict, up-front, names the offending entry (enrich pattern).
58
+ function fail(message) {
59
+ throw new Error(`signals config: ${message}`);
60
+ }
61
+ export function parseSignalsConfig(raw) {
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(raw);
65
+ }
66
+ catch (error) {
67
+ fail(`not valid JSON (${error instanceof Error ? error.message : String(error)})`);
68
+ }
69
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
70
+ fail("expected a JSON object with watchlist, buckets, dedupWindowDays, repostedRoleBoost");
71
+ }
72
+ const config = parsed;
73
+ // watchlist
74
+ const watchlist = config.watchlist ?? {};
75
+ if (typeof watchlist !== "object" || Array.isArray(watchlist)) {
76
+ fail('"watchlist" must be an object (source / domains / boards)');
77
+ }
78
+ if (watchlist.source !== undefined && typeof watchlist.source !== "string") {
79
+ fail('watchlist.source must be a string (e.g. "crm:open-pipeline")');
80
+ }
81
+ if (watchlist.domains !== undefined) {
82
+ if (!Array.isArray(watchlist.domains) || watchlist.domains.some((d) => typeof d !== "string")) {
83
+ fail("watchlist.domains must be an array of domain strings");
84
+ }
85
+ }
86
+ if (watchlist.boards !== undefined) {
87
+ if (typeof watchlist.boards !== "object" || Array.isArray(watchlist.boards)) {
88
+ fail("watchlist.boards must be an object mapping domain -> board token");
89
+ }
90
+ for (const [domain, tok] of Object.entries(watchlist.boards)) {
91
+ if (typeof tok !== "string" || !tok)
92
+ fail(`watchlist.boards["${domain}"] must be a non-empty board token`);
93
+ }
94
+ }
95
+ // buckets — fill missing buckets from the default so a partial config is valid.
96
+ const buckets = {};
97
+ const rawBuckets = (config.buckets ?? {});
98
+ if (typeof rawBuckets !== "object" || Array.isArray(rawBuckets)) {
99
+ fail('"buckets" must be an object keyed by bucket name');
100
+ }
101
+ for (const key of Object.keys(rawBuckets)) {
102
+ if (!SIGNAL_BUCKETS.includes(key)) {
103
+ fail(`unknown bucket "${key}" (use: ${SIGNAL_BUCKETS.join(", ")})`);
104
+ }
105
+ }
106
+ for (const bucket of SIGNAL_BUCKETS) {
107
+ const provided = rawBuckets[bucket];
108
+ if (provided === undefined) {
109
+ buckets[bucket] = { ...DEFAULT_SIGNALS_CONFIG.buckets[bucket] };
110
+ continue;
111
+ }
112
+ if (typeof provided !== "object" || Array.isArray(provided))
113
+ fail(`buckets.${bucket} must be an object`);
114
+ if (!Array.isArray(provided.sources) || provided.sources.some((s) => typeof s !== "string")) {
115
+ fail(`buckets.${bucket}.sources must be an array of source strings`);
116
+ }
117
+ if (provided.weight !== undefined && (!Number.isFinite(provided.weight) || provided.weight < 0)) {
118
+ fail(`buckets.${bucket}.weight must be a non-negative number`);
119
+ }
120
+ if (provided.keywords !== undefined) {
121
+ if (!Array.isArray(provided.keywords) || provided.keywords.some((k) => typeof k !== "string")) {
122
+ fail(`buckets.${bucket}.keywords must be an array of strings`);
123
+ }
124
+ }
125
+ buckets[bucket] = {
126
+ sources: provided.sources,
127
+ weight: provided.weight ?? DEFAULT_SIGNALS_CONFIG.buckets[bucket].weight,
128
+ ...(provided.keywords !== undefined ? { keywords: provided.keywords } : {}),
129
+ };
130
+ }
131
+ const dedupWindowDays = config.dedupWindowDays ?? DEFAULT_SIGNALS_CONFIG.dedupWindowDays;
132
+ if (!Number.isFinite(dedupWindowDays) || dedupWindowDays <= 0) {
133
+ fail(`dedupWindowDays must be a positive number (got ${JSON.stringify(config.dedupWindowDays)})`);
134
+ }
135
+ const repostedRoleBoost = config.repostedRoleBoost ?? DEFAULT_SIGNALS_CONFIG.repostedRoleBoost;
136
+ if (!Number.isFinite(repostedRoleBoost) || repostedRoleBoost < 0) {
137
+ fail(`repostedRoleBoost must be a non-negative number (got ${JSON.stringify(config.repostedRoleBoost)})`);
138
+ }
139
+ return {
140
+ watchlist: {
141
+ ...(watchlist.source !== undefined ? { source: watchlist.source } : {}),
142
+ ...(watchlist.domains !== undefined ? { domains: watchlist.domains } : {}),
143
+ ...(watchlist.boards !== undefined ? { boards: watchlist.boards } : {}),
144
+ },
145
+ buckets,
146
+ dedupWindowDays,
147
+ repostedRoleBoost,
148
+ };
149
+ }
150
+ export const SIGNALS_CONFIG_FILE_NAME = "signals.config.json";
151
+ export function loadSignalsConfig(path) {
152
+ let raw;
153
+ try {
154
+ raw = readFileSync(path, "utf8");
155
+ }
156
+ catch {
157
+ throw new Error(`No signals config at ${path}. Create ${SIGNALS_CONFIG_FILE_NAME} (watchlist/buckets/` +
158
+ "dedupWindowDays/repostedRoleBoost — see docs/signals.md) or run with the zero-config defaults.");
159
+ }
160
+ return parseSignalsConfig(raw);
161
+ }
162
+ // ---------------------------------------------------------------------------
163
+ // Recency + weight
164
+ const DAY_MS = 24 * 60 * 60 * 1000;
165
+ /**
166
+ * Recency decay over the dedup window: a signal seen today is 1.0; one at the
167
+ * window edge is ~`floor`. Linear, clamped, deterministic — no surprise from
168
+ * an exponential's tail. Older than the window → `floor`.
169
+ */
170
+ export function recencyFactor(firstSeen, now, windowDays) {
171
+ const seen = Date.parse(firstSeen);
172
+ if (!Number.isFinite(seen))
173
+ return 1;
174
+ const ageDays = Math.max(0, (now.getTime() - seen) / DAY_MS);
175
+ const floor = 0.4;
176
+ if (windowDays <= 0)
177
+ return 1;
178
+ const decayed = 1 - (1 - floor) * Math.min(1, ageDays / windowDays);
179
+ return Math.max(floor, decayed);
180
+ }
181
+ /**
182
+ * Final signal weight = bucketWeight × recency (+ repost boost when reposted).
183
+ * Rounded to 4 dp so the same inputs always serialize the same string (the
184
+ * deterministic-store property the tests and dedup ledger rely on).
185
+ */
186
+ export function signalWeight(opts) {
187
+ const base = opts.bucketWeight * recencyFactor(opts.firstSeen, opts.now, opts.windowDays);
188
+ const boosted = opts.reposted ? base + opts.repostedRoleBoost : base;
189
+ return Math.round(boosted * 10000) / 10000;
190
+ }
191
+ // ---------------------------------------------------------------------------
192
+ // Build signals from ATS jobs
193
+ /**
194
+ * Map a board's open roles to `job` signals for one account, computing weights
195
+ * and applying the reposted heuristic against `priorSignals` (the store's prior
196
+ * job signals for this account). A role is REPOSTED when the store saw a
197
+ * same-title job signal inside `dedupWindowDays` under a DIFFERENT role id (the
198
+ * old req closed, a new req opened with the same title) — the "first hire fell
199
+ * through, more pain now" signal the article calls out.
200
+ *
201
+ * A role only becomes a signal if its quote can carry a configured keyword
202
+ * verbatim (the evidence anchor). With no keywords configured, every open role
203
+ * qualifies and the quote is title + leading JD snippet.
204
+ */
205
+ export function buildSignalsFromAts(rawJobs, account, config, opts = {}) {
206
+ const now = opts.now ?? new Date();
207
+ const nowIso = now.toISOString();
208
+ const windowDays = config.dedupWindowDays;
209
+ const bucketWeight = config.buckets.job.weight;
210
+ const keywords = config.buckets.job.keywords ?? [];
211
+ const accountDomain = normalizeAccountDomain(account.domain);
212
+ // Prior same-title job signals (within window) indexed by title key, with the
213
+ // set of role ids seen — a different role id under a known title = reposted.
214
+ const priorByTitle = new Map();
215
+ for (const prior of opts.priorSignals ?? []) {
216
+ if (prior.bucket !== "job" || prior.accountDomain !== accountDomain)
217
+ continue;
218
+ const titleKey = roleTitleKey(prior.trigger);
219
+ const entry = priorByTitle.get(titleKey) ?? { roleKeys: new Set(), withinWindow: false };
220
+ if (prior.roleKey)
221
+ entry.roleKeys.add(prior.roleKey);
222
+ if (withinWindow(prior.firstSeen, now, windowDays))
223
+ entry.withinWindow = true;
224
+ priorByTitle.set(titleKey, entry);
225
+ }
226
+ const signals = [];
227
+ for (const job of rawJobs) {
228
+ const title = job.title.trim();
229
+ if (!title)
230
+ continue;
231
+ // Quote must carry a configured keyword verbatim; with no keywords, any role
232
+ // qualifies. Reject a role whose JD contains none of the keywords — the
233
+ // judge/drafter could not ground a why-now on it.
234
+ const quote = jobQuote(title, job.jdSnippet, keywords);
235
+ if (quote === null)
236
+ continue;
237
+ const titleKey = roleTitleKey(title);
238
+ const prior = priorByTitle.get(titleKey);
239
+ const reposted = Boolean(prior && prior.withinWindow && job.firstSeenKey != null && !prior.roleKeys.has(job.firstSeenKey));
240
+ const trigger = reposted ? `reposted: ${title}` : `hiring: ${title}`;
241
+ const base = { accountDomain, bucket: "job", trigger };
242
+ signals.push({
243
+ id: signalId(base),
244
+ accountDomain,
245
+ bucket: "job",
246
+ trigger,
247
+ quote,
248
+ sourceUrl: job.url,
249
+ firstSeen: nowIso,
250
+ weight: signalWeight({
251
+ bucketWeight,
252
+ firstSeen: nowIso,
253
+ now,
254
+ windowDays,
255
+ reposted,
256
+ repostedRoleBoost: config.repostedRoleBoost,
257
+ }),
258
+ ...(reposted ? { reposted: true } : {}),
259
+ source: job.source,
260
+ judgedBy: null,
261
+ roleKey: job.firstSeenKey,
262
+ });
263
+ }
264
+ return signals;
265
+ }
266
+ /** Normalize a role title for same-role matching (collapse spacing, lowercase). */
267
+ function roleTitleKey(triggerOrTitle) {
268
+ return triggerOrTitle
269
+ .replace(/^(reposted|hiring):\s*/i, "")
270
+ .trim()
271
+ .toLowerCase()
272
+ .replace(/\s+/g, " ");
273
+ }
274
+ /**
275
+ * Build the verbatim evidence quote for a job signal. The keyword (if any) must
276
+ * appear verbatim in the title or JD snippet; returns null when keywords are
277
+ * configured but none match (so the role is dropped, not faked).
278
+ */
279
+ function jobQuote(title, jdSnippet, keywords) {
280
+ const snippet = jdSnippet.trim();
281
+ const composed = snippet ? `${title} — ${snippet}` : title;
282
+ const active = keywords.map((k) => k.trim()).filter(Boolean);
283
+ if (active.length === 0)
284
+ return composed;
285
+ const hay = composed.toLowerCase();
286
+ const matched = active.some((k) => hay.includes(k.toLowerCase()));
287
+ return matched ? composed : null;
288
+ }
289
+ function withinWindow(iso, now, windowDays) {
290
+ const t = Date.parse(iso);
291
+ if (!Number.isFinite(t))
292
+ return false;
293
+ return now.getTime() - t <= windowDays * DAY_MS;
294
+ }
295
+ // ---------------------------------------------------------------------------
296
+ // Dedup
297
+ /**
298
+ * Split candidate signals into fresh vs. deduped against prior signals. A
299
+ * candidate is deduped when a prior signal shares its `dedupKey`
300
+ * (account|bucket|trigger) AND that prior was seen inside `windowDays` — the
301
+ * store's dedup ledger role. A reposted role carries a DIFFERENT trigger
302
+ * ("reposted: X" vs "hiring: X"), so it is correctly fresh. Deterministic.
303
+ */
304
+ export function dedupeSignals(candidates, priorSignals, windowDays, now) {
305
+ const recentKeys = new Set();
306
+ for (const prior of priorSignals) {
307
+ if (withinWindow(prior.firstSeen, now, windowDays))
308
+ recentKeys.add(dedupKey(prior));
309
+ }
310
+ const fresh = [];
311
+ const deduped = [];
312
+ const seenThisRun = new Set();
313
+ for (const candidate of candidates) {
314
+ const key = dedupKey(candidate);
315
+ if (recentKeys.has(key) || seenThisRun.has(key)) {
316
+ deduped.push(candidate);
317
+ continue;
318
+ }
319
+ seenThisRun.add(key);
320
+ fresh.push(candidate);
321
+ }
322
+ return { fresh, deduped };
323
+ }
324
+ // ---------------------------------------------------------------------------
325
+ // Learn loop: bucket weights from outcomes (Beta-smoothed booked-meeting rate)
326
+ /**
327
+ * Recompute a per-bucket weight multiplier from the outcome ledger: the
328
+ * booked-meeting rate of touches credited to each bucket, Beta-smoothed against
329
+ * the declared default so a single reply does not swing a bucket. With NO
330
+ * outcomes, weights == config defaults (graceful cold start). Deterministic:
331
+ * same ledger → same weights.
332
+ *
333
+ * Mechanic: a bucket's declared default `w` acts as the prior mean. We observe
334
+ * `booked` booked meetings out of `total` credited touches and form a
335
+ * Beta(α,β)-style posterior mean with prior strength `priorStrength`, then scale
336
+ * the default by (posteriorRate / priorRate). priorRate is a fixed baseline
337
+ * booked-rate the default is calibrated against, so a bucket that books above
338
+ * baseline gets heavier and one that dies gets cut — the config value is never
339
+ * overwritten, only this overlay moves.
340
+ */
341
+ export function computeWeights(config, outcomes, signalsById) {
342
+ const PRIOR_STRENGTH = 4; // pseudo-touches; damps small samples
343
+ const PRIOR_RATE = 0.2; // baseline booked-meeting rate the defaults assume
344
+ // Per-bucket booked / total over credited touches. A touch books if its
345
+ // result is "meeting". Credit flows to the bucket of each credited signal.
346
+ const booked = blankCounts();
347
+ const total = blankCounts();
348
+ for (const outcome of outcomes) {
349
+ const buckets = creditedBuckets(outcome, signalsById);
350
+ const isBooked = outcome.result === "meeting" ? 1 : 0;
351
+ for (const bucket of buckets) {
352
+ total[bucket] += 1;
353
+ booked[bucket] += isBooked;
354
+ }
355
+ }
356
+ const weights = {};
357
+ for (const bucket of SIGNAL_BUCKETS) {
358
+ const declared = config.buckets[bucket].weight;
359
+ const n = total[bucket];
360
+ if (n === 0) {
361
+ weights[bucket] = declared; // cold start: exactly the declared default
362
+ continue;
363
+ }
364
+ const posteriorRate = (booked[bucket] + PRIOR_STRENGTH * PRIOR_RATE) / (n + PRIOR_STRENGTH);
365
+ const scaled = declared * (posteriorRate / PRIOR_RATE);
366
+ weights[bucket] = Math.round(scaled * 10000) / 10000;
367
+ }
368
+ return weights;
369
+ }
370
+ /**
371
+ * Which buckets an outcome credits. When the credited signal objects are known
372
+ * (`signalsById`), use their real buckets; otherwise fall back to parsing the
373
+ * `sig_` ids' bucket is not recoverable, so credit nothing (the cold-start /
374
+ * unknown path leaves weights at defaults rather than mis-crediting).
375
+ */
376
+ function creditedBuckets(outcome, signalsById) {
377
+ if (!signalsById)
378
+ return [];
379
+ const buckets = new Set();
380
+ for (const id of outcome.creditedSignals) {
381
+ const signal = signalsById.get(id);
382
+ if (signal)
383
+ buckets.add(signal.bucket);
384
+ }
385
+ return [...buckets];
386
+ }
387
+ function blankCounts() {
388
+ return { demand: 0, funding: 0, job: 0, company: 0, social: 0 };
389
+ }
390
+ // ---------------------------------------------------------------------------
391
+ // Store: per-label JSON runs + append-only outcomes.jsonl, profile-scoped.
392
+ export function signalsDir(baseDir) {
393
+ return join(baseDir ?? credentialsDir(), "signals");
394
+ }
395
+ export function signalRunId(runLabel) {
396
+ return `sigr_${fnv1a(runLabel)}`;
397
+ }
398
+ export function createFileSignalStore(directory) {
399
+ const dir = directory ?? signalsDir();
400
+ const runsDir = join(dir, "runs");
401
+ const outcomesPath = join(dir, "outcomes.jsonl");
402
+ function fileFor(runLabel) {
403
+ if (!/^[\w.-]+$/.test(runLabel))
404
+ throw new Error(`Invalid run label: ${runLabel}`);
405
+ return join(runsDir, `${runLabel}.json`);
406
+ }
407
+ function read(runLabel) {
408
+ try {
409
+ return JSON.parse(readFileSync(fileFor(runLabel), "utf8"));
410
+ }
411
+ catch {
412
+ return null;
413
+ }
414
+ }
415
+ function write(run) {
416
+ // Run files carry account domains + source quotes/URLs; keep them owner-only
417
+ // like plan/enrich files (and lock the home down even before any login).
418
+ if (!directory)
419
+ ensureSecureHomeDir();
420
+ mkdirSync(runsDir, { recursive: true, mode: 0o700 });
421
+ writeSecureFile(fileFor(run.runLabel), `${JSON.stringify(run, null, 2)}\n`);
422
+ return run;
423
+ }
424
+ function listRunsSync() {
425
+ let names = [];
426
+ try {
427
+ names = readdirSync(runsDir).filter((name) => name.endsWith(".json"));
428
+ }
429
+ catch {
430
+ return [];
431
+ }
432
+ return names
433
+ .map((name) => read(name.slice(0, -".json".length)))
434
+ .filter((run) => run !== null)
435
+ .sort((a, b) => a.startedAt.localeCompare(b.startedAt));
436
+ }
437
+ function readOutcomes() {
438
+ let raw;
439
+ try {
440
+ raw = readFileSync(outcomesPath, "utf8");
441
+ }
442
+ catch {
443
+ return [];
444
+ }
445
+ const out = [];
446
+ for (const line of raw.split("\n")) {
447
+ const trimmed = line.trim();
448
+ if (!trimmed)
449
+ continue;
450
+ try {
451
+ out.push(JSON.parse(trimmed));
452
+ }
453
+ catch {
454
+ // Skip a corrupt line rather than fail the whole ledger read.
455
+ }
456
+ }
457
+ return out;
458
+ }
459
+ return {
460
+ async appendRun(run) {
461
+ if (read(run.runLabel)) {
462
+ throw new Error(`Signal run "${run.runLabel}" already exists — runs are append-only; use a new run label`);
463
+ }
464
+ return write(run);
465
+ },
466
+ async updateRun(run) {
467
+ const existing = read(run.runLabel);
468
+ if (!existing)
469
+ throw new Error(`No signal run "${run.runLabel}" to update`);
470
+ if (existing.id !== run.id) {
471
+ throw new Error(`Signal run "${run.runLabel}" belongs to a different run id (${existing.id})`);
472
+ }
473
+ return write(run);
474
+ },
475
+ async getRun(runLabel) {
476
+ return read(runLabel);
477
+ },
478
+ async listRuns() {
479
+ return listRunsSync();
480
+ },
481
+ async latestRun() {
482
+ const runs = listRunsSync();
483
+ return runs.length ? runs[runs.length - 1] : null;
484
+ },
485
+ async appendOutcome(outcome) {
486
+ if (!directory)
487
+ ensureSecureHomeDir();
488
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
489
+ // Append-only ledger: one JSON object per line, never rewritten.
490
+ if (!existsSync(outcomesPath))
491
+ writeSecureFile(outcomesPath, "");
492
+ appendFileSync(outcomesPath, `${JSON.stringify(outcome)}\n`, { mode: 0o600 });
493
+ return outcome;
494
+ },
495
+ async listOutcomes() {
496
+ return readOutcomes();
497
+ },
498
+ async allSignals() {
499
+ return listRunsSync().flatMap((run) => run.signals);
500
+ },
501
+ };
502
+ }
503
+ /** Construct a SignalOutcome with a stable id and a recordedAt stamp. */
504
+ export function makeOutcome(input) {
505
+ const recordedAt = input.recordedAt ?? new Date().toISOString();
506
+ const accountDomain = normalizeAccountDomain(input.accountDomain);
507
+ return {
508
+ id: outcomeId({ accountDomain, touchId: input.touchId, result: input.result, recordedAt }),
509
+ accountDomain,
510
+ ...(input.touchId !== undefined ? { touchId: input.touchId } : {}),
511
+ result: input.result,
512
+ recordedAt,
513
+ creditedSignals: input.creditedSignals ?? [],
514
+ };
515
+ }
package/docs/api.md CHANGED
@@ -40,8 +40,8 @@ release.
40
40
  - `GtmConnector` — `{ provider, fetchSnapshot(), applyOperation?, readField?, fetchChanges? }`.
41
41
  - Connectors never silently drop unresolvable records; audits surface them.
42
42
  - `fetchChanges(sinceIso)` returns a partial snapshot; change feeds may omit associations.
43
- - `createHubspotConnector(options)` — read/write/readField/fetchChanges. `applyOperation` implements every `PatchOperationType`: `set_field`, `clear_field`, `link_record`, `create_task`, `create_record` (resolve-first net-new contact/company create — re-checks the dedupe key at apply, never double-creates), `archive_record`, `merge_records` (HubSpot v3 merge — pairwise, irreversible; survivor must belong to the duplicate group). The Salesforce connector skips `merge_records` and `create_record` honestly (platform-specific).
44
- - `createSalesforceConnector(options)` — read/write/readField/fetchChanges; probabilities normalized to 0..1; `applyOperation` implements every operation type.
43
+ - `createHubspotConnector(options)` — read/write/readField/fetchChanges. `applyOperation` implements every `PatchOperationType`: `set_field`, `clear_field`, `link_record`, `create_task`, `create_record` (resolve-first net-new contact/company create — re-checks the dedupe key at apply, never double-creates), `archive_record`, `merge_records` (HubSpot v3 merge — pairwise, irreversible; survivor must belong to the duplicate group). (The Salesforce connector implements the same operation set, with the platform-specific constraints noted below.)
44
+ - `createSalesforceConnector(options)` — read/write/readField/fetchChanges; probabilities normalized to 0..1. `applyOperation` implements every operation type, with two platform constraints: `merge_records` covers **Accounts and Contacts** only (Salesforce exposes no Opportunity merge), and `create_record` resolve-first creates **contacts/accounts** (SOQL search on the match key, create only on a confirmed miss; stamps the canonical `ownerId` onto `OwnerId`).
45
45
  - `createStripeConnector(options)` — read-only billing by design (`applyOperation` returns `skipped`); email domains are the cross-system merge keys. Implements `fetchChanges` (incremental via `created[gte]`).
46
46
 
47
47
  ## Multi-system operations
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.39.0",
3
+ "version": "0.41.0",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",