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/src/connector.ts CHANGED
@@ -27,6 +27,12 @@ export type ApplyPatchPlanOptions = {
27
27
  * `readField`.
28
28
  */
29
29
  checkConflicts?: boolean;
30
+ /**
31
+ * For plans carrying a filter or guards: re-run the snapshot checks after
32
+ * the first applied write and then every N applied writes, so a record
33
+ * edited mid-apply is conflicted out instead of overwritten. Default 25.
34
+ */
35
+ recheckEvery?: number;
30
36
  };
31
37
 
32
38
  const FIELD_WRITE_OPERATIONS = new Set(["set_field", "clear_field", "link_record"]);
@@ -60,6 +66,129 @@ export async function applyPatchPlan(
60
66
  const results: PatchOperationResult[] = [];
61
67
  let attempted = 0;
62
68
  let applied = 0;
69
+ let appliedSinceRecheck = 0;
70
+
71
+ // Pass 0 — snapshot-backed checks: plan-level guards and per-record
72
+ // filter re-verification, both against a FRESH snapshot. Cross-record
73
+ // eligibility ("the account still has no open contractsent deal") cannot
74
+ // be checked through per-operation field reads.
75
+ //
76
+ // These checks are NOT one-shot: a concurrent edit can land mid-apply,
77
+ // after the initial verification but before later writes (the TOCTOU
78
+ // window). Providers offer no compare-and-swap, so the window cannot be
79
+ // closed — but it can be shrunk: re-run the snapshot checks after the
80
+ // first write and every `recheckEvery` writes, conflicting out any
81
+ // operation whose record went stale mid-run.
82
+ const needsSnapshot =
83
+ ((plan.guards && plan.guards.length > 0) || plan.filter) && connector.fetchSnapshot;
84
+ const recheckEvery = Math.max(1, options.recheckEvery ?? 25);
85
+ const staleIds = new Set<string>();
86
+ let guardFailure: string | null = null;
87
+ const refreshSnapshotChecks = async (): Promise<void> => {
88
+ if (!needsSnapshot) return;
89
+ const { evaluateGuard, eligibleIds } = await import("./bulkUpdate.ts");
90
+ const liveSnapshot = await connector.fetchSnapshot!();
91
+ if (plan.filter) {
92
+ const stillEligible = eligibleIds(liveSnapshot, plan.filter.objectType, plan.filter.where);
93
+ staleIds.clear();
94
+ for (const operation of plan.operations) {
95
+ if (!stillEligible.has(operation.objectId)) staleIds.add(operation.objectId);
96
+ }
97
+ }
98
+ for (const guard of plan.guards ?? []) {
99
+ const failure = evaluateGuard(liveSnapshot, guard);
100
+ if (failure) {
101
+ guardFailure = failure;
102
+ return;
103
+ }
104
+ }
105
+ };
106
+ await refreshSnapshotChecks();
107
+ if (guardFailure) {
108
+ for (const operation of plan.operations) {
109
+ results.push({
110
+ operationId: operation.id,
111
+ status: approved.has(operation.id) ? "conflict" : "skipped",
112
+ detail: approved.has(operation.id)
113
+ ? `${guardFailure} No operation in this plan was applied — re-plan against current data.`
114
+ : "Operation was not approved.",
115
+ });
116
+ }
117
+ return {
118
+ planId: plan.id,
119
+ provider: connector.provider,
120
+ startedAt,
121
+ finishedAt: new Date().toISOString(),
122
+ status: "rejected",
123
+ results,
124
+ };
125
+ }
126
+ const staleByFilter: Set<string> | null = plan.filter ? staleIds : null;
127
+
128
+ // Pass 1 — conflict detection. Re-read the written field (beforeValue
129
+ // compare-and-set) and any explicit preconditions. A conflicted operation
130
+ // never writes; if it carries a groupId, the whole group is poisoned so
131
+ // multi-record changes stay all-or-nothing.
132
+ const conflicts = new Map<string, PatchOperationResult>();
133
+ const poisonedGroups = new Set<string>();
134
+ if (staleByFilter && staleByFilter.size > 0) {
135
+ for (const operation of plan.operations) {
136
+ if (!approved.has(operation.id) || !staleByFilter.has(operation.objectId)) continue;
137
+ conflicts.set(operation.id, {
138
+ operationId: operation.id,
139
+ status: "conflict",
140
+ 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.`,
141
+ });
142
+ if (operation.groupId) poisonedGroups.add(operation.groupId);
143
+ }
144
+ }
145
+ if (checkConflicts && connector.readField) {
146
+ for (const operation of plan.operations) {
147
+ if (!approved.has(operation.id) || conflicts.has(operation.id)) continue;
148
+ let conflict: PatchOperationResult | null = null;
149
+ if (operation.field && FIELD_WRITE_OPERATIONS.has(operation.operation)) {
150
+ const current = await connector.readField(
151
+ operation.objectType,
152
+ operation.objectId,
153
+ operation.field,
154
+ );
155
+ const expected = normalizeForComparison(operation.beforeValue);
156
+ const found = normalizeForComparison(current);
157
+ if (expected !== found) {
158
+ conflict = {
159
+ operationId: operation.id,
160
+ status: "conflict",
161
+ detail: `Value drifted since the plan was proposed: expected ${expected ?? "∅"}, found ${found ?? "∅"}. Re-run the audit.`,
162
+ providerData: { currentValue: current ?? null },
163
+ };
164
+ }
165
+ }
166
+ if (!conflict && operation.preconditions) {
167
+ for (const precondition of operation.preconditions) {
168
+ const current = await connector.readField(
169
+ operation.objectType,
170
+ operation.objectId,
171
+ precondition.field,
172
+ );
173
+ const expected = normalizeForComparison(precondition.expectedValue);
174
+ const found = normalizeForComparison(current);
175
+ if (expected !== found) {
176
+ conflict = {
177
+ operationId: operation.id,
178
+ status: "conflict",
179
+ detail: `Precondition failed: ${precondition.field} was ${expected ?? "∅"} when the plan was built, found ${found ?? "∅"} now. The record changed — re-plan before writing.`,
180
+ providerData: { preconditionField: precondition.field, currentValue: current ?? null },
181
+ };
182
+ break;
183
+ }
184
+ }
185
+ }
186
+ if (conflict) {
187
+ conflicts.set(operation.id, conflict);
188
+ if (operation.groupId) poisonedGroups.add(operation.groupId);
189
+ }
190
+ }
191
+ }
63
192
 
64
193
  for (const operation of plan.operations) {
65
194
  if (!approved.has(operation.id)) {
@@ -81,28 +210,35 @@ export async function applyPatchPlan(
81
210
  continue;
82
211
  }
83
212
 
84
- if (
85
- checkConflicts &&
86
- connector.readField &&
87
- operation.field &&
88
- FIELD_WRITE_OPERATIONS.has(operation.operation)
89
- ) {
90
- const current = await connector.readField(
91
- operation.objectType,
92
- operation.objectId,
93
- operation.field,
94
- );
95
- const expected = normalizeForComparison(operation.beforeValue);
96
- const found = normalizeForComparison(current);
97
- if (expected !== found) {
98
- results.push({
99
- operationId: operation.id,
100
- status: "conflict",
101
- detail: `Value drifted since the plan was proposed: expected ${expected ?? "∅"}, found ${found ?? "∅"}. Re-run the audit.`,
102
- providerData: { currentValue: current ?? null },
103
- });
104
- continue;
105
- }
213
+ if (guardFailure) {
214
+ results.push({
215
+ operationId: operation.id,
216
+ status: "conflict",
217
+ detail: `${guardFailure} Detected mid-apply — this and all remaining operations were not applied.`,
218
+ });
219
+ continue;
220
+ }
221
+ const conflict = conflicts.get(operation.id);
222
+ if (conflict) {
223
+ results.push(conflict);
224
+ continue;
225
+ }
226
+ if (staleByFilter && staleByFilter.has(operation.objectId)) {
227
+ results.push({
228
+ operationId: operation.id,
229
+ status: "conflict",
230
+ 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.`,
231
+ });
232
+ if (operation.groupId) poisonedGroups.add(operation.groupId);
233
+ continue;
234
+ }
235
+ if (operation.groupId && poisonedGroups.has(operation.groupId)) {
236
+ results.push({
237
+ operationId: operation.id,
238
+ status: "skipped",
239
+ detail: `Skipped: another operation in group ${operation.groupId} hit a conflict — the group applies all-or-nothing.`,
240
+ });
241
+ continue;
106
242
  }
107
243
 
108
244
  const resolved: PatchOperation =
@@ -112,7 +248,17 @@ export async function applyPatchPlan(
112
248
  try {
113
249
  const result = await connector.applyOperation(resolved);
114
250
  results.push(result);
115
- if (result.status === "applied") applied += 1;
251
+ if (result.status === "applied") {
252
+ applied += 1;
253
+ appliedSinceRecheck += 1;
254
+ // shrink the TOCTOU window: first write fires a re-check (a
255
+ // concurrent editor reacting to our changes shows up immediately),
256
+ // then re-check on a fixed cadence
257
+ if (applied === 1 || appliedSinceRecheck >= recheckEvery) {
258
+ appliedSinceRecheck = 0;
259
+ await refreshSnapshotChecks();
260
+ }
261
+ }
116
262
  } catch (error) {
117
263
  results.push({
118
264
  operationId: operation.id,
package/src/index.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 {
3
4
  CONFIG_FILE_NAME,
4
5
  loadConfig,
@@ -118,6 +119,7 @@ export {
118
119
  DEFAULT_RUBRIC,
119
120
  detectProviderFromKey,
120
121
  extractInsightsLlm,
122
+ forcedToolCall,
121
123
  parseRubric,
122
124
  resolveLlmCredential,
123
125
  scoreCallLlm,
package/src/market.ts CHANGED
@@ -172,6 +172,9 @@ export function parseMarketConfig(raw: string): MarketConfig {
172
172
  if (!axis.id) throw new Error("market config: axis missing id");
173
173
  if (axisIds.has(axis.id)) throw new Error(`market config: duplicate axis id "${axis.id}"`);
174
174
  axisIds.add(axis.id);
175
+ if (!axis.negativePole || !axis.positivePole) {
176
+ throw new Error(`market config: axis "${axis.id}" needs negativePole and positivePole labels (the strategic map renders them as axis ends)`);
177
+ }
175
178
  for (const claimId of Object.keys(axis.claimScores ?? {})) {
176
179
  if (!claimIds.has(claimId)) {
177
180
  throw new Error(`market config: axis "${axis.id}" scores unknown claim "${claimId}"`);
package/src/mcp.ts CHANGED
@@ -335,7 +335,7 @@ export async function startMcpServer() {
335
335
  },
336
336
  },
337
337
  async ({ vendorId, configPath, captureRun }) => {
338
- const config = loadMarketConfig(resolve(process.cwd(), configPath ?? "market.config.json"));
338
+ const config = loadMarketConfigOrHint(resolve(process.cwd(), configPath ?? "market.config.json"));
339
339
  return content(buildWorksheet(config, vendorId, { captureRun }));
340
340
  },
341
341
  );
@@ -355,7 +355,7 @@ export async function startMcpServer() {
355
355
  },
356
356
  },
357
357
  async ({ observationsPath, configPath }) => {
358
- const config = loadMarketConfig(resolve(process.cwd(), configPath ?? "market.config.json"));
358
+ const config = loadMarketConfigOrHint(resolve(process.cwd(), configPath ?? "market.config.json"));
359
359
  const set = JSON.parse(readFileSync(resolve(process.cwd(), observationsPath), "utf8")) as ObservationSet;
360
360
  const problems = validateObservationSet(config, set);
361
361
  const failures = verifyEvidenceSpans(set.observations, loadCaptureTexts(config.category).textByHash);
@@ -375,3 +375,16 @@ export async function startMcpServer() {
375
375
  const transport = new StdioServerTransport();
376
376
  await server.connect(transport);
377
377
  }
378
+
379
+ function loadMarketConfigOrHint(path: string): ReturnType<typeof loadMarketConfig> {
380
+ try {
381
+ return loadMarketConfig(path);
382
+ } catch (error) {
383
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
384
+ throw new Error(
385
+ `No market config at ${path} — run \`fullstackgtm market init --category <name>\` in that directory first, or pass configPath.`,
386
+ );
387
+ }
388
+ throw error;
389
+ }
390
+ }
package/src/types.ts CHANGED
@@ -289,6 +289,20 @@ export type PatchOperation = {
289
289
  evidenceIds?: string[];
290
290
  findingIds?: string[];
291
291
  verification?: PatchVerification;
292
+ /**
293
+ * Compare-and-set guards beyond the written field: each precondition is
294
+ * re-read at apply time and a mismatch turns the operation into a
295
+ * conflict instead of a write. Guards against a record drifting on a
296
+ * DIFFERENT field than the one being written (e.g. stage changed while
297
+ * an owner write was pending).
298
+ */
299
+ preconditions?: Array<{ field: string; expectedValue: unknown }>;
300
+ /**
301
+ * Operations sharing a groupId are all-or-nothing at apply time: a
302
+ * conflict (beforeValue or precondition) on any member skips every
303
+ * member of the group.
304
+ */
305
+ groupId?: string;
292
306
  };
293
307
 
294
308
  /**
@@ -306,6 +320,31 @@ export type PatchPlan = {
306
320
  pipelineFindings?: PipelineFinding[];
307
321
  evidence?: GtmEvidence[];
308
322
  operations: PatchOperation[];
323
+ /**
324
+ * The filter this plan's operations were selected by. Re-evaluated per
325
+ * record against a FRESH snapshot at apply time: any operation whose
326
+ * record no longer matches is reported as a conflict instead of applied.
327
+ * Unlike per-operation preconditions, this enforces the FULL filter —
328
+ * negations and relational pseudo-fields included.
329
+ */
330
+ filter?: { objectType: "account" | "contact" | "deal"; where: string[] };
331
+ /**
332
+ * Plan-level guards re-evaluated against a FRESH snapshot at apply time.
333
+ * If any guard fails, NO operation in the plan is applied. This is how a
334
+ * plan expresses cross-record eligibility ("apply only while the account
335
+ * still has no open deal in contractsent") that per-operation
336
+ * preconditions cannot reach.
337
+ */
338
+ guards?: PlanGuard[];
339
+ };
340
+
341
+ export type PlanGuard = {
342
+ objectType: "account" | "contact" | "deal";
343
+ /** filter expressions in bulk-update --where grammar, AND-ed */
344
+ where: string[];
345
+ /** none: guard passes when ZERO records match; some: when at least one matches */
346
+ expect: "none" | "some";
347
+ description?: string;
309
348
  };
310
349
 
311
350
  // ── Audit rule engine ──────────────────────────────────────────