fullstackgtm 0.25.1 → 0.26.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/dist/bulkUpdate.js +6 -1
  3. package/dist/cli.js +67 -2
  4. package/dist/connector.js +90 -1
  5. package/dist/connectors/hubspot.js +5 -2
  6. package/dist/connectors/salesforce.js +4 -2
  7. package/dist/connectors/stripe.js +4 -2
  8. package/dist/credentials.js +22 -1
  9. package/dist/dedupe.d.ts +6 -0
  10. package/dist/dedupe.js +24 -1
  11. package/dist/enrich.js +24 -2
  12. package/dist/enrichApollo.js +5 -2
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +1 -0
  15. package/dist/integrity.d.ts +30 -0
  16. package/dist/integrity.js +128 -0
  17. package/dist/market.d.ts +1 -0
  18. package/dist/market.js +144 -8
  19. package/dist/marketReport.d.ts +9 -0
  20. package/dist/marketReport.js +29 -4
  21. package/dist/marketTaxonomy.d.ts +41 -0
  22. package/dist/marketTaxonomy.js +193 -0
  23. package/dist/planStore.d.ts +6 -0
  24. package/dist/planStore.js +10 -2
  25. package/dist/schedule.d.ts +17 -0
  26. package/dist/schedule.js +87 -2
  27. package/dist/types.d.ts +16 -0
  28. package/package.json +1 -1
  29. package/src/bulkUpdate.ts +6 -1
  30. package/src/cli.ts +80 -1
  31. package/src/connector.ts +96 -1
  32. package/src/connectors/hubspot.ts +5 -2
  33. package/src/connectors/salesforce.ts +4 -2
  34. package/src/connectors/stripe.ts +4 -2
  35. package/src/credentials.ts +24 -0
  36. package/src/dedupe.ts +23 -1
  37. package/src/enrich.ts +25 -2
  38. package/src/enrichApollo.ts +5 -2
  39. package/src/index.ts +8 -0
  40. package/src/integrity.ts +146 -0
  41. package/src/market.ts +129 -8
  42. package/src/marketReport.ts +30 -4
  43. package/src/marketTaxonomy.ts +288 -0
  44. package/src/planStore.ts +23 -4
  45. package/src/schedule.ts +98 -2
  46. package/src/types.ts +16 -0
