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/CHANGELOG.md CHANGED
@@ -5,6 +5,45 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
  The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
7
7
 
8
+ ## [0.19.0] — 2026-06-11
9
+
10
+ Governed bulk writes, plus fixes from the 0.18 published-artifact verification.
11
+
12
+ ### Added
13
+
14
+ - **`fullstackgtm bulk-update <account|contact|deal>`** — generic writes
15
+ through the same plan gate as everything else: `--where` filters (`=`,
16
+ `!=`, `~` substring, `:empty`/`:notempty`, `|` alternation) select records,
17
+ `--set`/`--archive`/`--create-task` define the change, and the result is a
18
+ dry-run patch plan needing explicit approval before `apply` — never a
19
+ direct write. `--require <field>=<value>` preconditions and
20
+ `--guard <object>:<where>:<none|some>` cross-record eligibility checks are
21
+ re-verified at apply time against the live CRM (mid-apply rechecks shrink
22
+ the audit→apply TOCTOU window); `--max-operations` caps blast radius.
23
+ - Eval tool card teaches agents to encode eligibility conditions in filters
24
+ rather than hand-selecting record ids.
25
+
26
+ ### Fixed
27
+
28
+ - `--help` no longer executes: every `market` subcommand now short-circuits
29
+ to usage before config loads, credential checks, or side effects
30
+ (`market capture --help` used to *run the capture* — live fetches and
31
+ manifest writes; `market axes --help` ran the analysis). Top-level
32
+ commands without bespoke help (`audit`, `snapshot`, `suggest`, …) print
33
+ usage on `--help` instead of executing (`audit --help` used to silently
34
+ run the sample audit).
35
+ - `market report --format html` no longer crashes with a bare TypeError on
36
+ axes missing pole labels: `parseMarketConfig` now requires
37
+ `negativePole`/`positivePole` on every axis and says so.
38
+ - The 0.18.0 entry below documented the axis shape with a `poles` field that
39
+ never existed; the real fields are `negativePole`/`positivePole` (entry
40
+ corrected in place).
41
+ - `forcedToolCall` is now actually exported from the package root, as the
42
+ 0.17.0 entry claimed.
43
+ - MCP `fullstackgtm_market_worksheet`/`_observe` with no
44
+ `market.config.json` in the server cwd return a "run `fullstackgtm market
45
+ init`" hint instead of a raw ENOENT.
46
+
8
47
  ## [0.18.0] — 2026-06-11
9
48
 
10
49
  Axis discovery: earn a strategic 2×2 from the observations instead of
@@ -13,7 +52,7 @@ asserting one.
13
52
  ### Added
14
53
 
15
54
  - **Axes as config** — `axes` in `market.config.json`: each axis is a
16
- claim-scoring rubric (`{ id, label, poles, rubric, status, claimScores }`,
55
+ claim-scoring rubric (`{ id, label, negativePole, positivePole, rubric, status, claimScores }`,
17
56
  null = axis doesn't apply to that claim); a vendor's position is the
18
57
  intensity-weighted mean (loud=1, quiet=½) of the claims it voices.
19
58
  `primaryAxes: [x, y]` picks the report's strategic map. Config validation
@@ -0,0 +1,37 @@
1
+ import type { CanonicalGtmSnapshot, PatchPlan, PlanGuard } from "./types.ts";
2
+ export type BulkUpdateOptions = {
3
+ objectType: "account" | "contact" | "deal";
4
+ /** raw --where expressions, AND-ed together; at least one is required */
5
+ where: string[];
6
+ /** canonical field → new value; one action only */
7
+ set?: Record<string, string>;
8
+ /** propose archive_record instead of field writes */
9
+ archive?: boolean;
10
+ /** propose create_task on each matched record with this subject/body text */
11
+ createTask?: string;
12
+ /** explicit preconditions (field=value), re-verified at apply time */
13
+ require?: string[];
14
+ /**
15
+ * plan-level guards, raw form "<objectType>:<where>[;<where>…]:<none|some>",
16
+ * re-evaluated against a fresh snapshot at apply time; failure aborts the
17
+ * entire plan
18
+ */
19
+ guard?: string[];
20
+ reason?: string;
21
+ /** refuse to build plans larger than this (default 500 operations) */
22
+ maxOperations?: number;
23
+ };
24
+ type WhereClause = {
25
+ field: string;
26
+ op: "eq" | "neq" | "contains" | "notcontains" | "empty" | "notempty";
27
+ value?: string;
28
+ raw: string;
29
+ };
30
+ export declare function parseWhere(expr: string): WhereClause;
31
+ export declare function parseGuard(raw: string): PlanGuard;
32
+ /** Ids of records matching a filter — used for apply-time filter re-verification. */
33
+ export declare function eligibleIds(snapshot: CanonicalGtmSnapshot, objectType: BulkUpdateOptions["objectType"], where: string[]): Set<string>;
34
+ /** Evaluate a plan guard against a snapshot. Returns null when satisfied, else a failure detail. */
35
+ export declare function evaluateGuard(snapshot: CanonicalGtmSnapshot, guard: PlanGuard): string | null;
36
+ export declare function buildBulkUpdatePlan(snapshot: CanonicalGtmSnapshot, options: BulkUpdateOptions): PatchPlan;
37
+ export {};
@@ -0,0 +1,315 @@
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.js";
31
+ const FIELD_PATTERN = "[a-zA-Z][a-zA-Z0-9_]*(?:\\.[a-zA-Z][a-zA-Z0-9_]*)?";
32
+ export function parseWhere(expr) {
33
+ const empty = expr.match(new RegExp(`^(${FIELD_PATTERN}):(empty|notempty)$`));
34
+ if (empty)
35
+ return { field: empty[1], op: empty[2], raw: expr };
36
+ const neq = expr.match(new RegExp(`^(${FIELD_PATTERN})!=(.*)$`));
37
+ if (neq)
38
+ return { field: neq[1], op: "neq", value: neq[2], raw: expr };
39
+ const notContains = expr.match(new RegExp(`^(${FIELD_PATTERN})!~(.*)$`));
40
+ if (notContains)
41
+ return { field: notContains[1], op: "notcontains", value: notContains[2], raw: expr };
42
+ const contains = expr.match(new RegExp(`^(${FIELD_PATTERN})~(.*)$`));
43
+ if (contains)
44
+ return { field: contains[1], op: "contains", value: contains[2], raw: expr };
45
+ const eq = expr.match(new RegExp(`^(${FIELD_PATTERN})=(.*)$`));
46
+ if (eq)
47
+ return { field: eq[1], op: "eq", value: eq[2], raw: expr };
48
+ throw new Error(`Cannot parse --where "${expr}". Use field=value, field!=value, field~substring, field!~substring, field:empty, or field:notempty.`);
49
+ }
50
+ function fieldValue(view, field) {
51
+ const value = view[field];
52
+ if (value === undefined || value === null)
53
+ return "";
54
+ return String(value);
55
+ }
56
+ function matches(view, clause) {
57
+ const actual = fieldValue(view, clause.field).toLowerCase();
58
+ // `|` alternation: eq/contains match ANY alternative; neq/notcontains
59
+ // must hold against ALL alternatives.
60
+ const alternatives = (clause.value ?? "").toLowerCase().split("|");
61
+ switch (clause.op) {
62
+ case "eq":
63
+ return alternatives.some((a) => actual === a);
64
+ case "neq":
65
+ return alternatives.every((a) => actual !== a);
66
+ case "contains":
67
+ return alternatives.some((a) => actual.includes(a));
68
+ case "notcontains":
69
+ return alternatives.every((a) => !actual.includes(a));
70
+ case "empty":
71
+ return actual === "";
72
+ case "notempty":
73
+ return actual !== "";
74
+ }
75
+ }
76
+ const COLLECTIONS = {
77
+ account: "accounts",
78
+ contact: "contacts",
79
+ deal: "deals",
80
+ };
81
+ const RELATIONAL_FIELDS = ["account.name", "account.domain", "account.ownerId", "account.contactCount", "account.openDealStages"];
82
+ /**
83
+ * Filterable fields per object type. Filters/requires/guards referencing any
84
+ * other field are rejected at plan time: a typo'd field silently evaluating
85
+ * to empty would make a ":none" guard pass vacuously — a safety assertion
86
+ * that never fires. Strictness turns typos into immediate, correctable
87
+ * errors.
88
+ */
89
+ const VALID_FIELDS = {
90
+ account: new Set(["id", "crmId", "name", "domain", "industry", "ownerId", "employeeCount", "annualRevenue", "lastActivityAt", "lastSyncAt", "contactCount", "openDealCount", "openDealStages"]),
91
+ contact: new Set(["id", "crmId", "accountId", "firstName", "lastName", "email", "phone", "title", "ownerId", "lastSyncAt", ...RELATIONAL_FIELDS]),
92
+ deal: new Set(["id", "crmId", "accountId", "ownerId", "name", "amount", "currency", "stage", "closeDate", "dealType", "forecastCategory", "nextStep", "probability", "isClosed", "isWon", "lastActivityAt", "lastSyncAt", ...RELATIONAL_FIELDS]),
93
+ };
94
+ function assertValidFields(objectType, clauses, context) {
95
+ for (const clause of clauses) {
96
+ if (!VALID_FIELDS[objectType].has(clause.field)) {
97
+ throw new Error(`Unknown field "${clause.field}" in ${context} "${clause.raw}" for ${objectType}s. Valid fields: ${[...VALID_FIELDS[objectType]].join(", ")}.`);
98
+ }
99
+ }
100
+ }
101
+ /**
102
+ * Fields that are derived in the canonical model (no provider property to
103
+ * re-read at apply time) or relational — excluded from auto-preconditions.
104
+ */
105
+ const NON_READABLE_FIELDS = new Set([
106
+ // derived in the canonical model — no provider property to re-read
107
+ "isClosed", "isWon", "forecastCategory", "probability", "lastActivityAt", "lastSyncAt",
108
+ // identity/bookkeeping fields — preconditions on these are meaningless
109
+ "id", "crmId", "provider", "identities",
110
+ ]);
111
+ /** Build the filter-evaluation view: record fields + relational pseudo-fields. */
112
+ function buildViews(snapshot, objectType) {
113
+ const accountsById = new Map(snapshot.accounts.map((a) => [a.id, a]));
114
+ const contactCountByAccount = new Map();
115
+ for (const c of snapshot.contacts) {
116
+ if (c.accountId)
117
+ contactCountByAccount.set(c.accountId, (contactCountByAccount.get(c.accountId) ?? 0) + 1);
118
+ }
119
+ const openDealCountByAccount = new Map();
120
+ const openDealStagesByAccount = new Map();
121
+ for (const d of snapshot.deals) {
122
+ if (d.accountId && !d.isClosed) {
123
+ openDealCountByAccount.set(d.accountId, (openDealCountByAccount.get(d.accountId) ?? 0) + 1);
124
+ const stages = openDealStagesByAccount.get(d.accountId) ?? [];
125
+ if (d.stage)
126
+ stages.push(d.stage);
127
+ openDealStagesByAccount.set(d.accountId, stages);
128
+ }
129
+ }
130
+ const records = snapshot[COLLECTIONS[objectType]];
131
+ return records.map((record) => {
132
+ const view = { ...record };
133
+ if (objectType === "account") {
134
+ view.contactCount = contactCountByAccount.get(String(record.id)) ?? 0;
135
+ view.openDealCount = openDealCountByAccount.get(String(record.id)) ?? 0;
136
+ view.openDealStages = (openDealStagesByAccount.get(String(record.id)) ?? []).join(",");
137
+ }
138
+ else {
139
+ const account = record.accountId ? accountsById.get(String(record.accountId)) : undefined;
140
+ view["account.name"] = account?.name ?? "";
141
+ view["account.domain"] = account?.domain ?? "";
142
+ view["account.ownerId"] = account?.ownerId ?? "";
143
+ view["account.contactCount"] = account ? (contactCountByAccount.get(account.id) ?? 0) : 0;
144
+ view["account.openDealStages"] = account ? (openDealStagesByAccount.get(account.id) ?? []).join(",") : "";
145
+ }
146
+ return { record, view };
147
+ });
148
+ }
149
+ export function parseGuard(raw) {
150
+ const first = raw.indexOf(":");
151
+ const last = raw.lastIndexOf(":");
152
+ if (first === -1 || last === first) {
153
+ throw new Error(`Cannot parse --guard "${raw}". Use <account|contact|deal>:<where>[;<where>…]:<none|some>.`);
154
+ }
155
+ const objectType = raw.slice(0, first);
156
+ const expect = raw.slice(last + 1);
157
+ const where = raw.slice(first + 1, last).split(";").map((s) => s.trim()).filter(Boolean);
158
+ if (!["account", "contact", "deal"].includes(objectType) || !["none", "some"].includes(expect) || where.length === 0) {
159
+ throw new Error(`Cannot parse --guard "${raw}". Use <account|contact|deal>:<where>[;<where>…]:<none|some>.`);
160
+ }
161
+ // validate eagerly at plan time: a typo'd guard must fail loudly, never
162
+ // pass vacuously at apply time
163
+ assertValidFields(objectType, where.map(parseWhere), "--guard");
164
+ return { objectType: objectType, where, expect: expect, description: raw };
165
+ }
166
+ /** Ids of records matching a filter — used for apply-time filter re-verification. */
167
+ export function eligibleIds(snapshot, objectType, where) {
168
+ const clauses = where.map(parseWhere);
169
+ const views = buildViews(snapshot, objectType);
170
+ return new Set(views.filter(({ view }) => clauses.every((c) => matches(view, c))).map(({ record }) => String(record.id)));
171
+ }
172
+ /** Evaluate a plan guard against a snapshot. Returns null when satisfied, else a failure detail. */
173
+ export function evaluateGuard(snapshot, guard) {
174
+ const clauses = guard.where.map(parseWhere);
175
+ const views = buildViews(snapshot, guard.objectType);
176
+ const matchCount = views.filter(({ view }) => clauses.every((c) => matches(view, c))).length;
177
+ const ok = guard.expect === "none" ? matchCount === 0 : matchCount > 0;
178
+ if (ok)
179
+ return null;
180
+ return `Guard failed: expected ${guard.expect === "none" ? "no" : "at least one"} ${guard.objectType}(s) matching [${guard.where.join(" AND ")}], found ${matchCount}.`;
181
+ }
182
+ export function buildBulkUpdatePlan(snapshot, options) {
183
+ const maxOperations = options.maxOperations ?? 500;
184
+ if (options.where.length === 0) {
185
+ throw new Error("bulk-update requires at least one --where filter — refusing to build an unscoped mass write.");
186
+ }
187
+ const hasSet = options.set && Object.keys(options.set).length > 0;
188
+ const actions = [hasSet, options.archive, options.createTask !== undefined].filter(Boolean).length;
189
+ if (actions !== 1) {
190
+ throw new Error("bulk-update needs exactly one action: --set <field>=<value> (repeatable), --archive, or --create-task <text>.");
191
+ }
192
+ const clauses = options.where.map(parseWhere);
193
+ assertValidFields(options.objectType, clauses, "--where");
194
+ const WRITABLE_BLOCKLIST = new Set(["id", "crmId", "contactCount", "openDealCount", "openDealStages"]);
195
+ for (const field of Object.keys(options.set ?? {})) {
196
+ if (!VALID_FIELDS[options.objectType].has(field) || WRITABLE_BLOCKLIST.has(field) || field.includes(".")) {
197
+ throw new Error(`Cannot --set "${field}" on ${options.objectType}s — not a writable canonical field.`);
198
+ }
199
+ }
200
+ const views = buildViews(snapshot, options.objectType);
201
+ const matched = views.filter(({ view }) => clauses.every((c) => matches(view, c)));
202
+ if (matched.length > maxOperations) {
203
+ throw new Error(`Filter matched ${matched.length} ${COLLECTIONS[options.objectType]} — above the ${maxOperations}-record safety cap. Narrow the --where filter or raise --max-operations explicitly.`);
204
+ }
205
+ // Preconditions: explicit --require, plus every equality filter on a real
206
+ // (re-readable, non-relational) field. The premise the plan was built on
207
+ // is re-verified per record at apply time.
208
+ const writtenFields = new Set(Object.keys(options.set ?? {}));
209
+ const preconditionSpecs = [];
210
+ for (const raw of options.require ?? []) {
211
+ const clause = parseWhere(raw);
212
+ if (clause.op !== "eq" || clause.field.includes(".") || (clause.value ?? "").includes("|")) {
213
+ throw new Error(`--require must be a direct single-value field equality (field=value), got "${raw}".`);
214
+ }
215
+ assertValidFields(options.objectType, [clause], "--require");
216
+ preconditionSpecs.push({ field: clause.field, expectedValue: clause.value ?? "" });
217
+ }
218
+ for (const clause of clauses) {
219
+ if (clause.op !== "eq")
220
+ continue;
221
+ if ((clause.value ?? "").includes("|"))
222
+ continue; // alternations are not single-value preconditions
223
+ if (clause.field.includes(".") || NON_READABLE_FIELDS.has(clause.field))
224
+ continue;
225
+ if (writtenFields.has(clause.field))
226
+ continue; // beforeValue already guards it
227
+ if (preconditionSpecs.some((p) => p.field === clause.field))
228
+ continue;
229
+ preconditionSpecs.push({ field: clause.field, expectedValue: clause.value ?? "" });
230
+ }
231
+ const whereText = options.where.join(" AND ");
232
+ const action = options.archive
233
+ ? `archive`
234
+ : options.createTask !== undefined
235
+ ? `create task "${options.createTask}"`
236
+ : `set ${Object.entries(options.set).map(([k, v]) => `${k}=${v}`).join(", ")}`;
237
+ const reason = options.reason ?? `bulk-update: ${action} where ${whereText}`;
238
+ const operations = [];
239
+ for (const { record } of matched) {
240
+ const objectId = String(record.id);
241
+ const groupId = `grp_${options.objectType}_${objectId}`;
242
+ const preconditions = preconditionSpecs.map((p) => ({
243
+ field: p.field,
244
+ // expected value is the record's CURRENT canonical value, not the
245
+ // filter literal — preserves casing/format the provider will echo back
246
+ expectedValue: record[p.field] ?? p.expectedValue,
247
+ }));
248
+ const shared = {
249
+ objectType: options.objectType,
250
+ objectId,
251
+ reason,
252
+ approvalRequired: true,
253
+ sourceRuleOrPolicy: "bulk-update",
254
+ ...(preconditions.length > 0 ? { preconditions } : {}),
255
+ groupId,
256
+ };
257
+ if (options.archive) {
258
+ operations.push({
259
+ ...shared,
260
+ id: `op_${stableHash(`bulk-archive:${options.objectType}:${objectId}:${whereText}`)}`,
261
+ operation: "archive_record",
262
+ beforeValue: null,
263
+ afterValue: null,
264
+ riskLevel: "high",
265
+ rollback: "Archived records can be restored from the provider's recycle bin within its retention window.",
266
+ });
267
+ continue;
268
+ }
269
+ if (options.createTask !== undefined) {
270
+ operations.push({
271
+ ...shared,
272
+ id: `op_${stableHash(`bulk-task:${options.objectType}:${objectId}:${options.createTask}`)}`,
273
+ operation: "create_task",
274
+ field: "follow_up_task",
275
+ beforeValue: null,
276
+ afterValue: options.createTask,
277
+ riskLevel: "low",
278
+ rollback: "Delete the created task.",
279
+ });
280
+ continue;
281
+ }
282
+ for (const [field, value] of Object.entries(options.set)) {
283
+ operations.push({
284
+ ...shared,
285
+ id: `op_${stableHash(`bulk-set:${options.objectType}:${objectId}:${field}:${value}`)}`,
286
+ operation: "set_field",
287
+ field,
288
+ beforeValue: record[field] ?? null,
289
+ afterValue: value,
290
+ riskLevel: "medium",
291
+ rollback: `Set ${field} back to ${JSON.stringify(record[field] ?? null)}.`,
292
+ });
293
+ }
294
+ }
295
+ const guards = (options.guard ?? []).map(parseGuard);
296
+ // guards must hold at plan time too — building a plan whose guard already
297
+ // fails is a footgun, surface it immediately
298
+ for (const guard of guards) {
299
+ const failure = evaluateGuard(snapshot, guard);
300
+ if (failure)
301
+ throw new Error(`${failure} The guard already fails against the current snapshot — the plan would never apply.`);
302
+ }
303
+ return {
304
+ id: `patch_plan_${stableHash(`bulk:${snapshot.provider}:${snapshot.generatedAt}:${whereText}:${action}:${operations.length}`)}`,
305
+ title: `Bulk update: ${options.objectType}s where ${whereText}`,
306
+ createdAt: snapshot.generatedAt,
307
+ status: operations.length > 0 ? "needs_approval" : "draft",
308
+ dryRun: true,
309
+ summary: `${matched.length} ${COLLECTIONS[options.objectType]} matched (${whereText}); ${operations.length} proposed dry-run operations (${action}).${guards.length > 0 ? ` ${guards.length} apply-time guard(s).` : ""}`,
310
+ findings: [],
311
+ operations,
312
+ filter: { objectType: options.objectType, where: options.where },
313
+ ...(guards.length > 0 ? { guards } : {}),
314
+ };
315
+ }
package/dist/cli.js CHANGED
@@ -24,6 +24,7 @@ import { buildWorksheet, classifyMarket } from "./marketClassify.js";
24
24
  import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
25
25
  import { DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
26
26
  import { resolveRecord } from "./resolve.js";
27
+ import { buildBulkUpdatePlan } from "./bulkUpdate.js";
27
28
  import { suggestValues } from "./suggest.js";
28
29
  function usage() {
29
30
  return `FullStackGTM — audit GTM data across providers, propose reviewable patch plans,
