fullstackgtm 0.17.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/CHANGELOG.md +72 -0
- package/INSTALL_FOR_AGENTS.md +10 -5
- package/README.md +17 -0
- package/dist/bulkUpdate.d.ts +37 -0
- package/dist/bulkUpdate.js +315 -0
- package/dist/cli.js +93 -2
- package/dist/connector.d.ts +6 -0
- package/dist/connector.js +158 -17
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/market.d.ts +16 -0
- package/dist/market.js +27 -0
- package/dist/marketAxes.d.ts +77 -0
- package/dist/marketAxes.js +199 -0
- package/dist/marketReport.js +114 -1
- package/dist/mcp.js +13 -2
- package/dist/types.d.ts +44 -0
- package/docs/api.md +29 -2
- package/llms.txt +16 -0
- package/package.json +1 -1
- package/src/bulkUpdate.ts +375 -0
- package/src/cli.ts +97 -2
- package/src/connector.ts +169 -23
- package/src/index.ts +15 -0
- package/src/market.ts +41 -0
- package/src/marketAxes.ts +268 -0
- package/src/marketReport.ts +134 -1
- package/src/mcp.ts +15 -2
- package/src/types.ts +39 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Governed generic writes: `bulk-update` builds a dry-run patch plan from a
|
|
3
|
+
* filter over the canonical snapshot plus an action (field assignments, an
|
|
4
|
+
* archive directive, or a task to create). It NEVER writes — the plan flows
|
|
5
|
+
* through the same plans-approve → apply gate as audit plans.
|
|
6
|
+
*
|
|
7
|
+
* Safety model, in layers:
|
|
8
|
+
* - every set_field captures the record's live value as `beforeValue`
|
|
9
|
+
* (apply refuses the write if the written field drifted);
|
|
10
|
+
* - every equality filter on a real record field becomes an automatic
|
|
11
|
+
* PRECONDITION, re-verified at apply time (the plan's premise must still
|
|
12
|
+
* hold — guards against drift on fields other than the one written);
|
|
13
|
+
* - `--require field=value` adds explicit preconditions on top;
|
|
14
|
+
* - all operations for one record share a groupId, so multi-field updates
|
|
15
|
+
* are all-or-nothing per record.
|
|
16
|
+
*
|
|
17
|
+
* Filter grammar (each --where is AND-ed):
|
|
18
|
+
* field=value case-insensitive equality
|
|
19
|
+
* field!=value case-insensitive inequality
|
|
20
|
+
* field~value case-insensitive substring
|
|
21
|
+
* field:empty unset or empty string
|
|
22
|
+
* field:notempty set and non-empty
|
|
23
|
+
*
|
|
24
|
+
* Fields are canonical (ownerId, stage, closeDate, amount, domain, name,
|
|
25
|
+
* email, isClosed, accountId, …). Relational pseudo-fields are available in
|
|
26
|
+
* filters: deals and contacts get `account.name`, `account.domain`,
|
|
27
|
+
* `account.ownerId`, `account.contactCount`; accounts get `contactCount`
|
|
28
|
+
* and `openDealCount`.
|
|
29
|
+
*/
|
|
30
|
+
import { stableHash } from "./rules.ts";
|
|
31
|
+
import type {
|
|
32
|
+
CanonicalGtmSnapshot,
|
|
33
|
+
GtmObjectType,
|
|
34
|
+
PatchOperation,
|
|
35
|
+
PatchPlan,
|
|
36
|
+
PlanGuard,
|
|
37
|
+
} from "./types.ts";
|
|
38
|
+
|
|
39
|
+
export type BulkUpdateOptions = {
|
|
40
|
+
objectType: "account" | "contact" | "deal";
|
|
41
|
+
/** raw --where expressions, AND-ed together; at least one is required */
|
|
42
|
+
where: string[];
|
|
43
|
+
/** canonical field → new value; one action only */
|
|
44
|
+
set?: Record<string, string>;
|
|
45
|
+
/** propose archive_record instead of field writes */
|
|
46
|
+
archive?: boolean;
|
|
47
|
+
/** propose create_task on each matched record with this subject/body text */
|
|
48
|
+
createTask?: string;
|
|
49
|
+
/** explicit preconditions (field=value), re-verified at apply time */
|
|
50
|
+
require?: string[];
|
|
51
|
+
/**
|
|
52
|
+
* plan-level guards, raw form "<objectType>:<where>[;<where>…]:<none|some>",
|
|
53
|
+
* re-evaluated against a fresh snapshot at apply time; failure aborts the
|
|
54
|
+
* entire plan
|
|
55
|
+
*/
|
|
56
|
+
guard?: string[];
|
|
57
|
+
reason?: string;
|
|
58
|
+
/** refuse to build plans larger than this (default 500 operations) */
|
|
59
|
+
maxOperations?: number;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type WhereClause = {
|
|
63
|
+
field: string;
|
|
64
|
+
op: "eq" | "neq" | "contains" | "notcontains" | "empty" | "notempty";
|
|
65
|
+
value?: string;
|
|
66
|
+
raw: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const FIELD_PATTERN = "[a-zA-Z][a-zA-Z0-9_]*(?:\\.[a-zA-Z][a-zA-Z0-9_]*)?";
|
|
70
|
+
|
|
71
|
+
export function parseWhere(expr: string): WhereClause {
|
|
72
|
+
const empty = expr.match(new RegExp(`^(${FIELD_PATTERN}):(empty|notempty)$`));
|
|
73
|
+
if (empty) return { field: empty[1], op: empty[2] as "empty" | "notempty", raw: expr };
|
|
74
|
+
const neq = expr.match(new RegExp(`^(${FIELD_PATTERN})!=(.*)$`));
|
|
75
|
+
if (neq) return { field: neq[1], op: "neq", value: neq[2], raw: expr };
|
|
76
|
+
const notContains = expr.match(new RegExp(`^(${FIELD_PATTERN})!~(.*)$`));
|
|
77
|
+
if (notContains) return { field: notContains[1], op: "notcontains", value: notContains[2], raw: expr };
|
|
78
|
+
const contains = expr.match(new RegExp(`^(${FIELD_PATTERN})~(.*)$`));
|
|
79
|
+
if (contains) return { field: contains[1], op: "contains", value: contains[2], raw: expr };
|
|
80
|
+
const eq = expr.match(new RegExp(`^(${FIELD_PATTERN})=(.*)$`));
|
|
81
|
+
if (eq) return { field: eq[1], op: "eq", value: eq[2], raw: expr };
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Cannot parse --where "${expr}". Use field=value, field!=value, field~substring, field!~substring, field:empty, or field:notempty.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function fieldValue(view: Record<string, unknown>, field: string): string {
|
|
88
|
+
const value = view[field];
|
|
89
|
+
if (value === undefined || value === null) return "";
|
|
90
|
+
return String(value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function matches(view: Record<string, unknown>, clause: WhereClause): boolean {
|
|
94
|
+
const actual = fieldValue(view, clause.field).toLowerCase();
|
|
95
|
+
// `|` alternation: eq/contains match ANY alternative; neq/notcontains
|
|
96
|
+
// must hold against ALL alternatives.
|
|
97
|
+
const alternatives = (clause.value ?? "").toLowerCase().split("|");
|
|
98
|
+
switch (clause.op) {
|
|
99
|
+
case "eq":
|
|
100
|
+
return alternatives.some((a) => actual === a);
|
|
101
|
+
case "neq":
|
|
102
|
+
return alternatives.every((a) => actual !== a);
|
|
103
|
+
case "contains":
|
|
104
|
+
return alternatives.some((a) => actual.includes(a));
|
|
105
|
+
case "notcontains":
|
|
106
|
+
return alternatives.every((a) => !actual.includes(a));
|
|
107
|
+
case "empty":
|
|
108
|
+
return actual === "";
|
|
109
|
+
case "notempty":
|
|
110
|
+
return actual !== "";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const COLLECTIONS: Record<BulkUpdateOptions["objectType"], keyof CanonicalGtmSnapshot> = {
|
|
115
|
+
account: "accounts",
|
|
116
|
+
contact: "contacts",
|
|
117
|
+
deal: "deals",
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const RELATIONAL_FIELDS = ["account.name", "account.domain", "account.ownerId", "account.contactCount", "account.openDealStages"];
|
|
121
|
+
/**
|
|
122
|
+
* Filterable fields per object type. Filters/requires/guards referencing any
|
|
123
|
+
* other field are rejected at plan time: a typo'd field silently evaluating
|
|
124
|
+
* to empty would make a ":none" guard pass vacuously — a safety assertion
|
|
125
|
+
* that never fires. Strictness turns typos into immediate, correctable
|
|
126
|
+
* errors.
|
|
127
|
+
*/
|
|
128
|
+
const VALID_FIELDS: Record<BulkUpdateOptions["objectType"], Set<string>> = {
|
|
129
|
+
account: new Set(["id", "crmId", "name", "domain", "industry", "ownerId", "employeeCount", "annualRevenue", "lastActivityAt", "lastSyncAt", "contactCount", "openDealCount", "openDealStages"]),
|
|
130
|
+
contact: new Set(["id", "crmId", "accountId", "firstName", "lastName", "email", "phone", "title", "ownerId", "lastSyncAt", ...RELATIONAL_FIELDS]),
|
|
131
|
+
deal: new Set(["id", "crmId", "accountId", "ownerId", "name", "amount", "currency", "stage", "closeDate", "dealType", "forecastCategory", "nextStep", "probability", "isClosed", "isWon", "lastActivityAt", "lastSyncAt", ...RELATIONAL_FIELDS]),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
function assertValidFields(objectType: BulkUpdateOptions["objectType"], clauses: WhereClause[], context: string): void {
|
|
135
|
+
for (const clause of clauses) {
|
|
136
|
+
if (!VALID_FIELDS[objectType].has(clause.field)) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Unknown field "${clause.field}" in ${context} "${clause.raw}" for ${objectType}s. Valid fields: ${[...VALID_FIELDS[objectType]].join(", ")}.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Fields that are derived in the canonical model (no provider property to
|
|
146
|
+
* re-read at apply time) or relational — excluded from auto-preconditions.
|
|
147
|
+
*/
|
|
148
|
+
const NON_READABLE_FIELDS = new Set([
|
|
149
|
+
// derived in the canonical model — no provider property to re-read
|
|
150
|
+
"isClosed", "isWon", "forecastCategory", "probability", "lastActivityAt", "lastSyncAt",
|
|
151
|
+
// identity/bookkeeping fields — preconditions on these are meaningless
|
|
152
|
+
"id", "crmId", "provider", "identities",
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
/** Build the filter-evaluation view: record fields + relational pseudo-fields. */
|
|
156
|
+
function buildViews(
|
|
157
|
+
snapshot: CanonicalGtmSnapshot,
|
|
158
|
+
objectType: BulkUpdateOptions["objectType"],
|
|
159
|
+
): Array<{ record: Record<string, unknown>; view: Record<string, unknown> }> {
|
|
160
|
+
const accountsById = new Map(snapshot.accounts.map((a) => [a.id, a]));
|
|
161
|
+
const contactCountByAccount = new Map<string, number>();
|
|
162
|
+
for (const c of snapshot.contacts) {
|
|
163
|
+
if (c.accountId) contactCountByAccount.set(c.accountId, (contactCountByAccount.get(c.accountId) ?? 0) + 1);
|
|
164
|
+
}
|
|
165
|
+
const openDealCountByAccount = new Map<string, number>();
|
|
166
|
+
const openDealStagesByAccount = new Map<string, string[]>();
|
|
167
|
+
for (const d of snapshot.deals) {
|
|
168
|
+
if (d.accountId && !d.isClosed) {
|
|
169
|
+
openDealCountByAccount.set(d.accountId, (openDealCountByAccount.get(d.accountId) ?? 0) + 1);
|
|
170
|
+
const stages = openDealStagesByAccount.get(d.accountId) ?? [];
|
|
171
|
+
if (d.stage) stages.push(d.stage);
|
|
172
|
+
openDealStagesByAccount.set(d.accountId, stages);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const records = snapshot[COLLECTIONS[objectType]] as Array<Record<string, unknown>>;
|
|
176
|
+
return records.map((record) => {
|
|
177
|
+
const view: Record<string, unknown> = { ...record };
|
|
178
|
+
if (objectType === "account") {
|
|
179
|
+
view.contactCount = contactCountByAccount.get(String(record.id)) ?? 0;
|
|
180
|
+
view.openDealCount = openDealCountByAccount.get(String(record.id)) ?? 0;
|
|
181
|
+
view.openDealStages = (openDealStagesByAccount.get(String(record.id)) ?? []).join(",");
|
|
182
|
+
} else {
|
|
183
|
+
const account = record.accountId ? accountsById.get(String(record.accountId)) : undefined;
|
|
184
|
+
view["account.name"] = account?.name ?? "";
|
|
185
|
+
view["account.domain"] = account?.domain ?? "";
|
|
186
|
+
view["account.ownerId"] = account?.ownerId ?? "";
|
|
187
|
+
view["account.contactCount"] = account ? (contactCountByAccount.get(account.id) ?? 0) : 0;
|
|
188
|
+
view["account.openDealStages"] = account ? (openDealStagesByAccount.get(account.id) ?? []).join(",") : "";
|
|
189
|
+
}
|
|
190
|
+
return { record, view };
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function parseGuard(raw: string): PlanGuard {
|
|
195
|
+
const first = raw.indexOf(":");
|
|
196
|
+
const last = raw.lastIndexOf(":");
|
|
197
|
+
if (first === -1 || last === first) {
|
|
198
|
+
throw new Error(`Cannot parse --guard "${raw}". Use <account|contact|deal>:<where>[;<where>…]:<none|some>.`);
|
|
199
|
+
}
|
|
200
|
+
const objectType = raw.slice(0, first);
|
|
201
|
+
const expect = raw.slice(last + 1);
|
|
202
|
+
const where = raw.slice(first + 1, last).split(";").map((s) => s.trim()).filter(Boolean);
|
|
203
|
+
if (!["account", "contact", "deal"].includes(objectType) || !["none", "some"].includes(expect) || where.length === 0) {
|
|
204
|
+
throw new Error(`Cannot parse --guard "${raw}". Use <account|contact|deal>:<where>[;<where>…]:<none|some>.`);
|
|
205
|
+
}
|
|
206
|
+
// validate eagerly at plan time: a typo'd guard must fail loudly, never
|
|
207
|
+
// pass vacuously at apply time
|
|
208
|
+
assertValidFields(objectType as BulkUpdateOptions["objectType"], where.map(parseWhere), "--guard");
|
|
209
|
+
return { objectType: objectType as PlanGuard["objectType"], where, expect: expect as PlanGuard["expect"], description: raw };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Ids of records matching a filter — used for apply-time filter re-verification. */
|
|
213
|
+
export function eligibleIds(
|
|
214
|
+
snapshot: CanonicalGtmSnapshot,
|
|
215
|
+
objectType: BulkUpdateOptions["objectType"],
|
|
216
|
+
where: string[],
|
|
217
|
+
): Set<string> {
|
|
218
|
+
const clauses = where.map(parseWhere);
|
|
219
|
+
const views = buildViews(snapshot, objectType);
|
|
220
|
+
return new Set(
|
|
221
|
+
views.filter(({ view }) => clauses.every((c) => matches(view, c))).map(({ record }) => String(record.id)),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Evaluate a plan guard against a snapshot. Returns null when satisfied, else a failure detail. */
|
|
226
|
+
export function evaluateGuard(snapshot: CanonicalGtmSnapshot, guard: PlanGuard): string | null {
|
|
227
|
+
const clauses = guard.where.map(parseWhere);
|
|
228
|
+
const views = buildViews(snapshot, guard.objectType);
|
|
229
|
+
const matchCount = views.filter(({ view }) => clauses.every((c) => matches(view, c))).length;
|
|
230
|
+
const ok = guard.expect === "none" ? matchCount === 0 : matchCount > 0;
|
|
231
|
+
if (ok) return null;
|
|
232
|
+
return `Guard failed: expected ${guard.expect === "none" ? "no" : "at least one"} ${guard.objectType}(s) matching [${guard.where.join(" AND ")}], found ${matchCount}.`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function buildBulkUpdatePlan(
|
|
236
|
+
snapshot: CanonicalGtmSnapshot,
|
|
237
|
+
options: BulkUpdateOptions,
|
|
238
|
+
): PatchPlan {
|
|
239
|
+
const maxOperations = options.maxOperations ?? 500;
|
|
240
|
+
if (options.where.length === 0) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
"bulk-update requires at least one --where filter — refusing to build an unscoped mass write.",
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
const hasSet = options.set && Object.keys(options.set).length > 0;
|
|
246
|
+
const actions = [hasSet, options.archive, options.createTask !== undefined].filter(Boolean).length;
|
|
247
|
+
if (actions !== 1) {
|
|
248
|
+
throw new Error("bulk-update needs exactly one action: --set <field>=<value> (repeatable), --archive, or --create-task <text>.");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const clauses = options.where.map(parseWhere);
|
|
252
|
+
assertValidFields(options.objectType, clauses, "--where");
|
|
253
|
+
const WRITABLE_BLOCKLIST = new Set(["id", "crmId", "contactCount", "openDealCount", "openDealStages"]);
|
|
254
|
+
for (const field of Object.keys(options.set ?? {})) {
|
|
255
|
+
if (!VALID_FIELDS[options.objectType].has(field) || WRITABLE_BLOCKLIST.has(field) || field.includes(".")) {
|
|
256
|
+
throw new Error(`Cannot --set "${field}" on ${options.objectType}s — not a writable canonical field.`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const views = buildViews(snapshot, options.objectType);
|
|
260
|
+
const matched = views.filter(({ view }) => clauses.every((c) => matches(view, c)));
|
|
261
|
+
if (matched.length > maxOperations) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`Filter matched ${matched.length} ${COLLECTIONS[options.objectType]} — above the ${maxOperations}-record safety cap. Narrow the --where filter or raise --max-operations explicitly.`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Preconditions: explicit --require, plus every equality filter on a real
|
|
268
|
+
// (re-readable, non-relational) field. The premise the plan was built on
|
|
269
|
+
// is re-verified per record at apply time.
|
|
270
|
+
const writtenFields = new Set(Object.keys(options.set ?? {}));
|
|
271
|
+
const preconditionSpecs: Array<{ field: string; expectedValue: string }> = [];
|
|
272
|
+
for (const raw of options.require ?? []) {
|
|
273
|
+
const clause = parseWhere(raw);
|
|
274
|
+
if (clause.op !== "eq" || clause.field.includes(".") || (clause.value ?? "").includes("|")) {
|
|
275
|
+
throw new Error(`--require must be a direct single-value field equality (field=value), got "${raw}".`);
|
|
276
|
+
}
|
|
277
|
+
assertValidFields(options.objectType, [clause], "--require");
|
|
278
|
+
preconditionSpecs.push({ field: clause.field, expectedValue: clause.value ?? "" });
|
|
279
|
+
}
|
|
280
|
+
for (const clause of clauses) {
|
|
281
|
+
if (clause.op !== "eq") continue;
|
|
282
|
+
if ((clause.value ?? "").includes("|")) continue; // alternations are not single-value preconditions
|
|
283
|
+
if (clause.field.includes(".") || NON_READABLE_FIELDS.has(clause.field)) continue;
|
|
284
|
+
if (writtenFields.has(clause.field)) continue; // beforeValue already guards it
|
|
285
|
+
if (preconditionSpecs.some((p) => p.field === clause.field)) continue;
|
|
286
|
+
preconditionSpecs.push({ field: clause.field, expectedValue: clause.value ?? "" });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const whereText = options.where.join(" AND ");
|
|
290
|
+
const action = options.archive
|
|
291
|
+
? `archive`
|
|
292
|
+
: options.createTask !== undefined
|
|
293
|
+
? `create task "${options.createTask}"`
|
|
294
|
+
: `set ${Object.entries(options.set!).map(([k, v]) => `${k}=${v}`).join(", ")}`;
|
|
295
|
+
const reason = options.reason ?? `bulk-update: ${action} where ${whereText}`;
|
|
296
|
+
|
|
297
|
+
const operations: PatchOperation[] = [];
|
|
298
|
+
for (const { record } of matched) {
|
|
299
|
+
const objectId = String(record.id);
|
|
300
|
+
const groupId = `grp_${options.objectType}_${objectId}`;
|
|
301
|
+
const preconditions = preconditionSpecs.map((p) => ({
|
|
302
|
+
field: p.field,
|
|
303
|
+
// expected value is the record's CURRENT canonical value, not the
|
|
304
|
+
// filter literal — preserves casing/format the provider will echo back
|
|
305
|
+
expectedValue: record[p.field] ?? p.expectedValue,
|
|
306
|
+
}));
|
|
307
|
+
const shared = {
|
|
308
|
+
objectType: options.objectType as GtmObjectType,
|
|
309
|
+
objectId,
|
|
310
|
+
reason,
|
|
311
|
+
approvalRequired: true as const,
|
|
312
|
+
sourceRuleOrPolicy: "bulk-update",
|
|
313
|
+
...(preconditions.length > 0 ? { preconditions } : {}),
|
|
314
|
+
groupId,
|
|
315
|
+
};
|
|
316
|
+
if (options.archive) {
|
|
317
|
+
operations.push({
|
|
318
|
+
...shared,
|
|
319
|
+
id: `op_${stableHash(`bulk-archive:${options.objectType}:${objectId}:${whereText}`)}`,
|
|
320
|
+
operation: "archive_record",
|
|
321
|
+
beforeValue: null,
|
|
322
|
+
afterValue: null,
|
|
323
|
+
riskLevel: "high",
|
|
324
|
+
rollback: "Archived records can be restored from the provider's recycle bin within its retention window.",
|
|
325
|
+
});
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (options.createTask !== undefined) {
|
|
329
|
+
operations.push({
|
|
330
|
+
...shared,
|
|
331
|
+
id: `op_${stableHash(`bulk-task:${options.objectType}:${objectId}:${options.createTask}`)}`,
|
|
332
|
+
operation: "create_task",
|
|
333
|
+
field: "follow_up_task",
|
|
334
|
+
beforeValue: null,
|
|
335
|
+
afterValue: options.createTask,
|
|
336
|
+
riskLevel: "low",
|
|
337
|
+
rollback: "Delete the created task.",
|
|
338
|
+
});
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
for (const [field, value] of Object.entries(options.set!)) {
|
|
342
|
+
operations.push({
|
|
343
|
+
...shared,
|
|
344
|
+
id: `op_${stableHash(`bulk-set:${options.objectType}:${objectId}:${field}:${value}`)}`,
|
|
345
|
+
operation: "set_field",
|
|
346
|
+
field,
|
|
347
|
+
beforeValue: record[field] ?? null,
|
|
348
|
+
afterValue: value,
|
|
349
|
+
riskLevel: "medium",
|
|
350
|
+
rollback: `Set ${field} back to ${JSON.stringify(record[field] ?? null)}.`,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const guards = (options.guard ?? []).map(parseGuard);
|
|
356
|
+
// guards must hold at plan time too — building a plan whose guard already
|
|
357
|
+
// fails is a footgun, surface it immediately
|
|
358
|
+
for (const guard of guards) {
|
|
359
|
+
const failure = evaluateGuard(snapshot, guard);
|
|
360
|
+
if (failure) throw new Error(`${failure} The guard already fails against the current snapshot — the plan would never apply.`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
id: `patch_plan_${stableHash(`bulk:${snapshot.provider}:${snapshot.generatedAt}:${whereText}:${action}:${operations.length}`)}`,
|
|
365
|
+
title: `Bulk update: ${options.objectType}s where ${whereText}`,
|
|
366
|
+
createdAt: snapshot.generatedAt,
|
|
367
|
+
status: operations.length > 0 ? "needs_approval" : "draft",
|
|
368
|
+
dryRun: true,
|
|
369
|
+
summary: `${matched.length} ${COLLECTIONS[options.objectType]} matched (${whereText}); ${operations.length} proposed dry-run operations (${action}).${guards.length > 0 ? ` ${guards.length} apply-time guard(s).` : ""}`,
|
|
370
|
+
findings: [],
|
|
371
|
+
operations,
|
|
372
|
+
filter: { objectType: options.objectType, where: options.where },
|
|
373
|
+
...(guards.length > 0 ? { guards } : {}),
|
|
374
|
+
};
|
|
375
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
verifyEvidenceSpans,
|
|
52
52
|
type ObservationSet,
|
|
53
53
|
} from "./market.ts";
|
|
54
|
+
import { assessAxes, axesReportToText } from "./marketAxes.ts";
|
|
54
55
|
import { buildWorksheet, classifyMarket } from "./marketClassify.ts";
|
|
55
56
|
import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
|
|
56
57
|
import {
|
|
@@ -65,6 +66,7 @@ import {
|
|
|
65
66
|
type LlmProvider,
|
|
66
67
|
} from "./llm.ts";
|
|
67
68
|
import { resolveRecord, type ResolveCandidate } from "./resolve.ts";
|
|
69
|
+
import { buildBulkUpdatePlan } from "./bulkUpdate.ts";
|
|
68
70
|
import { suggestValues, type ValueSuggestion } from "./suggest.ts";
|
|
69
71
|
import type { FieldMappings } from "./mappings.ts";
|
|
70
72
|
import type {
|
|
@@ -114,6 +116,7 @@ Usage:
|
|
|
114
116
|
fullstackgtm market worksheet --vendor <id> [--out <path>]
|
|
115
117
|
fullstackgtm market observe --from <observations.json> [--unverified]
|
|
116
118
|
fullstackgtm market fronts [--run <label>] [--diff <prior-run>] [--json]
|
|
119
|
+
fullstackgtm market axes [--run <label>] [--json]
|
|
117
120
|
fullstackgtm market report [--run <label>] [--format md|html] [--out <path>]
|
|
118
121
|
fullstackgtm market refresh [--run <label>] [--model m]
|
|
119
122
|
the live competitive map: capture vendor pages (content-addressed),
|
|
@@ -122,6 +125,18 @@ Usage:
|
|
|
122
125
|
against the stored capture it cites before it's accepted — then
|
|
123
126
|
compute deterministic front states and drift, render the field
|
|
124
127
|
report. refresh = capture → classify → drift → report in one step
|
|
128
|
+
fullstackgtm bulk-update <account|contact|deal> --where <expr> [--where …] (--set <field>=<value> [--set …] | --archive | --create-task <text>) [--require <field>=<value> …] [--guard <object>:<where>[;<where>]:<none|some> …] [source options] [--save] [--json] [--out <path>]
|
|
129
|
+
governed generic writes: filter the snapshot
|
|
130
|
+
(field=value, field!=value, field~substr, field!~substr,
|
|
131
|
+
field:empty, field:notempty, '|' = any-of; canonical fields
|
|
132
|
+
like ownerId, stage, closeDate, amount; relational
|
|
133
|
+
pseudo-fields account.name/domain/ownerId/contactCount/
|
|
134
|
+
openDealStages on deals and contacts, contactCount/
|
|
135
|
+
openDealCount/openDealStages on accounts) into a dry-run
|
|
136
|
+
patch plan. The full filter is re-verified per record at
|
|
137
|
+
apply time (incl. mid-apply rechecks); equality filters
|
|
138
|
+
double as preconditions; per-record ops apply
|
|
139
|
+
all-or-nothing; guards assert cross-record conditions.
|
|
125
140
|
fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
|
|
126
141
|
derive values for requires_human_* placeholders
|
|
127
142
|
from snapshot evidence, with confidence + reasons
|
|
@@ -849,7 +864,9 @@ async function marketCommand(args: string[]) {
|
|
|
849
864
|
const [subcommand, ...rest] = args;
|
|
850
865
|
const configPath = () => resolve(process.cwd(), option(rest, "--config") ?? "market.config.json");
|
|
851
866
|
|
|
852
|
-
|
|
867
|
+
// Catch --help anywhere before loadMarketConfig/credential checks run —
|
|
868
|
+
// several subcommands (capture, refresh) have side effects on bare invocation.
|
|
869
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h" || rest.includes("--help") || rest.includes("-h")) {
|
|
853
870
|
console.log(`Usage:
|
|
854
871
|
market init --category <name> [--out <path>] write a starter market.config.json
|
|
855
872
|
market capture [--config <path>] [--run <label>]
|
|
@@ -857,9 +874,17 @@ market classify [--run <label>] [--capture-run <label>] [--vendor <id>] [--model
|
|
|
857
874
|
market worksheet --vendor <id> [--capture-run <label>] [--out <path>]
|
|
858
875
|
market observe --from <observations.json> [--unverified]
|
|
859
876
|
market fronts [--config <path>] [--run <label>] [--diff <prior-run>] [--json]
|
|
877
|
+
market axes [--config <path>] [--run <label>] [--json]
|
|
860
878
|
market report [--config <path>] [--run <label>] [--format md|html] [--out <path>]
|
|
861
879
|
market refresh [--run <label>] [--model m] capture → classify → fronts drift → HTML report
|
|
862
880
|
|
|
881
|
+
axes runs the axis-discovery math: PCA over the vendor × claim intensity
|
|
882
|
+
matrix (PC1 = the category's primary axis, PC2 = the max-differentiation
|
|
883
|
+
direction orthogonal to it), triangulation of configured axes against the
|
|
884
|
+
PCs, and an orthogonality screen (|r|>0.75 = one axis twice). Axes live in
|
|
885
|
+
the config as claim-scoring rubrics; the report's strategic map and axis
|
|
886
|
+
lab render from them.
|
|
887
|
+
|
|
863
888
|
classify uses your Anthropic/OpenAI key (like call parse) to read the stored
|
|
864
889
|
captures and propose intensity readings; worksheet is the no-key path (an
|
|
865
890
|
agent or human fills it, submits via observe). Either way, every quoted span
|
|
@@ -1053,8 +1078,19 @@ recomputed deterministically on every invocation — never stored.`);
|
|
|
1053
1078
|
return;
|
|
1054
1079
|
}
|
|
1055
1080
|
|
|
1081
|
+
if (subcommand === "axes") {
|
|
1082
|
+
const set = await loadSet();
|
|
1083
|
+
const report = assessAxes(config, set);
|
|
1084
|
+
if (rest.includes("--json")) {
|
|
1085
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
console.log(axesReportToText(report));
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1056
1092
|
throw new Error(
|
|
1057
|
-
`Unknown market subcommand: ${subcommand} (try: init, capture, classify, worksheet, observe, fronts, report, refresh)`,
|
|
1093
|
+
`Unknown market subcommand: ${subcommand} (try: init, capture, classify, worksheet, observe, fronts, axes, report, refresh)`,
|
|
1058
1094
|
);
|
|
1059
1095
|
}
|
|
1060
1096
|
|
|
@@ -1089,6 +1125,54 @@ async function resolveCommand(args: string[]) {
|
|
|
1089
1125
|
if (result.verdict !== "safe_to_create") process.exitCode = 2;
|
|
1090
1126
|
}
|
|
1091
1127
|
|
|
1128
|
+
/**
|
|
1129
|
+
* Governed generic writes: build a dry-run patch plan from a snapshot filter
|
|
1130
|
+
* plus field assignments (or --archive). Never writes — approve and apply the
|
|
1131
|
+
* plan like any audit plan; compare-and-set protects every operation.
|
|
1132
|
+
*/
|
|
1133
|
+
async function bulkUpdateCommand(args: string[]) {
|
|
1134
|
+
const [objectType, ...rest] = args;
|
|
1135
|
+
if (!objectType || !["account", "contact", "deal"].includes(objectType)) {
|
|
1136
|
+
throw new Error(
|
|
1137
|
+
"Usage: fullstackgtm bulk-update <account|contact|deal> --where <field=value|field!=value|field~substr|field:empty|field:notempty> [--where …] (--set <field>=<value> [--set …] | --archive) [source options] [--reason <text>] [--max-operations <n>] [--save] [--out <path>] [--json]",
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
const where = repeatedOption(rest, "--where");
|
|
1141
|
+
const set: Record<string, string> = {};
|
|
1142
|
+
for (const pair of repeatedOption(rest, "--set")) {
|
|
1143
|
+
const separator = pair.indexOf("=");
|
|
1144
|
+
if (separator === -1) throw new Error(`--set must look like <field>=<value>, got "${pair}"`);
|
|
1145
|
+
set[pair.slice(0, separator)] = pair.slice(separator + 1);
|
|
1146
|
+
}
|
|
1147
|
+
const snapshot = await readSnapshot(rest);
|
|
1148
|
+
const plan = buildBulkUpdatePlan(snapshot, {
|
|
1149
|
+
objectType: objectType as "account" | "contact" | "deal",
|
|
1150
|
+
where,
|
|
1151
|
+
set: Object.keys(set).length > 0 ? set : undefined,
|
|
1152
|
+
archive: rest.includes("--archive"),
|
|
1153
|
+
createTask: option(rest, "--create-task") ?? undefined,
|
|
1154
|
+
require: repeatedOption(rest, "--require"),
|
|
1155
|
+
guard: repeatedOption(rest, "--guard"),
|
|
1156
|
+
reason: option(rest, "--reason") ?? undefined,
|
|
1157
|
+
maxOperations: numericOption(rest, "--max-operations"),
|
|
1158
|
+
});
|
|
1159
|
+
const out = option(rest, "--out");
|
|
1160
|
+
if (out) {
|
|
1161
|
+
writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(plan, null, 2)}\n`);
|
|
1162
|
+
}
|
|
1163
|
+
if (rest.includes("--save")) {
|
|
1164
|
+
await createFilePlanStore().save(plan);
|
|
1165
|
+
console.error(
|
|
1166
|
+
`Saved plan ${plan.id} (${plan.operations.length} operations). Review with \`fullstackgtm plans show ${plan.id}\`, approve with \`fullstackgtm plans approve ${plan.id} --operations <ids|all>\`, then \`fullstackgtm apply --plan-id ${plan.id} --provider <name>\`.`,
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
if (rest.includes("--json")) {
|
|
1170
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
1171
|
+
} else {
|
|
1172
|
+
console.log(patchPlanToMarkdown(plan));
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1092
1176
|
async function suggest(args: string[]) {
|
|
1093
1177
|
const planId = option(args, "--plan-id");
|
|
1094
1178
|
const planPath = option(args, "--plan");
|
|
@@ -1881,6 +1965,13 @@ export async function runCli(argv: string[]) {
|
|
|
1881
1965
|
console.log(readPackageInfo().version);
|
|
1882
1966
|
return;
|
|
1883
1967
|
}
|
|
1968
|
+
// Commands without bespoke help fall back to the top-level usage on --help
|
|
1969
|
+
// instead of executing (audit used to silently run the sample audit).
|
|
1970
|
+
// call/market/bulk-update print their own richer help.
|
|
1971
|
+
if (!["call", "market", "bulk-update"].includes(command) && (args.includes("--help") || args.includes("-h"))) {
|
|
1972
|
+
console.log(usage());
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1884
1975
|
|
|
1885
1976
|
if (command === "login") {
|
|
1886
1977
|
await login(args);
|
|
@@ -1922,6 +2013,10 @@ export async function runCli(argv: string[]) {
|
|
|
1922
2013
|
await resolveCommand(args);
|
|
1923
2014
|
return;
|
|
1924
2015
|
}
|
|
2016
|
+
if (command === "bulk-update") {
|
|
2017
|
+
await bulkUpdateCommand(args);
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
1925
2020
|
if (command === "market") {
|
|
1926
2021
|
await marketCommand(args);
|
|
1927
2022
|
return;
|