@@ -0,0 +1,288 @@
1
+ import {
2
+ DEFAULT_MODELS,
3
+ forcedToolCall,
4
+ type LlmCallOptions,
5
+ } from "./llm.ts";
6
+ import {
7
+ captureMarket,
8
+ type FetchPage,
9
+ loadCaptureTexts,
10
+ type MarketClaim,
11
+ type MarketConfig,
12
+ type MarketVendor,
13
+ } from "./market.ts";
14
+
15
+ /**
16
+ * Cold-start taxonomy bootstrap. `market init` writes a stub for a human
17
+ * analyst to fill in; the self-serve hosted map has no analyst in the loop, so
18
+ * this proposes the claim taxonomy automatically from the seed vendors' own
19
+ * pages.
20
+ *
21
+ * Posture matches the rest of the market layer: the LLM is a *proposal* layer
22
+ * grounded in captured evidence (it only sees text we actually fetched), and
23
+ * everything downstream — capture, classify with verbatim-span verification,
24
+ * front states, the report — stays deterministic over the stored observations.
25
+ * The taxonomy it emits is a normal `market.config.json` a human can still edit.
26
+ */
27
+
28
+ export type SeedVendor = {
29
+ url: string;
30
+ /** Display name; derived from the host when omitted. */
31
+ name?: string;
32
+ /** Marks the user's own company as the anchor vendor. */
33
+ anchor?: boolean;
34
+ };
35
+
36
+ export type SuggestTaxonomyOptions = {
37
+ category: string;
38
+ vendors: SeedVendor[];
39
+ llm: LlmCallOptions;
40
+ /** Upper bound on proposed claims, to keep classification bounded. */
41
+ maxClaims?: number;
42
+ /** Per-vendor captured-text budget fed to the proposer (chars). */
43
+ perVendorChars?: number;
44
+ /** Test injectables. */
45
+ fetchPage?: FetchPage;
46
+ capturesDir?: string;
47
+ now?: () => Date;
48
+ };
49
+
50
+ export type SuggestTaxonomyResult = {
51
+ config: MarketConfig;
52
+ /** Vendors whose homepage capture was empty/failed (excluded from grounding). */
53
+ unreadableVendorIds: string[];
54
+ model: string;
55
+ };
56
+
57
+ const DEFAULT_MAX_CLAIMS = 16;
58
+ const DEFAULT_PER_VENDOR_CHARS = 6_000;
59
+
60
+ /** Stable, human-readable id from a string (claim capability or host). */
61
+ function slugify(value: string, maxWords = 6): string {
62
+ const slug = value
63
+ .toLowerCase()
64
+ .replace(/[^a-z0-9]+/g, "-")
65
+ .replace(/^-+|-+$/g, "")
66
+ .split("-")
67
+ .filter(Boolean)
68
+ .slice(0, maxWords)
69
+ .join("-");
70
+ return slug || "item";
71
+ }
72
+
73
+ /** Second-level domain as a vendor id seed: https://www.stripe.com/ -> stripe. */
74
+ function vendorIdFromUrl(url: string): string {
75
+ let host: string;
76
+ try {
77
+ host = new URL(url).hostname;
78
+ } catch {
79
+ return slugify(url);
80
+ }
81
+ const labels = host.replace(/^www\./, "").split(".");
82
+ const sld = labels.length >= 2 ? labels[labels.length - 2] : labels[0];
83
+ return slugify(sld || host);
84
+ }
85
+
86
+ /** Disambiguate repeated ids by suffixing -2, -3, … */
87
+ function uniqueId(base: string, taken: Set<string>): string {
88
+ if (!taken.has(base)) {
89
+ taken.add(base);
90
+ return base;
91
+ }
92
+ for (let n = 2; ; n += 1) {
93
+ const candidate = `${base}-${n}`;
94
+ if (!taken.has(candidate)) {
95
+ taken.add(candidate);
96
+ return candidate;
97
+ }
98
+ }
99
+ }
100
+
101
+ function provisionalVendors(seeds: SeedVendor[]): MarketVendor[] {
102
+ const taken = new Set<string>();
103
+ return seeds.map((seed) => {
104
+ const id = uniqueId(vendorIdFromUrl(seed.url), taken);
105
+ const host = (() => {
106
+ try {
107
+ return new URL(seed.url).hostname.replace(/^www\./, "");
108
+ } catch {
109
+ return seed.url;
110
+ }
111
+ })();
112
+ return {
113
+ id,
114
+ name: seed.name?.trim() || host,
115
+ urls: { home: seed.url, pricing: null, product: [] },
116
+ };
117
+ });
118
+ }
119
+
120
+ type ProposedClaim = {
121
+ capability: string;
122
+ icp: string;
123
+ pricingStructure: string;
124
+ definition: string;
125
+ terms?: string[];
126
+ };
127
+
128
+ type ProposedVendor = { seedUrl: string; name?: string; pricingUrl?: string | null };
129
+
130
+ const TAXONOMY_SCHEMA = {
131
+ type: "object",
132
+ required: ["claims"],
133
+ properties: {
134
+ surfaceRule: {
135
+ type: "string",
136
+ description:
137
+ "One sentence stating how a reader judges LOUD vs QUIET vs ABSENT for this category (e.g. hero/top-nav = LOUD, deeper pages = QUIET, nowhere = ABSENT).",
138
+ },
139
+ claims: {
140
+ type: "array",
141
+ description:
142
+ "The distinct capability positions vendors in this category compete on. 8-16 of them. Only include claims you can actually see evidence for on the supplied pages.",
143
+ items: {
144
+ type: "object",
145
+ required: ["capability", "icp", "pricingStructure", "definition"],
146
+ properties: {
147
+ capability: {
148
+ type: "string",
149
+ description: "What is being claimed, precise enough to judge loud/quiet/absent. Max ~10 words.",
150
+ },
151
+ icp: { type: "string", description: "Which buyer/ICP this claim cell addresses (category vocabulary)." },
152
+ pricingStructure: {
153
+ type: "string",
154
+ description: "Which pricing structure the claim implies (e.g. per-seat, usage-based, flat, free-tier).",
155
+ },
156
+ definition: {
157
+ type: "string",
158
+ description:
159
+ "Operational definition a human (or classifier) uses to score any vendor's page LOUD/QUIET/ABSENT on this claim.",
160
+ },
161
+ terms: {
162
+ type: "array",
163
+ items: { type: "string" },
164
+ description: "Exact buyer phrasings for this claim, for deterministic mention matching. 2-5 terms.",
165
+ },
166
+ },
167
+ },
168
+ },
169
+ vendors: {
170
+ type: "array",
171
+ description: "Optional refinements: a clean display name per seed URL, and a pricing-page URL if one is clearly linked.",
172
+ items: {
173
+ type: "object",
174
+ required: ["seedUrl"],
175
+ properties: {
176
+ seedUrl: { type: "string" },
177
+ name: { type: "string" },
178
+ pricingUrl: { type: ["string", "null"] },
179
+ },
180
+ },
181
+ },
182
+ },
183
+ } as const;
184
+
185
+ function buildDossier(
186
+ vendors: MarketVendor[],
187
+ capture: ReturnType<typeof loadCaptureTexts>,
188
+ perVendorChars: number,
189
+ ): { dossier: string; unreadable: string[] } {
190
+ const { entries, textByHash } = capture;
191
+ const unreadable: string[] = [];
192
+ const blocks: string[] = [];
193
+ for (const vendor of vendors) {
194
+ const hash = entries.find((e) => e.vendorId === vendor.id && e.captureHash)?.captureHash ?? null;
195
+ const text = hash ? textByHash.get(hash) ?? "" : "";
196
+ if (!text.trim()) {
197
+ unreadable.push(vendor.id);
198
+ continue;
199
+ }
200
+ blocks.push(`### ${vendor.name} (${vendor.urls.home})\n${text.slice(0, perVendorChars)}`);
201
+ }
202
+ return { dossier: blocks.join("\n\n"), unreadable };
203
+ }
204
+
205
+ const INSTRUCTIONS = `You are seeding a competitive "market map" for a category. A market map breaks the category into CLAIMS — the distinct capability positions vendors compete on — so each (vendor x claim) cell can later be scored LOUD / QUIET / ABSENT from that vendor's pages.
206
+
207
+ Propose the claim taxonomy for this category from the competitor homepages below. Rules:
208
+ - Ground every claim in what is actually visible on the supplied pages. Do not invent positions no vendor mentions.
209
+ - Each claim is a cell: a precise capability, the ICP it targets, and the pricing structure it implies.
210
+ - Write each definition so a reader could judge ANY vendor's page LOUD/QUIET/ABSENT against it.
211
+ - Aim for the 8-16 claims that genuinely differentiate vendors. Prefer specific, contested positions over generic table stakes.
212
+ - Provide 2-5 verbatim buyer terms per claim for later mention matching.
213
+ - Optionally return a cleaned display name and a pricing-page URL per seed vendor when evident.`;
214
+
215
+ export async function suggestMarketConfig(options: SuggestTaxonomyOptions): Promise<SuggestTaxonomyResult> {
216
+ const { category } = options;
217
+ if (options.vendors.length === 0) throw new Error("suggestMarketConfig requires at least one seed vendor");
218
+ const maxClaims = options.maxClaims ?? DEFAULT_MAX_CLAIMS;
219
+ const perVendorChars = options.perVendorChars ?? DEFAULT_PER_VENDOR_CHARS;
220
+ const model = options.llm.model ?? DEFAULT_MODELS[options.llm.provider];
221
+
222
+ const vendors = provisionalVendors(options.vendors);
223
+ const anchorSeed = options.vendors.find((seed) => seed.anchor);
224
+ const anchorId = anchorSeed ? vendors[options.vendors.indexOf(anchorSeed)]?.id : undefined;
225
+
226
+ // Capture the seed homepages so the proposer only sees text we actually
227
+ // fetched (the SSRF guard in captureMarket applies to these user-supplied URLs).
228
+ await captureMarket(
229
+ { category, vendors, claims: [] },
230
+ { dir: options.capturesDir, runLabel: "bootstrap", fetchPage: options.fetchPage, now: options.now },
231
+ );
232
+ const capture = loadCaptureTexts(category, options.capturesDir);
233
+ const { dossier, unreadable } = buildDossier(vendors, capture, perVendorChars);
234
+ if (!dossier.trim()) {
235
+ throw new Error(
236
+ `market init --auto: none of the ${vendors.length} seed pages returned readable text — check the URLs are public homepages.`,
237
+ );
238
+ }
239
+
240
+ const prompt = `${INSTRUCTIONS}\n\nCategory: ${category}\n\nCompetitor homepages:\n${dossier}`;
241
+ const result = (await forcedToolCall(prompt, "propose_market_taxonomy", TAXONOMY_SCHEMA, model, options.llm)) as {
242
+ surfaceRule?: string;
243
+ claims?: ProposedClaim[];
244
+ vendors?: ProposedVendor[];
245
+ };
246
+
247
+ const takenClaimIds = new Set<string>();
248
+ const claims: MarketClaim[] = (result.claims ?? [])
249
+ .filter((claim) => claim?.capability && claim?.definition)
250
+ .slice(0, maxClaims)
251
+ .map((claim) => ({
252
+ id: uniqueId(slugify(claim.capability), takenClaimIds),
253
+ capability: claim.capability.trim(),
254
+ icp: (claim.icp ?? "").trim() || "general",
255
+ pricingStructure: (claim.pricingStructure ?? "").trim() || "unspecified",
256
+ definition: claim.definition.trim(),
257
+ ...(claim.terms?.length ? { terms: claim.terms.map((t) => t.trim()).filter(Boolean) } : {}),
258
+ }));
259
+
260
+ if (claims.length === 0) {
261
+ throw new Error("market init --auto: the model proposed no usable claims — try again or seed the taxonomy by hand.");
262
+ }
263
+
264
+ // Apply optional vendor refinements (display name + pricing URL), matched by seed URL.
265
+ const refinementByUrl = new Map((result.vendors ?? []).map((v) => [v.seedUrl, v]));
266
+ const refinedVendors: MarketVendor[] = vendors.map((vendor) => {
267
+ const refinement = refinementByUrl.get(vendor.urls.home);
268
+ const pricing =
269
+ refinement?.pricingUrl && /^https?:\/\//i.test(refinement.pricingUrl) ? refinement.pricingUrl : vendor.urls.pricing;
270
+ return {
271
+ ...vendor,
272
+ name: refinement?.name?.trim() || vendor.name,
273
+ urls: { ...vendor.urls, pricing },
274
+ };
275
+ });
276
+
277
+ const config: MarketConfig = {
278
+ category,
279
+ ...(anchorId ? { anchorVendor: anchorId } : {}),
280
+ vendors: refinedVendors,
281
+ claims,
282
+ surfaceRule:
283
+ result.surfaceRule?.trim() ||
284
+ "LOUD = hero copy OR top-level-nav named product with a dedicated page; QUIET = present on any indexed page below that; ABSENT = nowhere observed; UNOBSERVABLE = capture empty/failed — never score ABSENT from a failed capture.",
285
+ };
286
+
287
+ return { config, unreadableVendorIds: unreadable, model };
288
+ }
package/src/planStore.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { chmodSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.ts";
4
+ import { computeApprovalDigests, loadOrCreateSigningKey } from "./integrity.ts";
4
5
  import type { ApprovalStatus, PatchPlan, PatchPlanRun } from "./types.ts";
5
6
 
6
7
  /**
@@ -16,6 +17,12 @@ export type StoredPlan = {
16
17
  status: ApprovalStatus;
17
18
  approvedOperationIds: string[];
18
19
  valueOverrides: Record<string, unknown>;
20
+ /**
21
+ * HMAC of each approved operation's content at approval time (see
22
+ * integrity.ts). Apply re-verifies these so a post-approval edit to the plan
23
+ * file is caught instead of written. Absent on plans approved before 0.26.0.
24
+ */
25
+ approvalDigests?: Record<string, string>;
19
26
  runs: PatchPlanRun[];
20
27
  createdAt: string;
21
28
  updatedAt: string;
@@ -125,13 +132,25 @@ export function createFilePlanStore(directory?: string): PlanStore {
125
132
  throw new Error(`Plan ${planId} has no operation ${operationId}.`);
126
133
  }
127
134
  }