@@ -74,6 +75,18 @@ Usage:
74
75
  against the stored capture it cites before it's accepted — then
75
76
  compute deterministic front states and drift, render the field
76
77
  report. refresh = capture → classify → drift → report in one step
78
+ 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>]
79
+ governed generic writes: filter the snapshot
80
+ (field=value, field!=value, field~substr, field!~substr,
81
+ field:empty, field:notempty, '|' = any-of; canonical fields
82
+ like ownerId, stage, closeDate, amount; relational
83
+ pseudo-fields account.name/domain/ownerId/contactCount/
84
+ openDealStages on deals and contacts, contactCount/
85
+ openDealCount/openDealStages on accounts) into a dry-run
86
+ patch plan. The full filter is re-verified per record at
87
+ apply time (incl. mid-apply rechecks); equality filters
88
+ double as preconditions; per-record ops apply
89
+ all-or-nothing; guards assert cross-record conditions.
77
90
  fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
78
91
  derive values for requires_human_* placeholders
79
92
  from snapshot evidence, with confidence + reasons
@@ -751,7 +764,9 @@ function buildCallPlan(parsed, deal, proposed, current, extraNextSteps) {
751
764
  async function marketCommand(args) {
752
765
  const [subcommand, ...rest] = args;
753
766
  const configPath = () => resolve(process.cwd(), option(rest, "--config") ?? "market.config.json");
754
- if (!subcommand || subcommand === "--help") {
767
+ // Catch --help anywhere before loadMarketConfig/credential checks run —
768
+ // several subcommands (capture, refresh) have side effects on bare invocation.
769
+ if (!subcommand || subcommand === "--help" || subcommand === "-h" || rest.includes("--help") || rest.includes("-h")) {
755
770
  console.log(`Usage:
756
771
  market init --category <name> [--out <path>] write a starter market.config.json
757
772
  market capture [--config <path>] [--run <label>]
@@ -1001,6 +1016,51 @@ async function resolveCommand(args) {
1001
1016
  if (result.verdict !== "safe_to_create")
1002
1017
  process.exitCode = 2;
1003
1018
  }
1019
+ /**
1020
+ * Governed generic writes: build a dry-run patch plan from a snapshot filter
1021
+ * plus field assignments (or --archive). Never writes — approve and apply the
1022
+ * plan like any audit plan; compare-and-set protects every operation.
1023
+ */
1024
+ async function bulkUpdateCommand(args) {
1025
+ const [objectType, ...rest] = args;
1026
+ if (!objectType || !["account", "contact", "deal"].includes(objectType)) {
1027
+ throw new Error("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]");
1028
+ }
1029
+ const where = repeatedOption(rest, "--where");
1030
+ const set = {};
1031
+ for (const pair of repeatedOption(rest, "--set")) {
1032
+ const separator = pair.indexOf("=");
1033
+ if (separator === -1)
1034
+ throw new Error(`--set must look like <field>=<value>, got "${pair}"`);
1035
+ set[pair.slice(0, separator)] = pair.slice(separator + 1);
1036
+ }
1037
+ const snapshot = await readSnapshot(rest);
1038
+ const plan = buildBulkUpdatePlan(snapshot, {
1039
+ objectType: objectType,
1040
+ where,
1041
+ set: Object.keys(set).length > 0 ? set : undefined,
1042
+ archive: rest.includes("--archive"),
1043
+ createTask: option(rest, "--create-task") ?? undefined,
1044
+ require: repeatedOption(rest, "--require"),
1045
+ guard: repeatedOption(rest, "--guard"),
1046
+ reason: option(rest, "--reason") ?? undefined,
1047
+ maxOperations: numericOption(rest, "--max-operations"),
1048
+ });
1049
+ const out = option(rest, "--out");
1050
+ if (out) {
1051
+ writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(plan, null, 2)}\n`);
1052
+ }
1053
+ if (rest.includes("--save")) {
1054
+ await createFilePlanStore().save(plan);
1055
+ console.error(`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>\`.`);
1056
+ }
1057
+ if (rest.includes("--json")) {
1058
+ console.log(JSON.stringify(plan, null, 2));
1059
+ }
1060
+ else {
1061
+ console.log(patchPlanToMarkdown(plan));
1062
+ }
1063
+ }
1004
1064
  async function suggest(args) {
1005
1065
  const planId = option(args, "--plan-id");
1006
1066
  const planPath = option(args, "--plan");
@@ -1710,6 +1770,13 @@ export async function runCli(argv) {
1710
1770
  console.log(readPackageInfo().version);
1711
1771
  return;
1712
1772
  }
1773
+ // Commands without bespoke help fall back to the top-level usage on --help
1774
+ // instead of executing (audit used to silently run the sample audit).
1775
+ // call/market/bulk-update print their own richer help.
1776
+ if (!["call", "market", "bulk-update"].includes(command) && (args.includes("--help") || args.includes("-h"))) {
1777
+ console.log(usage());
1778
+ return;
1779
+ }
1713
1780
  if (command === "login") {
1714
1781
  await login(args);
1715
1782
  return;
@@ -1750,6 +1817,10 @@ export async function runCli(argv) {
1750
1817
  await resolveCommand(args);
1751
1818
  return;
1752
1819
  }
1820
+ if (command === "bulk-update") {
1821
+ await bulkUpdateCommand(args);
1822
+ return;
1823
+ }
1753
1824
  if (command === "market") {
1754
1825
  await marketCommand(args);
1755
1826
  return;
@@ -18,6 +18,12 @@ export type ApplyPatchPlanOptions = {
18
18
  * `readField`.
19
19
  */
20
20
  checkConflicts?: boolean;
21
+ /**
22
+ * For plans carrying a filter or guards: re-run the snapshot checks after
23
+ * the first applied write and then every N applied writes, so a record
24
+ * edited mid-apply is conflicted out instead of overwritten. Default 25.
25
+ */
26
+ recheckEvery?: number;
21
27
  };
22
28
  /**
23
29
  * Apply an approved subset of a patch plan through a connector.