fullstackgtm 0.18.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/dist/connector.js CHANGED
@@ -23,6 +23,124 @@ export async function applyPatchPlan(connector, plan, options) {
23
23
  const results = [];
24
24
  let attempted = 0;
25
25
  let applied = 0;
26
+ let appliedSinceRecheck = 0;
27
+ // Pass 0 — snapshot-backed checks: plan-level guards and per-record
28
+ // filter re-verification, both against a FRESH snapshot. Cross-record
29
+ // eligibility ("the account still has no open contractsent deal") cannot
30
+ // be checked through per-operation field reads.
31
+ //
32
+ // These checks are NOT one-shot: a concurrent edit can land mid-apply,
33
+ // after the initial verification but before later writes (the TOCTOU
34
+ // window). Providers offer no compare-and-swap, so the window cannot be
35
+ // closed — but it can be shrunk: re-run the snapshot checks after the
36
+ // first write and every `recheckEvery` writes, conflicting out any
37
+ // operation whose record went stale mid-run.
38
+ const needsSnapshot = ((plan.guards && plan.guards.length > 0) || plan.filter) && connector.fetchSnapshot;
39
+ const recheckEvery = Math.max(1, options.recheckEvery ?? 25);
40
+ const staleIds = new Set();
41
+ let guardFailure = null;
42
+ const refreshSnapshotChecks = async () => {
43
+ if (!needsSnapshot)
44
+ return;
45
+ const { evaluateGuard, eligibleIds } = await import("./bulkUpdate.js");
46
+ const liveSnapshot = await connector.fetchSnapshot();
47
+ if (plan.filter) {
48
+ const stillEligible = eligibleIds(liveSnapshot, plan.filter.objectType, plan.filter.where);
49
+ staleIds.clear();
50
+ for (const operation of plan.operations) {
51
+ if (!stillEligible.has(operation.objectId))
52
+ staleIds.add(operation.objectId);
53
+ }
54
+ }
55
+ for (const guard of plan.guards ?? []) {
56
+ const failure = evaluateGuard(liveSnapshot, guard);
57
+ if (failure) {
58
+ guardFailure = failure;
59
+ return;
60
+ }
61
+ }
62
+ };
63
+ await refreshSnapshotChecks();
64
+ if (guardFailure) {
65
+ for (const operation of plan.operations) {
66
+ results.push({
67
+ operationId: operation.id,
68
+ status: approved.has(operation.id) ? "conflict" : "skipped",
69
+ detail: approved.has(operation.id)
70
+ ? `${guardFailure} No operation in this plan was applied — re-plan against current data.`
71
+ : "Operation was not approved.",
72
+ });
73
+ }
74
+ return {
75
+ planId: plan.id,
76
+ provider: connector.provider,
77
+ startedAt,
78
+ finishedAt: new Date().toISOString(),
79
+ status: "rejected",
80
+ results,
81
+ };
82
+ }
83
+ const staleByFilter = plan.filter ? staleIds : null;
84
+ // Pass 1 — conflict detection. Re-read the written field (beforeValue
85
+ // compare-and-set) and any explicit preconditions. A conflicted operation
86
+ // never writes; if it carries a groupId, the whole group is poisoned so
87
+ // multi-record changes stay all-or-nothing.
88
+ const conflicts = new Map();
89
+ const poisonedGroups = new Set();
90
+ if (staleByFilter && staleByFilter.size > 0) {
91
+ for (const operation of plan.operations) {
92
+ if (!approved.has(operation.id) || !staleByFilter.has(operation.objectId))
93
+ continue;
94
+ conflicts.set(operation.id, {
95
+ operationId: operation.id,
96
+ status: "conflict",
97
+ detail: `Record ${operation.objectType}/${operation.objectId} no longer matches the plan's filter [${plan.filter.where.join(" AND ")}] — it changed since the plan was built. Re-plan against current data.`,
98
+ });
99
+ if (operation.groupId)
100
+ poisonedGroups.add(operation.groupId);
101
+ }
102
+ }
103
+ if (checkConflicts && connector.readField) {
104
+ for (const operation of plan.operations) {
105
+ if (!approved.has(operation.id) || conflicts.has(operation.id))
106
+ continue;
107
+ let conflict = null;
108
+ if (operation.field && FIELD_WRITE_OPERATIONS.has(operation.operation)) {
109
+ const current = await connector.readField(operation.objectType, operation.objectId, operation.field);
110
+ const expected = normalizeForComparison(operation.beforeValue);
111
+ const found = normalizeForComparison(current);
112
+ if (expected !== found) {
113
+ conflict = {
114
+ operationId: operation.id,
115
+ status: "conflict",
116
+ detail: `Value drifted since the plan was proposed: expected ${expected ?? "∅"}, found ${found ?? "∅"}. Re-run the audit.`,
117
+ providerData: { currentValue: current ?? null },
118
+ };
119
+ }
120
+ }
121
+ if (!conflict && operation.preconditions) {
122
+ for (const precondition of operation.preconditions) {
123
+ const current = await connector.readField(operation.objectType, operation.objectId, precondition.field);
124
+ const expected = normalizeForComparison(precondition.expectedValue);
125
+ const found = normalizeForComparison(current);
126
+ if (expected !== found) {
127
+ conflict = {
128
+ operationId: operation.id,
129
+ status: "conflict",
130
+ detail: `Precondition failed: ${precondition.field} was ${expected ?? "∅"} when the plan was built, found ${found ?? "∅"} now. The record changed — re-plan before writing.`,
131
+ providerData: { preconditionField: precondition.field, currentValue: current ?? null },
132
+ };
133
+ break;
134
+ }
135
+ }
136
+ }
137
+ if (conflict) {
138
+ conflicts.set(operation.id, conflict);
139
+ if (operation.groupId)
140
+ poisonedGroups.add(operation.groupId);
141
+ }
142
+ }
143
+ }
26
144
  for (const operation of plan.operations) {
27
145
  if (!approved.has(operation.id)) {
28
146
  results.push({
@@ -41,30 +159,53 @@ export async function applyPatchPlan(connector, plan, options) {
41
159
  });
42
160
  continue;
43
161
  }
44
- if (checkConflicts &&
45
- connector.readField &&
46
- operation.field &&
47
- FIELD_WRITE_OPERATIONS.has(operation.operation)) {
48
- const current = await connector.readField(operation.objectType, operation.objectId, operation.field);
49
- const expected = normalizeForComparison(operation.beforeValue);
50
- const found = normalizeForComparison(current);
51
- if (expected !== found) {
52
- results.push({
53
- operationId: operation.id,
54
- status: "conflict",
55
- detail: `Value drifted since the plan was proposed: expected ${expected ?? "∅"}, found ${found ?? "∅"}. Re-run the audit.`,
56
- providerData: { currentValue: current ?? null },
57
- });
58
- continue;
59
- }
162
+ if (guardFailure) {
163
+ results.push({
164
+ operationId: operation.id,
165
+ status: "conflict",
166
+ detail: `${guardFailure} Detected mid-apply this and all remaining operations were not applied.`,
167
+ });
168
+ continue;
169
+ }
170
+ const conflict = conflicts.get(operation.id);
171
+ if (conflict) {
172
+ results.push(conflict);
173
+ continue;
174
+ }
175
+ if (staleByFilter && staleByFilter.has(operation.objectId)) {
176
+ results.push({
177
+ operationId: operation.id,
178
+ status: "conflict",
179
+ detail: `Record ${operation.objectType}/${operation.objectId} no longer matches the plan's filter [${plan.filter.where.join(" AND ")}] (changed mid-apply). Not applied — re-plan against current data.`,
180
+ });
181
+ if (operation.groupId)
182
+ poisonedGroups.add(operation.groupId);
183
+ continue;
184
+ }
185
+ if (operation.groupId && poisonedGroups.has(operation.groupId)) {
186
+ results.push({
187
+ operationId: operation.id,
188
+ status: "skipped",
189
+ detail: `Skipped: another operation in group ${operation.groupId} hit a conflict — the group applies all-or-nothing.`,
190
+ });
191
+ continue;
60
192
  }
61
193
  const resolved = override === undefined ? operation : { ...operation, afterValue: override };
62
194
  attempted += 1;
63
195
  try {
64
196
  const result = await connector.applyOperation(resolved);
65
197
  results.push(result);
66
- if (result.status === "applied")
198
+ if (result.status === "applied") {
67
199
  applied += 1;
200
+ appliedSinceRecheck += 1;
201
+ // shrink the TOCTOU window: first write fires a re-check (a
202
+ // concurrent editor reacting to our changes shows up immediately),
203
+ // then re-check on a fixed cadence
204
+ if (applied === 1 || appliedSinceRecheck >= recheckEvery) {
205
+ appliedSinceRecheck = 0;
206
+ await refreshSnapshotChecks();
207
+ }
208
+ }
68
209
  }
69
210
  catch (error) {
70
211
  results.push({
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { auditSnapshot, defaultPolicy } from "./audit.ts";
2
+ export { buildBulkUpdatePlan, parseWhere, type BulkUpdateOptions } from "./bulkUpdate.ts";
2
3
  export { CONFIG_FILE_NAME, loadConfig, mergePolicy, resolveConfiguredRules, type FullstackgtmConfig, type LoadedConfig, } from "./config.ts";
3
4
  export { applyPatchPlan, type ApplyPatchPlanOptions } from "./connector.ts";
4
5
  export { createHubspotConnector, type HubspotConnectorOptions } from "./connectors/hubspot.ts";
@@ -17,7 +18,7 @@ export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mapp
17
18
  export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, provenanceSummary, requiresHumanInput, staleDealRule, } from "./rules.ts";
18
19
  export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, type CallDealSuggestion, type CallInsightType, type ExtractedCallInsight, type ParsedCall, type ParsedTranscriptSegment, } from "./calls.ts";
19
20
  export { sampleSnapshot } from "./sampleData.ts";
20
- export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, type CallScorecard, type LlmCredential, type LlmExtractedInsight, type LlmProvider, type Rubric, type ScoredDimension, } from "./llm.ts";
21
+ export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, forcedToolCall, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, type CallScorecard, type LlmCredential, type LlmExtractedInsight, type LlmProvider, type Rubric, type ScoredDimension, } from "./llm.ts";
21
22
  export { resolveRecord, type ResolveCandidate, type ResolveMatch, type ResolveResult } from "./resolve.ts";
22
23
  export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadCaptureTexts, loadMarketConfig, marketHome, normalizeForMatch, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, type CaptureEntry, type CaptureOptions, type ClaimFront, type ClaimIntensity, type FrontDrift, type FrontState, type MarketAxis, type MarketClaim, type MarketConfig, type MarketObservation, type MarketVendor, type ObservationConfidence, type ObservationSet, type ObservationStore, type SpanVerificationFailure, } from "./market.ts";
23
24
  export { assessAxes, axesReportToText, axisPosition, messageBreadth, pcaTop2, pearson, type AxesReport, type AxisAssessment, type AxisPairing, type PrincipalComponent, } from "./marketAxes.ts";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { auditSnapshot, defaultPolicy } from "./audit.js";
2
+ export { buildBulkUpdatePlan, parseWhere } from "./bulkUpdate.js";
2
3
  export { CONFIG_FILE_NAME, loadConfig, mergePolicy, resolveConfiguredRules, } from "./config.js";
3
4
  export { applyPatchPlan } from "./connector.js";
4
5
  export { createHubspotConnector } from "./connectors/hubspot.js";
@@ -17,7 +18,7 @@ export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mapp
17
18
  export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, provenanceSummary, requiresHumanInput, staleDealRule, } from "./rules.js";
18
19
  export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, } from "./calls.js";