135
+ const approvedOperationIds = Array.from(
136
+ new Set([...stored.approvedOperationIds, ...operationIds]),
137
+ );
138
+ const mergedOverrides = { ...stored.valueOverrides, ...valueOverrides };
139
+ // Bind the approval to the operation content so apply can detect a
140
+ // post-approval edit. Recompute over ALL approved ops (a later approve
141
+ // call may add overrides that change an earlier op's resolved value).
142
+ const approvalDigests = computeApprovalDigests(
143
+ stored.plan.operations,
144
+ approvedOperationIds,
145
+ mergedOverrides,
146
+ loadOrCreateSigningKey(),
147
+ );
128
148
  return write({
129
149
  ...stored,
130
150
  status: "approved",
131
- approvedOperationIds: Array.from(
132
- new Set([...stored.approvedOperationIds, ...operationIds]),
133
- ),
134
- valueOverrides: { ...stored.valueOverrides, ...valueOverrides },
151
+ approvedOperationIds,
152
+ valueOverrides: mergedOverrides,
153
+ approvalDigests,
135
154
  });
136
155
  },
137
156
 
package/src/schedule.ts CHANGED
@@ -124,6 +124,12 @@ export function validateSchedulableArgv(argv: string[]): void {
124
124
  "plan store's approval state. Use `apply --plan-id <id>` and approve via `plans approve`.",
125
125
  );
