fullstackgtm 0.18.0 → 0.20.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,
@@ -159,6 +161,7 @@ export {
159
161
  type ObservationConfidence,
160
162
  type ObservationSet,
161
163
  type ObservationStore,
164
+ type ScaleSignal,
162
165
  type SpanVerificationFailure,
163
166
  } from "./market.ts";
164
167
  export {
@@ -180,6 +183,21 @@ export {
180
183
  type ClassifyMarketResult,
181
184
  type MarketWorksheet,
182
185
  } from "./marketClassify.ts";
186
+ export {
187
+ computeDirectives,
188
+ computeOverlayStats,
189
+ directivesToPlan,
190
+ overlayToMarkdown,
191
+ type CallDocument,
192
+ type ClaimMentionStats,
193
+ type DirectiveStat,
194
+ type DirectiveType,
195
+ type MarketDirective,
196
+ type OverlayOptions,
197
+ type OverlayStats,
198
+ type VendorMentionStats,
199
+ } from "./marketOverlay.ts";
200
+ export { computeScaleIndex, scaleReportToText, type ScaleReport, type VendorScale } from "./marketScale.ts";
183
201
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
184
202
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
185
203
  export type {
package/src/market.ts CHANGED
@@ -38,6 +38,31 @@ export type MarketClaim = {
38
38
  pricingStructure: string;
39
39
  /** Operational definition: how a reader judges LOUD vs QUIET vs ABSENT. */
40
40
  definition: string;
41
+ /**
42
+ * Exact terms buyers use for this claim, for deterministic mention
43
+ * matching against call transcripts (the overlay). No terms = no mention
44
+ * stats for this claim; matching is word-boundary, case-insensitive.
45
+ */
46
+ terms?: string[];
47
+ };
48
+
49
+ /**
50
+ * One public, citable scale signal for a vendor (G2 review count, LinkedIn
51
+ * headcount, disclosed revenue, self-reported customer count). The composite
52
+ * of several biased-in-different-directions signals sizes the report's
53
+ * bubbles — a RELATIVE scale index within the mapped set, never "market
54
+ * share" unqualified.
55
+ */
56
+ export type ScaleSignal = {
57
+ /** e.g. "g2_reviews", "linkedin_employees", "revenue_usd", "self_reported_customers". */
58
+ metric: string;
59
+ value: number;
60
+ unit: string;
61
+ sourceUrl: string;
62
+ /** Verbatim snippet containing the number — same evidence posture as observations. */
63
+ quote: string;
64
+ asOf: string;
65
+ caveat?: string;
41
66
  };
42
67
 
43
68
  export type MarketVendor = {
@@ -49,6 +74,10 @@ export type MarketVendor = {
49
74
  pricing: string | null;
50
75
  product: string[];
51
76
  };
77
+ /** Alternate names/spellings for deterministic mention matching. */
78
+ aliases?: string[];
79
+ /** Public scale signals; see ScaleSignal. */
80
+ scaleSignals?: ScaleSignal[];
52
81
  notes?: string;
53
82
  };
54
83
 
@@ -172,6 +201,9 @@ export function parseMarketConfig(raw: string): MarketConfig {
172
201
  if (!axis.id) throw new Error("market config: axis missing id");
173
202
  if (axisIds.has(axis.id)) throw new Error(`market config: duplicate axis id "${axis.id}"`);
174
203
  axisIds.add(axis.id);
204
+ if (!axis.negativePole || !axis.positivePole) {
205
+ throw new Error(`market config: axis "${axis.id}" needs negativePole and positivePole labels (the strategic map renders them as axis ends)`);
206
+ }
175
207
  for (const claimId of Object.keys(axis.claimScores ?? {})) {
176
208
  if (!claimIds.has(claimId)) {
177
209
  throw new Error(`market config: axis "${axis.id}" scores unknown claim "${claimId}"`);