19
20
  export { sampleSnapshot } from "./sampleData.js";
20
- export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
21
+ export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, forcedToolCall, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
21
22
  export { resolveRecord } from "./resolve.js";
22
23
  export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadCaptureTexts, loadMarketConfig, marketHome, normalizeForMatch, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, } from "./market.js";
23
24
  export { assessAxes, axesReportToText, axisPosition, messageBreadth, pcaTop2, pearson, } from "./marketAxes.js";
package/dist/market.js CHANGED
@@ -58,6 +58,9 @@ export function parseMarketConfig(raw) {
58
58
  if (axisIds.has(axis.id))
59
59
  throw new Error(`market config: duplicate axis id "${axis.id}"`);
60
60
  axisIds.add(axis.id);
61
+ if (!axis.negativePole || !axis.positivePole) {
62
+ throw new Error(`market config: axis "${axis.id}" needs negativePole and positivePole labels (the strategic map renders them as axis ends)`);
63
+ }
61
64
  for (const claimId of Object.keys(axis.claimScores ?? {})) {
62
65
  if (!claimIds.has(claimId)) {
63
66
  throw new Error(`market config: axis "${axis.id}" scores unknown claim "${claimId}"`);
package/dist/mcp.js CHANGED
@@ -260,7 +260,7 @@ export async function startMcpServer() {
260
260
  captureRun: z.string().optional(),
261
261
  },
262
262
  }, async ({ vendorId, configPath, captureRun }) => {
263
- const config = loadMarketConfig(resolve(process.cwd(), configPath ?? "market.config.json"));
263
+ const config = loadMarketConfigOrHint(resolve(process.cwd(), configPath ?? "market.config.json"));
264
264
  return content(buildWorksheet(config, vendorId, { captureRun }));
265
265
  });
266
266
  server.registerTool("fullstackgtm_market_observe", {
@@ -274,7 +274,7 @@ export async function startMcpServer() {
274
274
  configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
275
275
  },
276
276
  }, async ({ observationsPath, configPath }) => {
277
- const config = loadMarketConfig(resolve(process.cwd(), configPath ?? "market.config.json"));
277
+ const config = loadMarketConfigOrHint(resolve(process.cwd(), configPath ?? "market.config.json"));
278
278
  const set = JSON.parse(readFileSync(resolve(process.cwd(), observationsPath), "utf8"));
279
279
  const problems = validateObservationSet(config, set);
280
280
  const failures = verifyEvidenceSpans(set.observations, loadCaptureTexts(config.category).textByHash);
@@ -292,3 +292,14 @@ export async function startMcpServer() {
292
292
  const transport = new StdioServerTransport();
293
293
  await server.connect(transport);
294
294
  }
295
+ function loadMarketConfigOrHint(path) {
296
+ try {
297
+ return loadMarketConfig(path);
298
+ }
299
+ catch (error) {
300
+ if (error.code === "ENOENT") {
301
+ throw new Error(`No market config at ${path} — run \`fullstackgtm market init --category <name>\` in that directory first, or pass configPath.`);
302
+ }
303
+ throw error;
304
+ }
305
+ }
package/dist/types.d.ts CHANGED
@@ -222,6 +222,23 @@ export type PatchOperation = {
222
222
  evidenceIds?: string[];
223
223
  findingIds?: string[];
224
224
  verification?: PatchVerification;
225
+ /**
226
+ * Compare-and-set guards beyond the written field: each precondition is
227
+ * re-read at apply time and a mismatch turns the operation into a
228
+ * conflict instead of a write. Guards against a record drifting on a
229
+ * DIFFERENT field than the one being written (e.g. stage changed while
230
+ * an owner write was pending).
231
+ */
232
+ preconditions?: Array<{
233
+ field: string;
234
+ expectedValue: unknown;
235
+ }>;
236
+ /**
237
+ * Operations sharing a groupId are all-or-nothing at apply time: a
238
+ * conflict (beforeValue or precondition) on any member skips every
239
+ * member of the group.
240
+ */
241
+ groupId?: string;
225
242
  };
226
243
  /**
227
244
  * A patch plan is always a dry-run proposal. Applying a plan never mutates
@@ -238,6 +255,33 @@ export type PatchPlan = {
238
255
  pipelineFindings?: PipelineFinding[];
239
256
  evidence?: GtmEvidence[];
240
257
  operations: PatchOperation[];
258
+ /**
259
+ * The filter this plan's operations were selected by. Re-evaluated per
260
+ * record against a FRESH snapshot at apply time: any operation whose
261
+ * record no longer matches is reported as a conflict instead of applied.
262
+ * Unlike per-operation preconditions, this enforces the FULL filter —
263
+ * negations and relational pseudo-fields included.
264
+ */
265
+ filter?: {
266
+ objectType: "account" | "contact" | "deal";
267
+ where: string[];
268
+ };
269
+ /**
270
+ * Plan-level guards re-evaluated against a FRESH snapshot at apply time.
271
+ * If any guard fails, NO operation in the plan is applied. This is how a
272
+ * plan expresses cross-record eligibility ("apply only while the account
273
+ * still has no open deal in contractsent") that per-operation
274
+ * preconditions cannot reach.
275
+ */
276
+ guards?: PlanGuard[];
277
+ };
278
+ export type PlanGuard = {
279
+ objectType: "account" | "contact" | "deal";
280
+ /** filter expressions in bulk-update --where grammar, AND-ed */
281
+ where: string[];
282
+ /** none: guard passes when ZERO records match; some: when at least one matches */
283
+ expect: "none" | "some";
284
+ description?: string;
241
285
  };
242
286
  /** Pre-computed lookups shared by all rules so each rule stays O(n). */
243
287
  export type GtmSnapshotIndex = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.18.0",
3
+ "version": "0.19.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",