126
126
  }
127
+ if (argv.includes("--value")) {
128
+ throw new Error(
129
+ "A scheduled apply cannot take --value — an unattended run must write exactly the values " +
130
+ "signed at approval. Set the value with `plans approve --value <op>=<v>` and re-approve.",
131
+ );
132
+ }
127
133
  return;
128
134
  }
129
135
  if (!Object.hasOwn(SCHEDULABLE, head)) {
@@ -145,6 +151,75 @@ export function validateSchedulableArgv(argv: string[]): void {
145
151
  }
146
152
  }
147
153
 
154
+ /**
155
+ * A schedule label is free text the operator chooses, but it is later
156
+ * interpolated into a crontab comment line by `renderManagedBlock`. A newline
157
+ * (or carriage return) would break out of the comment and inject an arbitrary
158
+ * crontab entry on `schedule install`. Reject control characters at the entry
159
+ * point so a label can never carry a second line; `renderManagedBlock` also
160
+ * strips them defensively in case a hand-edited schedules.json slips one past.
161
+ */
162
+ export function assertSingleLineLabel(label: string): void {
163
+ if (hasControlChar(label)) {
164
+ throw new Error(
165
+ "A schedule --label cannot contain newlines or control characters " +
166
+ "(they would inject lines into the managed crontab block). Use a plain single-line name.",
167
+ );
168
+ }
169
+ }
170
+
171
+ /**
172
+ * True if the string contains any line-breaking or control character. Covers
173
+ * C0 controls + DEL, plus the Unicode separators a non-cron parser might honor
174
+ * (NEL U+0085, LS U+2028, PS U+2029, VT U+000B, FF U+000C) — defense-in-depth
175
+ * for the future modal/aws scaffold renderers whose target formats may treat
176
+ * those as line breaks.
177
+ */
178
+ export function hasControlChar(value: string): boolean {
179
+ for (let i = 0; i < value.length; i++) {
180
+ const code = value.charCodeAt(i);
181
+ if (code < 0x20 || code === 0x7f || code === 0x85 || code === 0x2028 || code === 0x2029) return true;
182
+ }
183
+ return false;
184
+ }
185
+
186
+ /** Collapse any control/separator character to a space — last-resort guard at render time. */
187
+ function sanitizeCrontabComment(value: string): string {
188
+ let out = "";
189
+ for (const ch of value) {
190
+ const code = ch.charCodeAt(0);
191
+ out += code < 0x20 || code === 0x7f || code === 0x85 || code === 0x2028 || code === 0x2029 ? " " : ch;
192
+ }
193
+ return out.replace(/ {2,}/g, " ").trim();
194
+ }
195
+
196
+ /**
197
+ * Validate every field of an entry that `renderManagedBlock` interpolates into
198
+ * the crontab — not just the label. The EXECUTABLE line embeds `cron` and `id`
199
+ * raw, and `schedule install` renders entries straight from schedules.json, so
200
+ * a hand-edited (or otherwise tampered) entry with a newline in cron/id/profile
201
+ * would inject a live crontab line. Refuse to render a tampered entry rather
202
+ * than emit it. (Well-formed entries never trip this: cron is parser-validated,
203
+ * id is an fnv1a hex hash, label is guarded at add-time.)
204
+ */
205
+ function assertRenderableEntry(profile: string, entry: ScheduleEntry): void {
206
+ const fields: Array<[string, string]> = [
207
+ ["profile", profile],
208
+ ["cron", entry.cron],
209
+ ["id", entry.id],
210
+ ["label", entry.label],
211
+ ...entry.argv.map((token, i) => [`argv[${i}]`, token] as [string, string]),
212
+ ];
213
+ for (const [name, value] of fields) {
214
+ if (hasControlChar(value)) {
215
+ throw new Error(
216
+ `Refusing to render schedule entry ${entry.id}: its ${name} contains a newline or control character. ` +
217
+ "The schedules.json store has been tampered with or corrupted — repair it before installing.",
218
+ );
219
+ }
220
+ }
221
+ }
222
+
148
223
  /**
149
224
  * Split a `schedule add "<command>"` string into argv, honoring single and
150
225
  * double quotes (no escapes, no expansion — this is tokenization, not shell).
@@ -206,7 +281,13 @@ const CRON_FIELD_SPECS = [
206
281
  ] as const;
207
282
 
208
283
  export function parseCron(expression: string): CronExpression {
209
- const fields = expression.trim().split(/\s+/);
284
+ // Reject non-ASCII whitespace and control chars: JS \s splits on U+00A0,
285
+ // U+3000, etc., but Vixie cron's field separator is only space/tab. A source
286
+ // carrying them would parse here yet be misparsed or rejected by `crontab -`.
287
+ if (hasControlChar(expression) || /[^\x20-\x7e]/.test(expression)) {
288
+ throw new Error(`Invalid cron expression "${expression}": only ASCII characters, space, and tab are allowed.`);
289
+ }
290
+ const fields = expression.trim().split(/[ \t]+/);
210
291
  if (fields.length !== 5) {
211
292
  throw new Error(
212
293
  `Invalid cron expression "${expression}": expected 5 fields ` +
@@ -559,13 +640,28 @@ export function renderManagedBlock(
559
640
  entries: ScheduleEntry[],
560
641
  cliInvocation: string,
561
642
  ): string {
643
+ // cliInvocation is spliced raw into the executable line; it is built from
644
+ // process.execPath, the script path, and FSGTM_HOME (cli.ts), so a newline in
645
+ // FSGTM_HOME would inject a crontab line. Validate it like the entry fields —
646
+ // single-quote shell-escaping does NOT defend cron's line parser.
647
+ if (hasControlChar(cliInvocation)) {
648
+ throw new Error(
649
+ "Refusing to render the managed crontab: the resolved CLI invocation (node path, script path, " +
650
+ "or FSGTM_HOME) contains a newline or control character. Check $FSGTM_HOME.",
651
+ );
652
+ }
562
653
  const { open, close } = crontabSentinels(profile);
563
654
  const lines = [
564
655
  open,
565
656
  "# Managed by `fullstackgtm schedule install` — replaced wholesale on re-install; do not edit.",
566
657
  ];
567
658
  for (const entry of entries) {
568
- lines.push(`# ${entry.label} (${entry.id}): ${entry.argv.join(" ")}`);
659
+ // Refuse to render any entry whose interpolated fields carry a control char
660
+ // — the executable line below embeds cron/id raw, so a tampered store could
661
+ // otherwise inject a live crontab line. The comment line is additionally
662
+ // sanitized so a benign-but-messy label can't break it.
663
+ assertRenderableEntry(profile, entry);
664
+ lines.push(sanitizeCrontabComment(`# ${entry.label} (${entry.id}): ${entry.argv.join(" ")}`));
569
665
  lines.push(`${entry.cron} ${cliInvocation} schedule run ${entry.id} --profile ${profile} --trigger cron`);
570
666
  }
571
667
  lines.push(close);
package/src/types.ts CHANGED
@@ -303,6 +303,22 @@ export type PatchOperation = {
303
303
  * member of the group.
304
304
  */
305
305
  groupId?: string;
306
+ /**
307
+ * Set only when a human explicitly chose to archive a record that shares an
308
+ * identity key with another (`bulk-update --archive --force-archive-duplicates`).
309
+ * Without it, apply refuses to archive_record a record the live snapshot still
310
+ * sees as a duplicate — archiving a duplicate discards data that merging keeps,
311
+ * and an agent on a dedupe task must not silently substitute archive for merge.
312
+ */
313
+ forceArchiveDuplicate?: boolean;
314
+ /**
315
+ * For irreversible operations (merge_records, archive_record): the field
316
+ * values of the records that will be destroyed, captured at plan-build time.
317
+ * Merges and archives cannot be undone on any provider, so this is the
318
+ * recovery artifact a human uses to recreate a record by hand if a merge or
319
+ * archive was wrong — the plan file IS the backup.
320
+ */
321
+ recoverySnapshot?: Record<string, unknown>[];
306
322
  };
307
323
 
308
324
  /**