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.
@@ -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
@@ -52,6 +52,14 @@ import {
52
52
  type ObservationSet,
53
53
  } from "./market.ts";
54
54
  import { assessAxes, axesReportToText } from "./marketAxes.ts";
55
+ import {
56
+ computeDirectives,
57
+ computeOverlayStats,
58
+ directivesToPlan,
59
+ overlayToMarkdown,
60
+ type CallDocument,
61
+ } from "./marketOverlay.ts";
62
+ import { computeScaleIndex, scaleReportToText } from "./marketScale.ts";
55
63
  import { buildWorksheet, classifyMarket } from "./marketClassify.ts";
56
64
  import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
57
65
  import {
@@ -66,6 +74,7 @@ import {
66
74
  type LlmProvider,
67
75
  } from "./llm.ts";
68
76
  import { resolveRecord, type ResolveCandidate } from "./resolve.ts";
77
+ import { buildBulkUpdatePlan } from "./bulkUpdate.ts";
69
78
  import { suggestValues, type ValueSuggestion } from "./suggest.ts";
70
79
  import type { FieldMappings } from "./mappings.ts";
71
80
  import type {
@@ -117,6 +126,8 @@ Usage:
117
126
  fullstackgtm market fronts [--run <label>] [--diff <prior-run>] [--json]
118
127
  fullstackgtm market axes [--run <label>] [--json]
119
128
  fullstackgtm market report [--run <label>] [--format md|html] [--out <path>]
129
+ fullstackgtm market overlay --snapshot <crm.json> [--calls <files>] [--save]
130
+ fullstackgtm market scale [--json]
120
131
  fullstackgtm market refresh [--run <label>] [--model m]
121
132
  the live competitive map: capture vendor pages (content-addressed),
122
133
  classify intensity per claim (LLM bring-your-own-key, or fill the
@@ -124,6 +135,18 @@ Usage:
124
135
  against the stored capture it cites before it's accepted — then
125
136
  compute deterministic front states and drift, render the field
126
137
  report. refresh = capture → classify → drift → report in one step
138
+ 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>]
139
+ governed generic writes: filter the snapshot
140
+ (field=value, field!=value, field~substr, field!~substr,
141
+ field:empty, field:notempty, '|' = any-of; canonical fields
142
+ like ownerId, stage, closeDate, amount; relational
143
+ pseudo-fields account.name/domain/ownerId/contactCount/
144
+ openDealStages on deals and contacts, contactCount/
145
+ openDealCount/openDealStages on accounts) into a dry-run
146
+ patch plan. The full filter is re-verified per record at
147
+ apply time (incl. mid-apply rechecks); equality filters
148
+ double as preconditions; per-record ops apply
149
+ all-or-nothing; guards assert cross-record conditions.
127
150
  fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
128
151
  derive values for requires_human_* placeholders
129
152
  from snapshot evidence, with confidence + reasons
@@ -851,7 +874,9 @@ async function marketCommand(args: string[]) {
851
874
  const [subcommand, ...rest] = args;
852
875
  const configPath = () => resolve(process.cwd(), option(rest, "--config") ?? "market.config.json");
853
876
 
854
- if (!subcommand || subcommand === "--help") {
877
+ // Catch --help anywhere before loadMarketConfig/credential checks run —
878
+ // several subcommands (capture, refresh) have side effects on bare invocation.
879
+ if (!subcommand || subcommand === "--help" || subcommand === "-h" || rest.includes("--help") || rest.includes("-h")) {
855
880
  console.log(`Usage:
856
881
  market init --category <name> [--out <path>] write a starter market.config.json
857
882
  market capture [--config <path>] [--run <label>]
@@ -860,9 +885,24 @@ market worksheet --vendor <id> [--capture-run <label>] [--out <path>]
860
885
  market observe --from <observations.json> [--unverified]
861
886
  market fronts [--config <path>] [--run <label>] [--diff <prior-run>] [--json]
862
887
  market axes [--config <path>] [--run <label>] [--json]
888
+ market overlay --snapshot <crm.json> [--calls <parsed.json|manifest.json>]... [--prior-run <label>]
889
+ [--min-mentions N] [--promote-lift X] [--json] [--save --task-account <id>|--task-deal <id>]
890
+ market scale [--config <path>] [--json]
863
891
  market report [--config <path>] [--run <label>] [--format md|html] [--out <path>]
864
892
  market refresh [--run <label>] [--model m] capture → classify → fronts drift → HTML report
865
893
 
894
+ overlay is the directive layer: joins the map to YOUR CRM ground truth and
895
+ emits OCCUPY / PROMOTE / URGENT / RETREAT directives, each carrying ≥1
896
+ observation and ≥1 CRM statistic with its sample size. Claim mentions are
897
+ deterministic word-boundary matches of each claim's "terms" against call
898
+ documents (call parse output); small samples refuse to become strategy
899
+ (--min-mentions, default 3). --save turns directives into approval-gated
900
+ create_task operations through the normal plans → approve → apply gate.
901
+
902
+ scale prints the relative scale index that sizes the report's bubbles when
903
+ vendors carry scaleSignals (citable review counts / headcount / revenue —
904
+ a within-set index, never "market share" unqualified).
905
+
866
906
  axes runs the axis-discovery math: PCA over the vendor × claim intensity
867
907
  matrix (PC1 = the category's primary axis, PC2 = the max-differentiation
868
908
  direction orthogonal to it), triangulation of configured axes against the
@@ -1074,8 +1114,90 @@ recomputed deterministically on every invocation — never stored.`);
1074
1114
  return;
1075
1115
  }
1076
1116
 
1117
+ if (subcommand === "scale") {
1118
+ const report = computeScaleIndex(config);
1119
+ if (rest.includes("--json")) {
1120
+ console.log(JSON.stringify(report, null, 2));
1121
+ return;
1122
+ }
1123
+ console.log(scaleReportToText(config, report));
1124
+ return;
1125
+ }
1126
+
1127
+ if (subcommand === "overlay") {
1128
+ const set = await loadSet();
1129
+ const snapshotPath = option(rest, "--snapshot");
1130
+ if (!snapshotPath) {
1131
+ throw new Error(
1132
+ "market overlay requires --snapshot <canonical-snapshot.json> (fullstackgtm snapshot --out it first) — directives need CRM ground truth",
1133
+ );
1134
+ }
1135
+ const snapshot = JSON.parse(readFileSync(resolve(process.cwd(), snapshotPath), "utf8")) as CanonicalGtmSnapshot;
1136
+
1137
+ // --calls accepts ParsedCall JSON files (from `call parse --out`) and/or
1138
+ // manifest arrays [{path, dealId?}] linking calls to deals. Repeatable.
1139
+ const documents: CallDocument[] = [];
1140
+ const addParsedCall = (parsedPath: string, dealId?: string) => {
1141
+ const parsed = JSON.parse(readFileSync(resolve(process.cwd(), parsedPath), "utf8")) as ParsedCall & {
1142
+ segments?: Array<{ text?: string }>;
1143
+ };
1144
+ const text = [
1145
+ ...(parsed.segments ?? []).map((segment) => segment.text ?? ""),
1146
+ ...(parsed.insights ?? []).map((insight) => `${insight.text ?? ""} ${insight.evidence ?? ""}`),
1147
+ ].join("\n");
1148
+ documents.push({ id: parsed.id ?? parsedPath, text, dealId, occurredAt: parsed.evidence?.[0]?.capturedAt });
1149
+ };
1150
+ for (let i = 0; i < rest.length; i += 1) {
1151
+ if (rest[i] !== "--calls") continue;
1152
+ const callsPath = rest[i + 1];
1153
+ if (!callsPath) throw new Error("--calls needs a path");
1154
+ const raw = JSON.parse(readFileSync(resolve(process.cwd(), callsPath), "utf8"));
1155
+ if (Array.isArray(raw)) {
1156
+ for (const entry of raw as Array<{ path: string; dealId?: string }>) addParsedCall(entry.path, entry.dealId);
1157
+ } else {
1158
+ addParsedCall(callsPath);
1159
+ }
1160
+ }
1161
+
1162
+ const priorLabel = option(rest, "--prior-run");
1163
+ const priorSet = priorLabel ? await store.get(priorLabel) : null;
1164
+ if (priorLabel && !priorSet) throw new Error(`No observation run "${priorLabel}" for URGENT drift`);
1165
+
1166
+ const stats = computeOverlayStats(config, snapshot, documents);
1167
+ const directives = computeDirectives(config, set, stats, {
1168
+ minMentions: numericOption(rest, "--min-mentions") ?? undefined,
1169
+ promoteLift: numericOption(rest, "--promote-lift") ?? undefined,
1170
+ priorSet: priorSet ?? undefined,
1171
+ });
1172
+
1173
+ if (rest.includes("--json")) {
1174
+ console.log(JSON.stringify({ stats, directives }, null, 2));
1175
+ return;
1176
+ }
1177
+ console.log(overlayToMarkdown(stats, directives));
1178
+
1179
+ if (rest.includes("--save")) {
1180
+ const taskAccount = option(rest, "--task-account");
1181
+ const taskDeal = option(rest, "--task-deal");
1182
+ if (!taskAccount && !taskDeal) {
1183
+ throw new Error(
1184
+ "--save needs --task-account <id> or --task-deal <id>: directives become approval-gated create_task operations, and the CRM needs a record to hang them on (your own company's account record works well)",
1185
+ );
1186
+ }
1187
+ const plan = directivesToPlan(
1188
+ config,
1189
+ set,
1190
+ directives,
1191
+ taskDeal ? { objectType: "deal", objectId: taskDeal } : { objectType: "account", objectId: taskAccount as string },
1192
+ );
1193
+ const stored = await createFilePlanStore().save(plan);
1194
+ console.log(`Saved plan ${stored.plan.id} (${directives.length} directive task(s); approve via \`plans approve\`)`);
1195
+ }
1196
+ return;
1197
+ }
1198
+
1077
1199
  throw new Error(
1078
- `Unknown market subcommand: ${subcommand} (try: init, capture, classify, worksheet, observe, fronts, axes, report, refresh)`,
1200
+ `Unknown market subcommand: ${subcommand} (try: init, capture, classify, worksheet, observe, fronts, axes, overlay, scale, report, refresh)`,
1079
1201
  );
1080
1202
  }
1081
1203
 
@@ -1110,6 +1232,54 @@ async function resolveCommand(args: string[]) {
1110
1232
  if (result.verdict !== "safe_to_create") process.exitCode = 2;
1111
1233
  }
1112
1234
 
1235
+ /**
1236
+ * Governed generic writes: build a dry-run patch plan from a snapshot filter
1237
+ * plus field assignments (or --archive). Never writes — approve and apply the
1238
+ * plan like any audit plan; compare-and-set protects every operation.
1239
+ */
1240
+ async function bulkUpdateCommand(args: string[]) {
1241
+ const [objectType, ...rest] = args;
1242
+ if (!objectType || !["account", "contact", "deal"].includes(objectType)) {
1243
+ throw new Error(
1244
+ "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]",
1245
+ );
1246
+ }
1247
+ const where = repeatedOption(rest, "--where");
1248
+ const set: Record<string, string> = {};
1249
+ for (const pair of repeatedOption(rest, "--set")) {
1250
+ const separator = pair.indexOf("=");
1251
+ if (separator === -1) throw new Error(`--set must look like <field>=<value>, got "${pair}"`);
1252
+ set[pair.slice(0, separator)] = pair.slice(separator + 1);
1253
+ }
1254
+ const snapshot = await readSnapshot(rest);
1255
+ const plan = buildBulkUpdatePlan(snapshot, {
1256
+ objectType: objectType as "account" | "contact" | "deal",
1257
+ where,
1258
+ set: Object.keys(set).length > 0 ? set : undefined,
1259
+ archive: rest.includes("--archive"),
1260
+ createTask: option(rest, "--create-task") ?? undefined,
1261
+ require: repeatedOption(rest, "--require"),
1262
+ guard: repeatedOption(rest, "--guard"),
1263
+ reason: option(rest, "--reason") ?? undefined,
1264
+ maxOperations: numericOption(rest, "--max-operations"),
1265
+ });
1266
+ const out = option(rest, "--out");
1267
+ if (out) {
1268
+ writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(plan, null, 2)}\n`);
1269
+ }
1270
+ if (rest.includes("--save")) {
1271
+ await createFilePlanStore().save(plan);
1272
+ console.error(
1273
+ `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>\`.`,
1274
+ );
1275
+ }
1276
+ if (rest.includes("--json")) {
1277
+ console.log(JSON.stringify(plan, null, 2));
1278
+ } else {
1279
+ console.log(patchPlanToMarkdown(plan));
1280
+ }
1281
+ }
1282
+
1113
1283
  async function suggest(args: string[]) {
1114
1284
  const planId = option(args, "--plan-id");
1115
1285
  const planPath = option(args, "--plan");
@@ -1902,6 +2072,13 @@ export async function runCli(argv: string[]) {
1902
2072
  console.log(readPackageInfo().version);
1903
2073
  return;
1904
2074
  }
2075
+ // Commands without bespoke help fall back to the top-level usage on --help
2076
+ // instead of executing (audit used to silently run the sample audit).
2077
+ // call/market/bulk-update print their own richer help.
2078
+ if (!["call", "market", "bulk-update"].includes(command) && (args.includes("--help") || args.includes("-h"))) {
2079
+ console.log(usage());
2080
+ return;
2081
+ }
1905
2082
 
1906
2083
  if (command === "login") {
1907
2084
  await login(args);
@@ -1943,6 +2120,10 @@ export async function runCli(argv: string[]) {
1943
2120
  await resolveCommand(args);
1944
2121
  return;
1945
2122
  }
2123
+ if (command === "bulk-update") {
2124
+ await bulkUpdateCommand(args);
2125
+ return;
2126
+ }
1946
2127
  if (command === "market") {
1947
2128
  await marketCommand(args);
1948
2129
  return;